Add /v2/accounts/data_report

This commit is contained in:
Chris Eager
2023-03-20 18:40:52 -05:00
committed by Chris Eager
parent 890293e429
commit 6075d5137b
3 changed files with 363 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
@@ -20,17 +21,25 @@ import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableSet;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
@@ -57,10 +66,13 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccou
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockError;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.entities.AccountDataReportResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
@@ -68,6 +80,7 @@ import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptio
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.Device;
@@ -75,6 +88,7 @@ import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsMan
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.Util;
@ExtendWith(DropwizardExtensionsSupport.class)
class AccountControllerV2Test {
@@ -441,14 +455,203 @@ class AccountControllerV2Test {
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(
"""
{
"discoverableByPhoneNumber": null
}
"""));
"""
{
"discoverableByPhoneNumber": null
}
"""));
assertThat(response.getStatus()).isEqualTo(422);
verify(AuthHelper.VALID_ACCOUNT, never()).setDiscoverableByPhoneNumber(anyBoolean());
}
@ParameterizedTest
@MethodSource
void testGetAccountDataReport(final Account account, final String expectedTextAfterHeader) throws Exception {
when(AuthHelper.ACCOUNTS_MANAGER.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));
final Response response = resources.getJerseyTest()
.target("/v2/accounts/data_report")
.request()
.header("Authorization", AuthHelper.getAuthHeader(account.getUuid(), "password"))
.get();
assertEquals(200, response.getStatus());
final String stringResponse = response.readEntity(String.class);
final AccountDataReportResponse structuredResponse = SystemMapper.jsonMapper()
.readValue(stringResponse, AccountDataReportResponse.class);
assertEquals(account.getNumber(), structuredResponse.data().account().phoneNumber());
assertEquals(account.isDiscoverableByPhoneNumber(),
structuredResponse.data().account().findAccountByPhoneNumber());
assertEquals(account.isUnrestrictedUnidentifiedAccess(),
structuredResponse.data().account().allowSealedSenderFromAnyone());
final Set<Long> deviceIds = account.getDevices().stream().map(Device::getId).collect(Collectors.toSet());
// all devices should be present
structuredResponse.data().devices().forEach(deviceDataReport -> {
assertTrue(deviceIds.remove(deviceDataReport.id()));
assertEquals(account.getDevice(deviceDataReport.id()).orElseThrow().getUserAgent(),
deviceDataReport.userAgent());
});
assertTrue(deviceIds.isEmpty());
final String actualText = (String) SystemMapper.jsonMapper().readValue(stringResponse, Map.class).get("text");
final int headerEnd = actualText.indexOf("# Account");
assertEquals(expectedTextAfterHeader, actualText.substring(headerEnd));
final String actualHeader = actualText.substring(0, headerEnd);
assertTrue(actualHeader.matches(
"Report ID: [a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\nReport timestamp: \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z\n\n"));
}
static Stream<Arguments> testGetAccountDataReport() {
final String exampleNumber1 = toE164(PhoneNumberUtil.getInstance().getExampleNumber("ES"));
final String account2PhoneNumber = toE164(PhoneNumberUtil.getInstance().getExampleNumber("AU"));
final String account3PhoneNumber = toE164(PhoneNumberUtil.getInstance().getExampleNumber("IN"));
final Instant account1Device1Created = Instant.ofEpochSecond(1669323142); // 2022-11-24T20:52:22Z
final Instant account1Device2Created = Instant.ofEpochSecond(1679155122); // 2023-03-18T15:58:42Z
final Instant account1Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis());
final Instant account1Device2LastSeen = Instant.ofEpochSecond(1678838400); // 2023-03-15T00:00:00Z
final Instant account2Device1Created = Instant.ofEpochSecond(1659123001); // 2022-07-29T19:30:01Z
final Instant account2Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis());
final Instant badgeAExpiration = Instant.now().plus(Duration.ofDays(21)).truncatedTo(ChronoUnit.SECONDS);
final Instant account3Device1Created = Instant.ofEpochSecond(1639923487); // 2021-12-19T14:18:07Z
final Instant account3Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis());
final Instant badgeBExpiration = Instant.now().plus(Duration.ofDays(21)).truncatedTo(ChronoUnit.SECONDS);
final Instant badgeCExpiration = Instant.now().plus(Duration.ofDays(24)).truncatedTo(ChronoUnit.SECONDS);
return Stream.of(
Arguments.of(
buildTestAccountForDataReport(UUID.randomUUID(), exampleNumber1,
true, true,
Collections.emptyList(),
List.of(new DeviceData(1, account1Device1LastSeen, account1Device1Created, null),
new DeviceData(2, account1Device2LastSeen, account1Device2Created, "OWP"))),
String.format("""
# Account
Phone number: %s
Allow sealed sender from anyone: true
Find account by phone number: true
Badges: None
# Devices
- ID: 1
Created: 2022-11-24T20:52:22Z
Last seen: %s
User-agent: null
- ID: 2
Created: 2023-03-18T15:58:42Z
Last seen: 2023-03-15T00:00:00Z
User-agent: OWP
""",
exampleNumber1,
account1Device1LastSeen)
),
Arguments.of(
buildTestAccountForDataReport(UUID.randomUUID(), account2PhoneNumber,
false, true,
List.of(new AccountBadge("badge_a", badgeAExpiration, true)),
List.of(new DeviceData(1, account2Device1LastSeen, account2Device1Created, "OWI"))),
String.format("""
# Account
Phone number: %s
Allow sealed sender from anyone: false
Find account by phone number: true
Badges:
- ID: badge_a
Expiration: %s
Visible: true
# Devices
- ID: 1
Created: 2022-07-29T19:30:01Z
Last seen: %s
User-agent: OWI
""", account2PhoneNumber,
badgeAExpiration,
account2Device1LastSeen)
),
Arguments.of(
buildTestAccountForDataReport(UUID.randomUUID(), account3PhoneNumber,
true, false,
List.of(
new AccountBadge("badge_b", badgeBExpiration, true),
new AccountBadge("badge_c", badgeCExpiration, false)),
List.of(new DeviceData(1, account3Device1LastSeen, account3Device1Created, "OWA"))),
String.format("""
# Account
Phone number: %s
Allow sealed sender from anyone: true
Find account by phone number: false
Badges:
- ID: badge_b
Expiration: %s
Visible: true
- ID: badge_c
Expiration: %s
Visible: false
# Devices
- ID: 1
Created: 2021-12-19T14:18:07Z
Last seen: %s
User-agent: OWA
""", account3PhoneNumber,
badgeBExpiration,
badgeCExpiration,
account3Device1LastSeen)
)
);
}
/**
* Creates an {@link Account} with data sufficient for
* {@link AccountControllerV2#getAccountDataReport(AuthenticatedAccount)}.
* <p>
* Note: All devices will have a {@link SaltedTokenHash} for "password"
*/
static Account buildTestAccountForDataReport(final UUID aci, final String number,
final boolean unrestrictedUnidentifiedAccess, final boolean discoverableByPhoneNumber,
List<AccountBadge> badges, List<DeviceData> devices) {
final Account account = new Account();
account.setUuid(aci);
account.setNumber(number, UUID.randomUUID());
account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess);
account.setDiscoverableByPhoneNumber(discoverableByPhoneNumber);
account.setBadges(Clock.systemUTC(), new ArrayList<>(badges));
assert !devices.isEmpty();
final SaltedTokenHash passwordTokenHash = SaltedTokenHash.generateFor("password");
devices.forEach(deviceData -> {
final Device device = new Device();
device.setId(deviceData.id);
device.setAuthTokenHash(passwordTokenHash);
device.setFetchesMessages(true);
device.setSignedPreKey(new SignedPreKey(1, "publicKey", "signature"));
device.setLastSeen(deviceData.lastSeen().toEpochMilli());
device.setCreated(deviceData.created().toEpochMilli());
device.setUserAgent(deviceData.userAgent());
account.addDevice(device);
});
return account;
}
private record DeviceData(long id, Instant lastSeen, Instant created, @Nullable String userAgent) {
}
private static String toE164(Phonenumber.PhoneNumber phoneNumber) {
return PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
}
}
}