Add current entitlements to whoami response
This commit is contained in:
parent
d5b39cd496
commit
33c0a27b85
|
@ -4,7 +4,11 @@
|
||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.Entitlements;
|
||||||
import org.whispersystems.textsecuregcm.storage.Account;
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
|
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
|
||||||
|
|
||||||
|
@ -12,10 +16,12 @@ public class AccountIdentityResponseBuilder {
|
||||||
|
|
||||||
private final Account account;
|
private final Account account;
|
||||||
private boolean storageCapable;
|
private boolean storageCapable;
|
||||||
|
private Clock clock;
|
||||||
|
|
||||||
public AccountIdentityResponseBuilder(Account account) {
|
public AccountIdentityResponseBuilder(Account account) {
|
||||||
this.account = account;
|
this.account = account;
|
||||||
this.storageCapable = account.hasCapability(DeviceCapability.STORAGE);
|
this.storageCapable = account.hasCapability(DeviceCapability.STORAGE);
|
||||||
|
this.clock = Clock.systemUTC();
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountIdentityResponseBuilder storageCapable(boolean storageCapable) {
|
public AccountIdentityResponseBuilder storageCapable(boolean storageCapable) {
|
||||||
|
@ -23,13 +29,31 @@ public class AccountIdentityResponseBuilder {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AccountIdentityResponseBuilder clock(Clock clock) {
|
||||||
|
this.clock = clock;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public AccountIdentityResponse build() {
|
public AccountIdentityResponse build() {
|
||||||
|
final List<Entitlements.BadgeEntitlement> badges = account.getBadges()
|
||||||
|
.stream()
|
||||||
|
.filter(bv -> bv.expiration().isAfter(clock.instant()))
|
||||||
|
.map(badge -> new Entitlements.BadgeEntitlement(badge.id(), badge.expiration(), badge.visible()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final Entitlements.BackupEntitlement backupEntitlement = Optional
|
||||||
|
.ofNullable(account.getBackupVoucher())
|
||||||
|
.filter(bv -> bv.expiration().isAfter(clock.instant()))
|
||||||
|
.map(bv -> new Entitlements.BackupEntitlement(bv.receiptLevel(), bv.expiration()))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
return new AccountIdentityResponse(account.getUuid(),
|
return new AccountIdentityResponse(account.getUuid(),
|
||||||
account.getNumber(),
|
account.getNumber(),
|
||||||
account.getPhoneNumberIdentifier(),
|
account.getPhoneNumberIdentifier(),
|
||||||
account.getUsernameHash().filter(h -> h.length > 0).orElse(null),
|
account.getUsernameHash().filter(h -> h.length > 0).orElse(null),
|
||||||
account.getUsernameLinkHandle(),
|
account.getUsernameLinkHandle(),
|
||||||
storageCapable);
|
storageCapable,
|
||||||
|
new Entitlements(badges, backupEntitlement));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AccountIdentityResponse fromAccount(final Account account) {
|
public static AccountIdentityResponse fromAccount(final Account account) {
|
||||||
|
|
|
@ -31,5 +31,8 @@ public record AccountIdentityResponse(
|
||||||
@Nullable UUID usernameLinkHandle,
|
@Nullable UUID usernameLinkHandle,
|
||||||
|
|
||||||
@Schema(description="whether any of this account's devices support storage")
|
@Schema(description="whether any of this account's devices support storage")
|
||||||
boolean storageCapable) {
|
boolean storageCapable,
|
||||||
|
|
||||||
|
@Schema(description = "entitlements for this account and their current expirations")
|
||||||
|
Entitlements entitlements) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.whispersystems.textsecuregcm.util.InstantAdapter;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record Entitlements(
|
||||||
|
@Schema(description = "Active badges added via /v1/donation/redeem-receipt")
|
||||||
|
List<BadgeEntitlement> badges,
|
||||||
|
@Schema(description = "If present, the backup level set via /v1/archives/redeem-receipt")
|
||||||
|
@Nullable BackupEntitlement backup) {
|
||||||
|
|
||||||
|
public record BadgeEntitlement(
|
||||||
|
@Schema(description = "The badge id")
|
||||||
|
String id,
|
||||||
|
|
||||||
|
@Schema(description = "When the badge expires, in number of seconds since epoch", implementation = Long.class)
|
||||||
|
@JsonSerialize(using = InstantAdapter.EpochSecondSerializer.class)
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
|
||||||
|
@JsonProperty("expirationSeconds")
|
||||||
|
Instant expiration,
|
||||||
|
|
||||||
|
@Schema(description = "Whether the badge is currently configured to be visible")
|
||||||
|
boolean visible) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BackupEntitlement(
|
||||||
|
@Schema(description = "The backup level of the account")
|
||||||
|
long backupLevel,
|
||||||
|
|
||||||
|
@Schema(description = "When the backup entitlement expires, in number of seconds since epoch", implementation = Long.class)
|
||||||
|
@JsonSerialize(using = InstantAdapter.EpochSecondSerializer.class)
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
|
||||||
|
@JsonProperty("expirationSeconds")
|
||||||
|
Instant expiration) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import org.glassfish.jersey.server.ServerProperties;
|
import org.glassfish.jersey.server.ServerProperties;
|
||||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||||
|
@ -66,6 +67,7 @@ import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
|
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
||||||
import org.whispersystems.textsecuregcm.entities.EncryptedUsername;
|
import org.whispersystems.textsecuregcm.entities.EncryptedUsername;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.Entitlements;
|
||||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
|
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
|
||||||
|
@ -82,6 +84,7 @@ import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.storage.Account;
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.Device;
|
import org.whispersystems.textsecuregcm.storage.Device;
|
||||||
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
|
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
|
||||||
|
@ -324,6 +327,15 @@ class AccountControllerTest {
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {"/v1/accounts/whoami", "/v1/accounts/me"})
|
@ValueSource(strings = {"/v1/accounts/whoami", "/v1/accounts/me"})
|
||||||
void testWhoAmI(final String path) {
|
void testWhoAmI(final String path) {
|
||||||
|
final Instant expiration = Instant.now().plus(Duration.ofHours(1)).plusMillis(101);
|
||||||
|
final Instant truncatedExpiration = Instant.ofEpochSecond(expiration.getEpochSecond());
|
||||||
|
final AccountBadge badge1 = new AccountBadge("badge1", expiration, true);
|
||||||
|
final AccountBadge badge2 = new AccountBadge("badge2", expiration, true);
|
||||||
|
|
||||||
|
when(AuthHelper.VALID_ACCOUNT.getBackupVoucher())
|
||||||
|
.thenReturn(new Account.BackupVoucher(100, expiration));
|
||||||
|
when(AuthHelper.VALID_ACCOUNT.getBadges()).thenReturn(List.of(badge1, badge2));
|
||||||
|
|
||||||
try (final Response response = resources.getJerseyTest()
|
try (final Response response = resources.getJerseyTest()
|
||||||
.target(path)
|
.target(path)
|
||||||
.request()
|
.request()
|
||||||
|
@ -331,7 +343,19 @@ class AccountControllerTest {
|
||||||
.get()) {
|
.get()) {
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
assertThat(response.readEntity(AccountIdentityResponse.class).uuid()).isEqualTo(AuthHelper.VALID_UUID);
|
final AccountIdentityResponse identityResponse = response.readEntity(AccountIdentityResponse.class);
|
||||||
|
assertThat(identityResponse.uuid()).isEqualTo(AuthHelper.VALID_UUID);
|
||||||
|
|
||||||
|
final BiConsumer<Entitlements.BadgeEntitlement, AccountBadge> compareBadge = (actual, expected) -> {
|
||||||
|
assertThat(actual.expiration()).isEqualTo(truncatedExpiration);
|
||||||
|
assertThat(actual.id()).isEqualTo(expected.id());
|
||||||
|
assertThat(actual.visible()).isEqualTo(expected.visible());
|
||||||
|
};
|
||||||
|
compareBadge.accept(identityResponse.entitlements().badges().getFirst(), badge1);
|
||||||
|
compareBadge.accept(identityResponse.entitlements().badges().getLast(), badge2);
|
||||||
|
|
||||||
|
assertThat(identityResponse.entitlements().backup().backupLevel()).isEqualTo(100);
|
||||||
|
assertThat(identityResponse.entitlements().backup().expiration()).isEqualTo(truncatedExpiration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.Entitlements;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||||
|
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||||
|
|
||||||
|
class AccountIdentityResponseBuilderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredBackupEntitlement() {
|
||||||
|
final Instant expiration = Instant.ofEpochSecond(101);
|
||||||
|
final Account account = mock(Account.class);
|
||||||
|
when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(6, expiration));
|
||||||
|
|
||||||
|
Entitlements.BackupEntitlement backup = new AccountIdentityResponseBuilder(account)
|
||||||
|
.clock(TestClock.pinned(Instant.ofEpochSecond(101)))
|
||||||
|
.build().entitlements().backup();
|
||||||
|
assertThat(backup).isNull();
|
||||||
|
|
||||||
|
backup = new AccountIdentityResponseBuilder(account)
|
||||||
|
.clock(TestClock.pinned(Instant.ofEpochSecond(100)))
|
||||||
|
.build().entitlements().backup();
|
||||||
|
assertThat(backup).isNotNull();
|
||||||
|
assertThat(backup.expiration()).isEqualTo(expiration);
|
||||||
|
assertThat(backup.backupLevel()).isEqualTo(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredBadgeEntitlement() {
|
||||||
|
final Account account = mock(Account.class);
|
||||||
|
when(account.getBadges()).thenReturn(List.of(
|
||||||
|
new AccountBadge("badge1", Instant.ofEpochSecond(10), false),
|
||||||
|
new AccountBadge("badge2", Instant.ofEpochSecond(11), true)));
|
||||||
|
|
||||||
|
// all should be expired
|
||||||
|
assertThat(new AccountIdentityResponseBuilder(account)
|
||||||
|
.clock(TestClock.pinned(Instant.ofEpochSecond(11)))
|
||||||
|
.build().entitlements().badges()).isEmpty();
|
||||||
|
|
||||||
|
// first badge should be expired
|
||||||
|
assertThat(new AccountIdentityResponseBuilder(account).clock(TestClock.pinned(Instant.ofEpochSecond(10))).build()
|
||||||
|
.entitlements()
|
||||||
|
.badges()
|
||||||
|
.stream().map(Entitlements.BadgeEntitlement::id).toList())
|
||||||
|
.containsExactly("badge2");
|
||||||
|
|
||||||
|
// no badges should be expired
|
||||||
|
assertThat(new AccountIdentityResponseBuilder(account).clock(TestClock.pinned(Instant.ofEpochSecond(9))).build()
|
||||||
|
.entitlements()
|
||||||
|
.badges()
|
||||||
|
.stream().map(Entitlements.BadgeEntitlement::id).toList())
|
||||||
|
.containsExactly("badge1", "badge2");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -152,6 +152,7 @@ public class AccountsHelper {
|
||||||
case "getIdentityKey" ->
|
case "getIdentityKey" ->
|
||||||
when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);
|
when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);
|
||||||
case "getBadges" -> when(updatedAccount.getBadges()).thenAnswer(stubbing);
|
case "getBadges" -> when(updatedAccount.getBadges()).thenAnswer(stubbing);
|
||||||
|
case "getBackupVoucher" -> when(updatedAccount.getBackupVoucher()).thenAnswer(stubbing);
|
||||||
case "getLastSeen" -> when(updatedAccount.getLastSeen()).thenAnswer(stubbing);
|
case "getLastSeen" -> when(updatedAccount.getLastSeen()).thenAnswer(stubbing);
|
||||||
case "hasLockedCredentials" -> when(updatedAccount.hasLockedCredentials()).thenAnswer(stubbing);
|
case "hasLockedCredentials" -> when(updatedAccount.hasLockedCredentials()).thenAnswer(stubbing);
|
||||||
default -> throw new IllegalArgumentException("unsupported method: Account#" + stubbing.getInvocation().getMethod().getName());
|
default -> throw new IllegalArgumentException("unsupported method: Account#" + stubbing.getInvocation().getMethod().getName());
|
||||||
|
|
Loading…
Reference in New Issue