Add media deletion endpoint
This commit is contained in:
parent
e934ead85c
commit
cc6cf8194f
|
@ -11,6 +11,7 @@ import io.micrometer.core.instrument.Metrics;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -30,6 +31,8 @@ import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
public class BackupManager {
|
public class BackupManager {
|
||||||
|
|
||||||
|
@ -125,12 +128,10 @@ public class BackupManager {
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<MessageBackupUploadDescriptor> createMessageBackupUploadDescriptor(
|
public CompletableFuture<MessageBackupUploadDescriptor> createMessageBackupUploadDescriptor(
|
||||||
final AuthenticatedBackupUser backupUser) {
|
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
|
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
|
||||||
return backupsDb
|
return backupsDb
|
||||||
.addMessageBackup(backupUser)
|
.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
|
// 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.
|
// 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(cdnMediaDirectory(backupUser))
|
||||||
return this.remoteStorageManager.calculateBytesUsed(mediaPrefix)
|
|
||||||
.thenCompose(usage -> backupsDb
|
.thenCompose(usage -> backupsDb
|
||||||
.setMediaUsage(backupUser, usage)
|
.setMediaUsage(backupUser, usage)
|
||||||
.thenApply(ignored -> usage))
|
.thenApply(ignored -> usage))
|
||||||
|
@ -249,15 +249,14 @@ public class BackupManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
final MessageBackupUploadDescriptor dst = cdn3BackupCredentialGenerator.generateUpload(
|
final MessageBackupUploadDescriptor dst = cdn3BackupCredentialGenerator.generateUpload(
|
||||||
encodeBackupIdForCdn(backupUser),
|
cdnMediaPath(backupUser, destinationMediaId));
|
||||||
"%s/%s".formatted(MEDIA_DIRECTORY_NAME, encodeForCdn(destinationMediaId)));
|
|
||||||
|
|
||||||
final int destinationLength = encryptionParameters.outputSize(sourceLength);
|
final int destinationLength = encryptionParameters.outputSize(sourceLength);
|
||||||
|
|
||||||
final URI sourceUri = attachmentReadUri(sourceCdn, sourceKey);
|
final URI sourceUri = attachmentReadUri(sourceCdn, sourceKey);
|
||||||
return this.backupsDb
|
return this.backupsDb
|
||||||
// Write the ddb updates before actually updating backing storage
|
// 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
|
// Actually copy the objects. If the copy fails, our estimated quota usage may not be exact
|
||||||
.thenComposeAsync(ignored -> remoteStorageManager.copy(sourceUri, sourceLength, encryptionParameters, dst))
|
.thenComposeAsync(ignored -> remoteStorageManager.copy(sourceUri, sourceLength, encryptionParameters, dst))
|
||||||
|
@ -267,7 +266,7 @@ public class BackupManager {
|
||||||
throw ExceptionUtils.wrap(unwrapped);
|
throw ExceptionUtils.wrap(unwrapped);
|
||||||
}
|
}
|
||||||
// In cases where we know the copy fails without writing anything, we can try to restore the user's quota
|
// 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);
|
throw ExceptionUtils.wrap(unwrapped);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -335,8 +334,7 @@ public class BackupManager {
|
||||||
.withDescription("credential does not support list operation")
|
.withDescription("credential does not support list operation")
|
||||||
.asRuntimeException();
|
.asRuntimeException();
|
||||||
}
|
}
|
||||||
final String mediaPrefix = "%s/%s/".formatted(encodeBackupIdForCdn(backupUser), MEDIA_DIRECTORY_NAME);
|
return remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit)
|
||||||
return remoteStorageManager.list(mediaPrefix, cursor, limit)
|
|
||||||
.thenApply(result ->
|
.thenApply(result ->
|
||||||
new ListMediaResult(
|
new ListMediaResult(
|
||||||
result
|
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
|
* Authenticate the ZK anonymous backup credential's presentation
|
||||||
* <p>
|
* <p>
|
||||||
|
@ -452,7 +518,8 @@ public class BackupManager {
|
||||||
return encodeForCdn(BackupsDb.hashedBackupId(backupUser.backupId()));
|
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);
|
return Base64.getUrlEncoder().encodeToString(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -460,4 +527,16 @@ public class BackupManager {
|
||||||
return Base64.getUrlDecoder().decode(base64);
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -126,18 +126,19 @@ public class BackupsDb {
|
||||||
* Update the quota in the backup table
|
* Update the quota in the backup table
|
||||||
*
|
*
|
||||||
* @param backupUser The backup user
|
* @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.
|
* @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();
|
final Instant now = clock.instant();
|
||||||
return dynamoClient
|
return dynamoClient
|
||||||
.updateItem(
|
.updateItem(
|
||||||
// Update the media quota and TTL
|
// Update the media quota and TTL
|
||||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||||
.setRefreshTimes(now)
|
.setRefreshTimes(now)
|
||||||
.incrementMediaBytes(mediaLength)
|
.incrementMediaBytes(mediaBytesDelta)
|
||||||
.incrementMediaCount(Integer.signum(mediaLength))
|
.incrementMediaCount(mediaCountDelta)
|
||||||
.updateItemBuilder()
|
.updateItemBuilder()
|
||||||
.build())
|
.build())
|
||||||
.thenRun(Util.NOOP);
|
.thenRun(Util.NOOP);
|
||||||
|
|
|
@ -49,11 +49,10 @@ public class Cdn3BackupCredentialGenerator {
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public MessageBackupUploadDescriptor generateUpload(final String hashedBackupId, final String objectName) {
|
public MessageBackupUploadDescriptor generateUpload(final String key) {
|
||||||
if (hashedBackupId.isBlank() || objectName.isBlank()) {
|
if (key.isBlank()) {
|
||||||
throw new IllegalArgumentException("Upload descriptors must have non-empty keys");
|
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 String entity = WRITE_ENTITY_PREFIX + key;
|
||||||
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);
|
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);
|
||||||
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package org.whispersystems.textsecuregcm.backup;
|
package org.whispersystems.textsecuregcm.backup;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.Metrics;
|
||||||
|
import io.micrometer.core.instrument.Timer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
@ -21,8 +23,6 @@ import java.util.stream.Stream;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.ws.rs.core.Response;
|
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.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
@ -168,10 +168,7 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||||
cursor.ifPresent(s -> queryParams.put("cursor", cursor.get()));
|
cursor.ifPresent(s -> queryParams.put("cursor", cursor.get()));
|
||||||
|
|
||||||
final HttpRequest request = HttpRequest.newBuilder().GET()
|
final HttpRequest request = HttpRequest.newBuilder().GET()
|
||||||
.uri(URI.create("%s/%s/%s".formatted(
|
.uri(URI.create("%s%s".formatted(listUrl(), HttpUtils.queryParamString(queryParams.entrySet()))))
|
||||||
storageManagerBaseUrl,
|
|
||||||
Cdn3BackupCredentialGenerator.CDN_PATH,
|
|
||||||
HttpUtils.queryParamString(queryParams.entrySet()))))
|
|
||||||
.header(CLIENT_ID_HEADER, clientId)
|
.header(CLIENT_ID_HEADER, clientId)
|
||||||
.header(CLIENT_SECRET_HEADER, clientSecret)
|
.header(CLIENT_SECRET_HEADER, clientSecret)
|
||||||
.build();
|
.build();
|
||||||
|
@ -226,12 +223,13 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||||
*/
|
*/
|
||||||
record UsageResponse(@NotNull long numObjects, @NotNull long bytesUsed) {}
|
record UsageResponse(@NotNull long numObjects, @NotNull long bytesUsed) {}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletionStage<UsageInfo> calculateBytesUsed(final String prefix) {
|
public CompletionStage<UsageInfo> calculateBytesUsed(final String prefix) {
|
||||||
final Timer.Sample sample = Timer.start();
|
final Timer.Sample sample = Timer.start();
|
||||||
final HttpRequest request = HttpRequest.newBuilder().GET()
|
final HttpRequest request = HttpRequest.newBuilder().GET()
|
||||||
.uri(URI.create("%s/usage%s".formatted(
|
.uri(URI.create("%s%s".formatted(
|
||||||
storageManagerBaseUrl,
|
usageUrl(),
|
||||||
HttpUtils.queryParamString(Map.of("prefix", prefix).entrySet()))))
|
HttpUtils.queryParamString(Map.of("prefix", prefix).entrySet()))))
|
||||||
.header(CLIENT_ID_HEADER, clientId)
|
.header(CLIENT_ID_HEADER, clientId)
|
||||||
.header(CLIENT_SECRET_HEADER, clientSecret)
|
.header(CLIENT_SECRET_HEADER, clientSecret)
|
||||||
|
@ -260,5 +258,49 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
||||||
return new UsageInfo(response.bytesUsed(), response.numObjects);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,4 +74,12 @@ public interface RemoteStorageManager {
|
||||||
* @return The number of bytes used
|
* @return The number of bytes used
|
||||||
*/
|
*/
|
||||||
CompletionStage<UsageInfo> calculateBytesUsed(final String prefix);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -683,4 +683,53 @@ public class ArchiveController {
|
||||||
.toList(),
|
.toList(),
|
||||||
result.cursor().orElse(null)));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.backup;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
@ -20,12 +21,14 @@ import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import io.grpc.Status;
|
import io.grpc.Status;
|
||||||
import io.grpc.StatusRuntimeException;
|
import io.grpc.StatusRuntimeException;
|
||||||
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
@ -36,6 +39,7 @@ import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
@ -102,7 +106,7 @@ public class BackupManagerTest {
|
||||||
|
|
||||||
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
|
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
|
||||||
verify(tusCredentialGenerator, times(1))
|
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();
|
final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser).join();
|
||||||
assertThat(info.backupSubdir()).isEqualTo(encodedBackupId);
|
assertThat(info.backupSubdir()).isEqualTo(encodedBackupId);
|
||||||
|
@ -260,7 +264,7 @@ public class BackupManagerTest {
|
||||||
@Test
|
@Test
|
||||||
public void copySuccess() {
|
public void copySuccess() {
|
||||||
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
|
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(), ""));
|
.thenReturn(new MessageBackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
|
||||||
when(remoteStorageManager.copy(eq(URI.create("cdn3.example.org/attachments/abc")), eq(100), any(), any()))
|
when(remoteStorageManager.copy(eq(URI.create("cdn3.example.org/attachments/abc")), eq(100), any(), any()))
|
||||||
.thenReturn(CompletableFuture.completedFuture(null));
|
.thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
@ -286,7 +290,7 @@ public class BackupManagerTest {
|
||||||
@Test
|
@Test
|
||||||
public void copyFailure() {
|
public void copyFailure() {
|
||||||
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
|
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(), ""));
|
.thenReturn(new MessageBackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
|
||||||
when(remoteStorageManager.copy(eq(URI.create("cdn3.example.org/attachments/abc")), eq(100), any(), any()))
|
when(remoteStorageManager.copy(eq(URI.create("cdn3.example.org/attachments/abc")), eq(100), any(), any()))
|
||||||
.thenReturn(CompletableFuture.failedFuture(new SourceObjectNotFoundException()));
|
.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) {
|
private Map<String, AttributeValue> getBackupItem(final AuthenticatedBackupUser backupUser) {
|
||||||
return DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()
|
return DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()
|
||||||
.tableName(DynamoDbExtensionSchema.Tables.BACKUPS.tableName())
|
.tableName(DynamoDbExtensionSchema.Tables.BACKUPS.tableName())
|
||||||
|
|
|
@ -50,14 +50,14 @@ public class BackupsDbTest {
|
||||||
backupsDb.addMessageBackup(backupUser).join();
|
backupsDb.addMessageBackup(backupUser).join();
|
||||||
int total = 0;
|
int total = 0;
|
||||||
for (int i = 0; i < 5; i++) {
|
for (int i = 0; i < 5; i++) {
|
||||||
this.backupsDb.trackMedia(backupUser, i).join();
|
this.backupsDb.trackMedia(backupUser, 1, i).join();
|
||||||
total += i;
|
total += i;
|
||||||
final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();
|
final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();
|
||||||
assertThat(description.mediaUsedSpace().get()).isEqualTo(total);
|
assertThat(description.mediaUsedSpace().get()).isEqualTo(total);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < 5; i++) {
|
for (int i = 0; i < 5; i++) {
|
||||||
this.backupsDb.trackMedia(backupUser, -i).join();
|
this.backupsDb.trackMedia(backupUser, -1, -i).join();
|
||||||
total -= i;
|
total -= i;
|
||||||
final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();
|
final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();
|
||||||
assertThat(description.mediaUsedSpace().get()).isEqualTo(total);
|
assertThat(description.mediaUsedSpace().get()).isEqualTo(total);
|
||||||
|
@ -70,7 +70,7 @@ public class BackupsDbTest {
|
||||||
testClock.pin(Instant.ofEpochSecond(5));
|
testClock.pin(Instant.ofEpochSecond(5));
|
||||||
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
|
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupTier.MEDIA);
|
||||||
if (mediaAlreadyExists) {
|
if (mediaAlreadyExists) {
|
||||||
this.backupsDb.trackMedia(backupUser, 10).join();
|
this.backupsDb.trackMedia(backupUser, 1, 10).join();
|
||||||
}
|
}
|
||||||
backupsDb.setMediaUsage(backupUser, new UsageInfo( 113, 17)).join();
|
backupsDb.setMediaUsage(backupUser, new UsageInfo( 113, 17)).join();
|
||||||
final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();
|
final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();
|
||||||
|
|
|
@ -22,7 +22,7 @@ public class Cdn3BackupCredentialGeneratorTest {
|
||||||
new SecretBytes(TestRandomUtil.nextBytes(32)),
|
new SecretBytes(TestRandomUtil.nextBytes(32)),
|
||||||
"https://example.org/upload"));
|
"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.signedUploadLocation()).isEqualTo("https://example.org/upload/backups");
|
||||||
assertThat(messageBackupUploadDescriptor.key()).isEqualTo("subdir/key");
|
assertThat(messageBackupUploadDescriptor.key()).isEqualTo("subdir/key");
|
||||||
assertThat(messageBackupUploadDescriptor.headers()).containsKey("Authorization");
|
assertThat(messageBackupUploadDescriptor.headers()).containsKey("Authorization");
|
||||||
|
|
|
@ -12,6 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||||
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -234,6 +235,8 @@ public class Cdn3RemoteStorageManagerTest {
|
||||||
public void usage() throws JsonProcessingException {
|
public void usage() throws JsonProcessingException {
|
||||||
wireMock.stubFor(get(urlPathEqualTo("/storage-manager/usage"))
|
wireMock.stubFor(get(urlPathEqualTo("/storage-manager/usage"))
|
||||||
.withQueryParam("prefix", equalTo("abc/"))
|
.withQueryParam("prefix", equalTo("abc/"))
|
||||||
|
.withHeader(Cdn3RemoteStorageManager.CLIENT_ID_HEADER, equalTo("clientId"))
|
||||||
|
.withHeader(Cdn3RemoteStorageManager.CLIENT_SECRET_HEADER, equalTo("clientSecret"))
|
||||||
.willReturn(aResponse()
|
.willReturn(aResponse()
|
||||||
.withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.UsageResponse(
|
.withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.UsageResponse(
|
||||||
17,
|
17,
|
||||||
|
@ -244,4 +247,15 @@ public class Cdn3RemoteStorageManagerTest {
|
||||||
assertThat(result.numObjects()).isEqualTo(17);
|
assertThat(result.numObjects()).isEqualTo(17);
|
||||||
assertThat(result.bytesUsed()).isEqualTo(113);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -478,4 +478,29 @@ public class ArchiveControllerTest {
|
||||||
assertThat(response.storedMediaObjects().get(0).mediaId()).isEqualTo(mediaId);
|
assertThat(response.storedMediaObjects().get(0).mediaId()).isEqualTo(mediaId);
|
||||||
assertThat(response.cursor()).isEqualTo(returnedCursor.orElse(null));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue