Add media deletion endpoint

This commit is contained in:
Ravi Khadiwala 2024-01-09 13:52:15 -06:00 committed by ravi-signal
parent e934ead85c
commit cc6cf8194f
11 changed files with 340 additions and 34 deletions

View File

@ -11,6 +11,7 @@ import io.micrometer.core.instrument.Metrics;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
@ -30,6 +31,8 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public class BackupManager {
@ -125,12 +128,10 @@ public class BackupManager {
*/
public CompletableFuture<MessageBackupUploadDescriptor> createMessageBackupUploadDescriptor(
final AuthenticatedBackupUser backupUser) {
final String encodedBackupId = encodeBackupIdForCdn(backupUser);
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
return backupsDb
.addMessageBackup(backupUser)
.thenApply(result -> cdn3BackupCredentialGenerator.generateUpload(encodedBackupId, MESSAGE_BACKUP_NAME));
.thenApply(result -> cdn3BackupCredentialGenerator.generateUpload(cdnMessageBackupName(backupUser)));
}
/**
@ -194,8 +195,7 @@ public class BackupManager {
// The user is out of quota, and we have not recently recalculated the user's usage. Double check by doing a
// hard recalculation before actually forbidding the user from storing additional media.
final String mediaPrefix = "%s/%s/".formatted(encodeBackupIdForCdn(backupUser), MEDIA_DIRECTORY_NAME);
return this.remoteStorageManager.calculateBytesUsed(mediaPrefix)
return this.remoteStorageManager.calculateBytesUsed(cdnMediaDirectory(backupUser))
.thenCompose(usage -> backupsDb
.setMediaUsage(backupUser, usage)
.thenApply(ignored -> usage))
@ -249,15 +249,14 @@ public class BackupManager {
}
final MessageBackupUploadDescriptor dst = cdn3BackupCredentialGenerator.generateUpload(
encodeBackupIdForCdn(backupUser),
"%s/%s".formatted(MEDIA_DIRECTORY_NAME, encodeForCdn(destinationMediaId)));
cdnMediaPath(backupUser, destinationMediaId));
final int destinationLength = encryptionParameters.outputSize(sourceLength);
final URI sourceUri = attachmentReadUri(sourceCdn, sourceKey);
return this.backupsDb
// Write the ddb updates before actually updating backing storage
.trackMedia(backupUser, destinationLength)
.trackMedia(backupUser, 1, destinationLength)
// Actually copy the objects. If the copy fails, our estimated quota usage may not be exact
.thenComposeAsync(ignored -> remoteStorageManager.copy(sourceUri, sourceLength, encryptionParameters, dst))
@ -267,7 +266,7 @@ public class BackupManager {
throw ExceptionUtils.wrap(unwrapped);
}
// In cases where we know the copy fails without writing anything, we can try to restore the user's quota
return this.backupsDb.trackMedia(backupUser, -destinationLength).whenComplete((ignored, ignoredEx) -> {
return this.backupsDb.trackMedia(backupUser, -1, -destinationLength).whenComplete((ignored, ignoredEx) -> {
throw ExceptionUtils.wrap(unwrapped);
});
})
@ -335,8 +334,7 @@ public class BackupManager {
.withDescription("credential does not support list operation")
.asRuntimeException();
}
final String mediaPrefix = "%s/%s/".formatted(encodeBackupIdForCdn(backupUser), MEDIA_DIRECTORY_NAME);
return remoteStorageManager.list(mediaPrefix, cursor, limit)
return remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit)
.thenApply(result ->
new ListMediaResult(
result
@ -352,6 +350,74 @@ public class BackupManager {
));
}
private sealed interface Either permits DeleteSuccess, DeleteFailure {}
private record DeleteSuccess(long usage) implements Either {}
private record DeleteFailure(Throwable e) implements Either {}
public CompletableFuture<Void> delete(final AuthenticatedBackupUser backupUser,
final List<StorageDescriptor> storageDescriptors) {
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
throw Status.PERMISSION_DENIED
.withDescription("credential does not support list operation")
.asRuntimeException();
}
if (storageDescriptors.stream().anyMatch(sd -> sd.cdn() != remoteStorageManager.cdnNumber())) {
throw Status.INVALID_ARGUMENT
.withDescription("unsupported media cdn provided")
.asRuntimeException();
}
return Flux
.fromIterable(storageDescriptors)
// Issue deletes for all storage descriptors (proceeds with default flux concurrency)
.flatMap(descriptor -> Mono.fromCompletionStage(
remoteStorageManager
.delete(cdnMediaPath(backupUser, descriptor.key))
// Squash errors/success into a single type
.handle((bytesDeleted, throwable) -> throwable != null
? new DeleteFailure(throwable)
: new DeleteSuccess(bytesDeleted))
))
// Update backupsDb with the change in usage
.collectList()
.<Void>flatMap(eithers -> {
// count up usage changes
long totalBytesDeleted = 0;
long totalCountDeleted = 0;
final List<Throwable> toThrow = new ArrayList<>();
for (Either either : eithers) {
switch (either) {
case DeleteFailure f:
toThrow.add(f.e());
break;
case DeleteSuccess s when s.usage() > 0:
totalBytesDeleted += s.usage();
totalCountDeleted++;
break;
default:
break;
}
}
final Mono<Void> result = toThrow.isEmpty()
? Mono.empty()
: Mono.error(toThrow.stream().reduce((t1, t2) -> {
t1.addSuppressed(t2);
return t1;
}).get());
return Mono
.fromCompletionStage(this.backupsDb.trackMedia(backupUser, -totalCountDeleted, -totalBytesDeleted))
.then(result);
})
.toFuture();
}
/**
* Authenticate the ZK anonymous backup credential's presentation
* <p>
@ -452,7 +518,8 @@ public class BackupManager {
return encodeForCdn(BackupsDb.hashedBackupId(backupUser.backupId()));
}
private static String encodeForCdn(final byte[] bytes) {
@VisibleForTesting
static String encodeForCdn(final byte[] bytes) {
return Base64.getUrlEncoder().encodeToString(bytes);
}
@ -460,4 +527,16 @@ public class BackupManager {
return Base64.getUrlDecoder().decode(base64);
}
private static String cdnMessageBackupName(final AuthenticatedBackupUser backupUser) {
return "%s/%s".formatted(encodeBackupIdForCdn(backupUser), MESSAGE_BACKUP_NAME);
}
private static String cdnMediaDirectory(final AuthenticatedBackupUser backupUser) {
return "%s/%s/".formatted(encodeBackupIdForCdn(backupUser), MEDIA_DIRECTORY_NAME);
}
private static String cdnMediaPath(final AuthenticatedBackupUser backupUser, final byte[] mediaId) {
return "%s%s".formatted(cdnMediaDirectory(backupUser), encodeForCdn(mediaId));
}
}

View File

@ -126,18 +126,19 @@ public class BackupsDb {
* Update the quota in the backup table
*
* @param backupUser The backup user
* @param mediaLength The length of the media after encryption. A negative length implies the media is being removed
* @param mediaBytesDelta The length of the media after encryption. A negative length implies media being removed
* @param mediaCountDelta The number of media objects being added, or if negative, removed
* @return A stage that completes successfully once the table are updated.
*/
CompletableFuture<Void> trackMedia(final AuthenticatedBackupUser backupUser, final int mediaLength) {
CompletableFuture<Void> trackMedia(final AuthenticatedBackupUser backupUser, final long mediaCountDelta, final long mediaBytesDelta) {
final Instant now = clock.instant();
return dynamoClient
.updateItem(
// Update the media quota and TTL
UpdateBuilder.forUser(backupTableName, backupUser)
.setRefreshTimes(now)
.incrementMediaBytes(mediaLength)
.incrementMediaCount(Integer.signum(mediaLength))
.incrementMediaBytes(mediaBytesDelta)
.incrementMediaCount(mediaCountDelta)
.updateItemBuilder()
.build())
.thenRun(Util.NOOP);

View File

@ -49,11 +49,10 @@ public class Cdn3BackupCredentialGenerator {
.build();
}
public MessageBackupUploadDescriptor generateUpload(final String hashedBackupId, final String objectName) {
if (hashedBackupId.isBlank() || objectName.isBlank()) {
public MessageBackupUploadDescriptor generateUpload(final String key) {
if (key.isBlank()) {
throw new IllegalArgumentException("Upload descriptors must have non-empty keys");
}
final String key = "%s/%s".formatted(hashedBackupId, objectName);
final String entity = WRITE_ENTITY_PREFIX + key;
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));

View File

@ -1,5 +1,7 @@
package org.whispersystems.textsecuregcm.backup;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
@ -21,8 +23,6 @@ import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
import javax.ws.rs.core.Response;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -168,10 +168,7 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
cursor.ifPresent(s -> queryParams.put("cursor", cursor.get()));
final HttpRequest request = HttpRequest.newBuilder().GET()
.uri(URI.create("%s/%s/%s".formatted(
storageManagerBaseUrl,
Cdn3BackupCredentialGenerator.CDN_PATH,
HttpUtils.queryParamString(queryParams.entrySet()))))
.uri(URI.create("%s%s".formatted(listUrl(), HttpUtils.queryParamString(queryParams.entrySet()))))
.header(CLIENT_ID_HEADER, clientId)
.header(CLIENT_SECRET_HEADER, clientSecret)
.build();
@ -226,12 +223,13 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
*/
record UsageResponse(@NotNull long numObjects, @NotNull long bytesUsed) {}
@Override
public CompletionStage<UsageInfo> calculateBytesUsed(final String prefix) {
final Timer.Sample sample = Timer.start();
final HttpRequest request = HttpRequest.newBuilder().GET()
.uri(URI.create("%s/usage%s".formatted(
storageManagerBaseUrl,
.uri(URI.create("%s%s".formatted(
usageUrl(),
HttpUtils.queryParamString(Map.of("prefix", prefix).entrySet()))))
.header(CLIENT_ID_HEADER, clientId)
.header(CLIENT_SECRET_HEADER, clientSecret)
@ -260,5 +258,49 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
return new UsageInfo(response.bytesUsed(), response.numObjects);
}
/**
* Serialized delete response from storage manager
*/
record DeleteResponse(@NotNull long bytesDeleted) {}
public CompletionStage<Long> delete(final String key) {
final HttpRequest request = HttpRequest.newBuilder().DELETE()
.uri(URI.create(deleteUrl(key)))
.header(CLIENT_ID_HEADER, clientId)
.header(CLIENT_SECRET_HEADER, clientSecret)
.build();
return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
.thenApply(response -> {
Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,
OPERATION_TAG_NAME, "delete",
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
.increment();
try {
return parseDeleteResponse(response);
} catch (IOException e) {
throw ExceptionUtils.wrap(e);
}
});
}
private long parseDeleteResponse(final HttpResponse<InputStream> httpDeleteResponse) throws IOException {
if (!HttpUtils.isSuccessfulResponse(httpDeleteResponse.statusCode())) {
throw new IOException("Failed to retrieve usage: " + httpDeleteResponse.statusCode());
}
return SystemMapper.jsonMapper().readValue(httpDeleteResponse.body(), DeleteResponse.class).bytesDeleted();
}
private String deleteUrl(final String key) {
return "%s/%s/%s".formatted(storageManagerBaseUrl, Cdn3BackupCredentialGenerator.CDN_PATH, key);
}
private String usageUrl() {
return "%s/usage".formatted(storageManagerBaseUrl);
}
private String listUrl() {
return "%s/%s/".formatted(storageManagerBaseUrl, Cdn3BackupCredentialGenerator.CDN_PATH);
}
}

View File

@ -74,4 +74,12 @@ public interface RemoteStorageManager {
* @return The number of bytes used
*/
CompletionStage<UsageInfo> calculateBytesUsed(final String prefix);
/**
* Delete the specified object.
*
* @param key the key of the stored object to delete.
* @return the number of bytes freed by the deletion operation
*/
CompletionStage<Long> delete(final String key);
}

View File

@ -683,4 +683,53 @@ public class ArchiveController {
.toList(),
result.cursor().orElse(null)));
}
public record DeleteMedia(@Size(min = 1, max = 1000) List<@Valid MediaToDelete> mediaToDelete) {
public record MediaToDelete(
@Schema(description = "The backup cdn where this media object is stored")
@NotNull
Integer cdn,
@Schema(description = "The mediaId of the object in URL-safe base64", implementation = String.class)
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
@NotNull
@ExactlySize(15)
byte[] mediaId
) {}
}
@POST
@Produces(MediaType.APPLICATION_JSON)
@Path("/media/delete")
@Operation(summary = "Delete media objects",
description = "Delete media objects stored with this backup-id")
@ApiResponse(responseCode = "204")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> deleteMedia(
@Auth final Optional<AuthenticatedAccount> account,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,
@Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@Valid @NotNull DeleteMedia deleteMedia) {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager
.authenticateBackupUser(presentation.presentation, signature.signature)
.thenCompose(authenticatedBackupUser -> backupManager.delete(authenticatedBackupUser,
deleteMedia.mediaToDelete().stream()
.map(media -> new BackupManager.StorageDescriptor(media.cdn(), media.mediaId))
.toList()))
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
}
}

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.backup;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
@ -20,12 +21,14 @@ import static org.mockito.Mockito.when;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
@ -36,6 +39,7 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@ -102,7 +106,7 @@ public class BackupManagerTest {
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
verify(tusCredentialGenerator, times(1))
.generateUpload(encodedBackupId, BackupManager.MESSAGE_BACKUP_NAME);
.generateUpload("%s/%s".formatted(encodedBackupId, BackupManager.MESSAGE_BACKUP_NAME));
final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser).join();
assertThat(info.backupSubdir()).isEqualTo(encodedBackupId);
@ -260,7 +264,7 @@ public class BackupManagerTest {
@Test
public void copySuccess() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
when(tusCredentialGenerator.generateUpload(any(), any()))
when(tusCredentialGenerator.generateUpload(any()))
.thenReturn(new MessageBackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
when(remoteStorageManager.copy(eq(URI.create("cdn3.example.org/attachments/abc")), eq(100), any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
@ -286,7 +290,7 @@ public class BackupManagerTest {
@Test
public void copyFailure() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
when(tusCredentialGenerator.generateUpload(any(), any()))
when(tusCredentialGenerator.generateUpload(any()))
.thenReturn(new MessageBackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
when(remoteStorageManager.copy(eq(URI.create("cdn3.example.org/attachments/abc")), eq(100), any(), any()))
.thenReturn(CompletableFuture.failedFuture(new SourceObjectNotFoundException()));
@ -409,6 +413,91 @@ public class BackupManagerTest {
}
@Test
public void delete() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
final byte[] mediaId = TestRandomUtil.nextBytes(16);
final String backupMediaKey = "%s/%s/%s".formatted(
BackupManager.encodeBackupIdForCdn(backupUser),
BackupManager.MEDIA_DIRECTORY_NAME,
BackupManager.encodeForCdn(mediaId));
backupsDb.setMediaUsage(backupUser, new UsageInfo(100, 1000)).join();
when(remoteStorageManager.delete(backupMediaKey))
.thenReturn(CompletableFuture.completedFuture(7L));
when(remoteStorageManager.cdnNumber()).thenReturn(5);
backupManager.delete(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId))).toCompletableFuture()
.join();
assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())
.isEqualTo(new UsageInfo(93, 999));
}
@Test
public void deleteUnknownCdn() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
when(remoteStorageManager.cdnNumber()).thenReturn(5);
assertThatThrownBy(() ->
backupManager.delete( backupUser, List.of(new BackupManager.StorageDescriptor(4, TestRandomUtil.nextBytes(15)))))
.isInstanceOf(StatusRuntimeException.class)
.matches(e -> ((StatusRuntimeException) e).getStatus().getCode() == Status.INVALID_ARGUMENT.getCode());
}
@Test
public void deletePartialFailure() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
final List<BackupManager.StorageDescriptor> descriptors = new ArrayList<>();
long initialBytes = 0;
for (int i = 1; i <= 10; i++) {
final BackupManager.StorageDescriptor descriptor = new BackupManager.StorageDescriptor(5,
TestRandomUtil.nextBytes(15));
descriptors.add(descriptor);
final String backupMediaKey = "%s/%s/%s".formatted(
BackupManager.encodeBackupIdForCdn(backupUser),
BackupManager.MEDIA_DIRECTORY_NAME,
BackupManager.encodeForCdn(descriptor.key()));
initialBytes += i;
// fail 2 deletions, otherwise return the corresponding object's size as i
final CompletableFuture<Long> deleteResult =
i == 3 || i == 6
? CompletableFuture.failedFuture(new IOException("oh no"))
: CompletableFuture.completedFuture(Long.valueOf(i));
when(remoteStorageManager.delete(backupMediaKey)).thenReturn(deleteResult);
}
when(remoteStorageManager.cdnNumber()).thenReturn(5);
backupsDb.setMediaUsage(backupUser, new UsageInfo(initialBytes, 10)).join();
CompletableFutureTestUtil.assertFailsWithCause(IOException.class, backupManager.delete(backupUser, descriptors));
// 2 objects should have failed to be deleted
assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())
.isEqualTo(new UsageInfo(9, 2));
}
@Test
public void alreadyDeleted() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
final byte[] mediaId = TestRandomUtil.nextBytes(16);
final String backupMediaKey = "%s/%s/%s".formatted(
BackupManager.encodeBackupIdForCdn(backupUser),
BackupManager.MEDIA_DIRECTORY_NAME,
BackupManager.encodeForCdn(mediaId));
backupsDb.setMediaUsage(backupUser, new UsageInfo(100, 5)).join();
// Deletion doesn't remove anything
when(remoteStorageManager.delete(backupMediaKey)).thenReturn(CompletableFuture.completedFuture(0L));
when(remoteStorageManager.cdnNumber()).thenReturn(5);
backupManager.delete(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId))).toCompletableFuture()
.join();
assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())
.isEqualTo(new UsageInfo(100, 5));
}
private Map<String, AttributeValue> getBackupItem(final AuthenticatedBackupUser backupUser) {
return DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()
.tableName(DynamoDbExtensionSchema.Tables.BACKUPS.tableName())

View File

@ -50,14 +50,14 @@ public class BackupsDbTest {
backupsDb.addMessageBackup(backupUser).join();
int total = 0;
for (int i = 0; i < 5; i++) {
this.backupsDb.trackMedia(backupUser, i).join();
this.backupsDb.trackMedia(backupUser, 1, i).join();
total += i;
final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();
assertThat(description.mediaUsedSpace().get()).isEqualTo(total);
}
for (int i = 0; i < 5; i++) {
this.backupsDb.trackMedia(backupUser, -i).join();
this.backupsDb.trackMedia(backupUser, -1, -i).join();
total -= i;
final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();
assertThat(description.mediaUsedSpace().get()).isEqualTo(total);
@ -70,7 +70,7 @@ public class BackupsDbTest {
testClock.pin(Instant.ofEpochSecond(5));
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
if (mediaAlreadyExists) {
this.backupsDb.trackMedia(backupUser, 10).join();
this.backupsDb.trackMedia(backupUser, 1, 10).join();
}
backupsDb.setMediaUsage(backupUser, new UsageInfo( 113, 17)).join();
final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();

View File

@ -22,7 +22,7 @@ public class Cdn3BackupCredentialGeneratorTest {
new SecretBytes(TestRandomUtil.nextBytes(32)),
"https://example.org/upload"));
final MessageBackupUploadDescriptor messageBackupUploadDescriptor = generator.generateUpload("subdir", "key");
final MessageBackupUploadDescriptor messageBackupUploadDescriptor = generator.generateUpload("subdir/key");
assertThat(messageBackupUploadDescriptor.signedUploadLocation()).isEqualTo("https://example.org/upload/backups");
assertThat(messageBackupUploadDescriptor.key()).isEqualTo("subdir/key");
assertThat(messageBackupUploadDescriptor.headers()).containsKey("Authorization");

View File

@ -12,6 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import java.io.IOException;
@ -234,6 +235,8 @@ public class Cdn3RemoteStorageManagerTest {
public void usage() throws JsonProcessingException {
wireMock.stubFor(get(urlPathEqualTo("/storage-manager/usage"))
.withQueryParam("prefix", equalTo("abc/"))
.withHeader(Cdn3RemoteStorageManager.CLIENT_ID_HEADER, equalTo("clientId"))
.withHeader(Cdn3RemoteStorageManager.CLIENT_SECRET_HEADER, equalTo("clientSecret"))
.willReturn(aResponse()
.withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.UsageResponse(
17,
@ -244,4 +247,15 @@ public class Cdn3RemoteStorageManagerTest {
assertThat(result.numObjects()).isEqualTo(17);
assertThat(result.bytesUsed()).isEqualTo(113);
}
@Test
public void delete() throws JsonProcessingException {
wireMock.stubFor(WireMock.delete(urlEqualTo("/storage-manager/backups/abc/def"))
.withHeader(Cdn3RemoteStorageManager.CLIENT_ID_HEADER, equalTo("clientId"))
.withHeader(Cdn3RemoteStorageManager.CLIENT_SECRET_HEADER, equalTo("clientSecret"))
.willReturn(aResponse()
.withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.DeleteResponse(9L)))));
final long deleted = remoteStorageManager.delete("abc/def").toCompletableFuture().join();
assertThat(deleted).isEqualTo(9L);
}
}

View File

@ -478,4 +478,29 @@ public class ArchiveControllerTest {
assertThat(response.storedMediaObjects().get(0).mediaId()).isEqualTo(mediaId);
assertThat(response.cursor()).isEqualTo(returnedCursor.orElse(null));
}
@Test
public void delete() throws VerificationFailedException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(BackupTier.MEDIA,
backupKey, aci);
when(backupManager.authenticateBackupUser(any(), any()))
.thenReturn(CompletableFuture.completedFuture(
new AuthenticatedBackupUser(presentation.getBackupId(), BackupTier.MEDIA)));
final ArchiveController.DeleteMedia deleteRequest = new ArchiveController.DeleteMedia(
IntStream
.range(0, 100)
.mapToObj(i -> new ArchiveController.DeleteMedia.MediaToDelete(3, TestRandomUtil.nextBytes(15)))
.toList());
when(backupManager.delete(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
final Response response = resources.getJerseyTest()
.target("v1/archives/media/delete")
.request()
.header("X-Signal-ZK-Auth", Base64.getEncoder().encodeToString(presentation.serialize()))
.header("X-Signal-ZK-Auth-Signature", "aaa")
.post(Entity.json(deleteRequest));
assertThat(response.getStatus()).isEqualTo(204);
}
}