From 33c0a27b857d3b118562889409bc83d3bad13d05 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Wed, 11 Dec 2024 11:51:38 -0600 Subject: [PATCH] Add current entitlements to whoami response --- .../AccountIdentityResponseBuilder.java | 26 ++++++- .../entities/AccountIdentityResponse.java | 5 +- .../textsecuregcm/entities/Entitlements.java | 43 ++++++++++++ .../controllers/AccountControllerTest.java | 26 ++++++- .../AccountIdentityResponseBuilderTest.java | 67 +++++++++++++++++++ .../tests/util/AccountsHelper.java | 1 + 6 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/Entitlements.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilderTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilder.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilder.java index 9c38090f8..495ffd463 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilder.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilder.java @@ -4,7 +4,11 @@ */ 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.Entitlements; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.DeviceCapability; @@ -12,10 +16,12 @@ public class AccountIdentityResponseBuilder { private final Account account; private boolean storageCapable; + private Clock clock; public AccountIdentityResponseBuilder(Account account) { this.account = account; this.storageCapable = account.hasCapability(DeviceCapability.STORAGE); + this.clock = Clock.systemUTC(); } public AccountIdentityResponseBuilder storageCapable(boolean storageCapable) { @@ -23,13 +29,31 @@ public AccountIdentityResponseBuilder storageCapable(boolean storageCapable) { return this; } + public AccountIdentityResponseBuilder clock(Clock clock) { + this.clock = clock; + return this; + } + public AccountIdentityResponse build() { + final List 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(), account.getNumber(), account.getPhoneNumberIdentifier(), account.getUsernameHash().filter(h -> h.length > 0).orElse(null), account.getUsernameLinkHandle(), - storageCapable); + storageCapable, + new Entitlements(badges, backupEntitlement)); } public static AccountIdentityResponse fromAccount(final Account account) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java index 29c8ca44a..ec8be7cd6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java @@ -31,5 +31,8 @@ public record AccountIdentityResponse( @Nullable UUID usernameLinkHandle, @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) { } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Entitlements.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Entitlements.java new file mode 100644 index 000000000..aa983cbd7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Entitlements.java @@ -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 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) { + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java index 14841ecd7..3844148f8 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java @@ -42,6 +42,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; import java.util.stream.Stream; import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; @@ -66,6 +67,7 @@ import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest; import org.whispersystems.textsecuregcm.entities.DeviceName; import org.whispersystems.textsecuregcm.entities.EncryptedUsername; +import org.whispersystems.textsecuregcm.entities.Entitlements; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.RegistrationLock; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest; @@ -82,6 +84,7 @@ import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountBadge; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.DeviceCapability; @@ -324,6 +327,15 @@ void testSetApnId() { @ParameterizedTest @ValueSource(strings = {"/v1/accounts/whoami", "/v1/accounts/me"}) 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() .target(path) .request() @@ -331,7 +343,19 @@ void testWhoAmI(final String path) { .get()) { 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 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); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilderTest.java new file mode 100644 index 000000000..6da16943d --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilderTest.java @@ -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"); + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java index 69167be67..e7be0aac6 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java @@ -152,6 +152,7 @@ private static Account copyAndMarkStale(Account account) throws IOException { case "getIdentityKey" -> when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing); case "getBadges" -> when(updatedAccount.getBadges()).thenAnswer(stubbing); + case "getBackupVoucher" -> when(updatedAccount.getBackupVoucher()).thenAnswer(stubbing); case "getLastSeen" -> when(updatedAccount.getLastSeen()).thenAnswer(stubbing); case "hasLockedCredentials" -> when(updatedAccount.hasLockedCredentials()).thenAnswer(stubbing); default -> throw new IllegalArgumentException("unsupported method: Account#" + stubbing.getInvocation().getMethod().getName());