Skip to content

Commit

Permalink
Add current entitlements to whoami response
Browse files Browse the repository at this point in the history
  • Loading branch information
ravi-signal committed Dec 12, 2024
1 parent d5b39cd commit 33c0a27
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,56 @@
*/
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;

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) {
this.storageCapable = storageCapable;
return this;
}

public AccountIdentityResponseBuilder clock(Clock clock) {
this.clock = clock;
return this;
}

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(),
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -324,14 +327,35 @@ 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()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.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<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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down

0 comments on commit 33c0a27

Please sign in to comment.