Clear "canceled at" timestamp when setting a new subscrition ID

This commit is contained in:
Jon Chambers 2024-12-06 14:59:02 -05:00 committed by Jon Chambers
parent 4988b4e0f5
commit 20685b6d69
3 changed files with 105 additions and 14 deletions

View File

@ -81,7 +81,7 @@ public class SubscriptionManager {
.orElseGet(() -> CompletableFuture.completedFuture(null));
})
.thenCompose(unused ->
subscriptions.canceledAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()));
subscriptions.setCanceledAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()));
}
/**

View File

@ -195,7 +195,7 @@ public class Subscriptions {
throw new IllegalStateException(
"expected invariant of 1-1 subscriber-customer violated for customer " + processorCustomer);
} else {
Map<String, AttributeValue> result = queryResponse.items().get(0);
Map<String, AttributeValue> result = queryResponse.items().getFirst();
return result.get(KEY_USER).b().asByteArray();
}
});
@ -370,15 +370,16 @@ public class Subscriptions {
+ "#subscription_id = :subscription_id, "
+ "#subscription_level = :subscription_level, "
+ "#subscription_created_at = if_not_exists(#subscription_created_at, :subscription_created_at), "
+ "#subscription_level_changed_at = :subscription_level_changed_at"
)
+ "#subscription_level_changed_at = :subscription_level_changed_at "
+ "REMOVE #canceled_at")
.expressionAttributeNames(Map.of(
"#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID,
"#accessed_at", KEY_ACCESSED_AT,
"#subscription_id", KEY_SUBSCRIPTION_ID,
"#subscription_level", KEY_SUBSCRIPTION_LEVEL,
"#subscription_created_at", KEY_SUBSCRIPTION_CREATED_AT,
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT))
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT,
"#canceled_at", KEY_CANCELED_AT))
.expressionAttributeValues(Map.of(
":accessed_at", n(updatedAt.getEpochSecond()),
":processor_customer_id", b(processorCustomer.toDynamoBytes()),
@ -414,7 +415,7 @@ public class Subscriptions {
return client.updateItem(request).thenApply(updateItemResponse -> null);
}
public CompletableFuture<Void> canceledAt(byte[] user, Instant canceledAt) {
public CompletableFuture<Void> setCanceledAt(byte[] user, Instant canceledAt) {
checkUserLength(user);
UpdateItemRequest request = UpdateItemRequest.builder()
@ -449,13 +450,15 @@ public class Subscriptions {
+ "#subscription_id = :subscription_id, "
+ "#subscription_created_at = :subscription_created_at, "
+ "#subscription_level = :subscription_level, "
+ "#subscription_level_changed_at = :subscription_level_changed_at")
+ "#subscription_level_changed_at = :subscription_level_changed_at "
+ "REMOVE #canceled_at")
.expressionAttributeNames(Map.of(
"#accessed_at", KEY_ACCESSED_AT,
"#subscription_id", KEY_SUBSCRIPTION_ID,
"#subscription_created_at", KEY_SUBSCRIPTION_CREATED_AT,
"#subscription_level", KEY_SUBSCRIPTION_LEVEL,
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT))
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT,
"#canceled_at", KEY_CANCELED_AT))
.expressionAttributeValues(Map.of(
":accessed_at", n(subscriptionCreatedAt.getEpochSecond()),
":subscription_id", s(subscriptionId),
@ -478,12 +481,14 @@ public class Subscriptions {
+ "#accessed_at = :accessed_at, "
+ "#subscription_id = :subscription_id, "
+ "#subscription_level = :subscription_level, "
+ "#subscription_level_changed_at = :subscription_level_changed_at")
+ "#subscription_level_changed_at = :subscription_level_changed_at "
+ "REMOVE #canceled_at")
.expressionAttributeNames(Map.of(
"#accessed_at", KEY_ACCESSED_AT,
"#subscription_id", KEY_SUBSCRIPTION_ID,
"#subscription_level", KEY_SUBSCRIPTION_LEVEL,
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT))
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT,
"#canceled_at", KEY_CANCELED_AT))
.expressionAttributeValues(Map.of(
":accessed_at", n(subscriptionLevelChangedAt.getEpochSecond()),
":subscription_id", s(subscriptionId),

View File

@ -12,7 +12,6 @@ import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.T
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.PASSWORD_MISMATCH;
import jakarta.ws.rs.ClientErrorException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
@ -36,7 +35,6 @@ class SubscriptionsTest {
private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3);
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.SUBSCRIPTIONS);
@ -177,10 +175,10 @@ class SubscriptionsTest {
}
@Test
void testCanceledAt() {
void testSetCanceledAt() {
Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42);
assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.canceledAt(user, canceled)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.setCanceledAt(user, canceled)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
assertThat(getResult).isNotNull();
assertThat(getResult.type).isEqualTo(FOUND);
@ -213,6 +211,36 @@ class SubscriptionsTest {
});
}
@Test
void testSubscriptionCreatedClearCanceledAt() {
String subscriptionId = Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(16));
Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
Instant canceledAt = subscriptionCreated.plusSeconds(1);
long level = 42;
assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.subscriptionCreated(user, subscriptionId, subscriptionCreated, level))
.succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.setCanceledAt(user, canceledAt)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.get(user, password).join().record.canceledAt).isEqualTo(canceledAt);
assertThat(subscriptions.subscriptionCreated(user, subscriptionId, subscriptionCreated, level))
.succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
assertThat(getResult).isNotNull();
assertThat(getResult.type).isEqualTo(FOUND);
assertThat(getResult.record).isNotNull().satisfies(record -> {
assertThat(record.accessedAt).isEqualTo(subscriptionCreated);
assertThat(record.subscriptionId).isEqualTo(subscriptionId);
assertThat(record.subscriptionCreatedAt).isEqualTo(subscriptionCreated);
assertThat(record.subscriptionLevel).isEqualTo(level);
assertThat(record.subscriptionLevelChangedAt).isEqualTo(subscriptionCreated);
assertThat(record.canceledAt).isNull();
});
});
}
@Test
void testSubscriptionLevelChanged() {
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
@ -235,6 +263,34 @@ class SubscriptionsTest {
});
}
@Test
void testSubscriptionLevelChangedClearCanceledAt() {
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
Instant canceledAt = at.plusSeconds(100);
long level = 1776;
String updatedSubscriptionId = "new";
assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.subscriptionCreated(user, "original", created, level - 1))
.succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.setCanceledAt(user, canceledAt)).succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.get(user, password).join().record.canceledAt).isEqualTo(canceledAt);
assertThat(subscriptions.subscriptionLevelChanged(user, at, level, updatedSubscriptionId))
.succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
assertThat(getResult).isNotNull();
assertThat(getResult.type).isEqualTo(FOUND);
assertThat(getResult.record).isNotNull().satisfies(record -> {
assertThat(record.accessedAt).isEqualTo(at);
assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);
assertThat(record.subscriptionLevel).isEqualTo(level);
assertThat(record.subscriptionId).isEqualTo(updatedSubscriptionId);
assertThat(record.canceledAt).isNull();
});
});
}
@Test
void testSetIapPurchase() {
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
@ -287,6 +343,36 @@ class SubscriptionsTest {
assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
}
@Test
void testSetIapPurchaseClearCanceledAt() {
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
Instant canceledAt = at.plusSeconds(100);
long level = 100;
ProcessorCustomer pc = new ProcessorCustomer("customerId", PaymentProvider.GOOGLE_PLAY_BILLING);
Record record = subscriptions.create(user, password, created).join();
// Should be able to set a fresh subscription
assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", level, at))
.succeedsWithin(DEFAULT_TIMEOUT);
assertThat(subscriptions.setCanceledAt(record.user, canceledAt))
.succeedsWithin(DEFAULT_TIMEOUT);
record = subscriptions.get(user, password).join().record;
assertThat(record.canceledAt).isEqualTo(canceledAt);
// should be able to update the level
Instant nextAt = at.plus(Duration.ofSeconds(10));
long nextLevel = level + 1;
assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt))
.succeedsWithin(DEFAULT_TIMEOUT);
// Resetting the level should clear the "canceled at" timestamp
record = subscriptions.get(user, password).join().record;
assertThat(record.canceledAt).isNull();
}
@Test
void testProcessorAndCustomerId() {
final ProcessorCustomer processorCustomer =