添加storage

This commit is contained in:
yoyuzh
2026-04-08 21:11:18 +08:00
parent 6da0d196ee
commit 19c296a212
8 changed files with 708 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
package com.yoyuzh.files.storage;
import com.yoyuzh.config.FileStorageProperties;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.function.Function;
import java.util.function.Supplier;
final class DogeCloudS3SessionProvider implements S3SessionProvider {
private static final Duration REFRESH_WINDOW = Duration.ofMinutes(1);
private final Supplier<DogeCloudTemporaryS3Session> sessionSupplier;
private final Clock clock;
private final Function<DogeCloudTemporaryS3Session, S3FileRuntimeSession> runtimeFactory;
private CachedSession cachedSession;
DogeCloudS3SessionProvider(FileStorageProperties.S3 properties, DogeCloudTmpTokenClient tmpTokenClient) {
this(
properties,
tmpTokenClient::fetchSession,
Clock.systemUTC(),
session -> createRuntimeSession(properties, session)
);
}
DogeCloudS3SessionProvider(
FileStorageProperties.S3 properties,
Supplier<DogeCloudTemporaryS3Session> sessionSupplier,
Clock clock,
Function<DogeCloudTemporaryS3Session, S3FileRuntimeSession> runtimeFactory
) {
this.sessionSupplier = sessionSupplier;
this.clock = clock;
this.runtimeFactory = runtimeFactory;
}
@Override
public synchronized S3FileRuntimeSession currentSession() {
if (cachedSession != null && clock.instant().isBefore(cachedSession.expiresAt().minus(REFRESH_WINDOW))) {
return cachedSession.runtimeSession();
}
closeCachedSession();
DogeCloudTemporaryS3Session nextSession = sessionSupplier.get();
S3FileRuntimeSession runtimeSession = runtimeFactory.apply(nextSession);
cachedSession = new CachedSession(nextSession.expiresAt(), runtimeSession);
return runtimeSession;
}
@Override
public synchronized void close() {
closeCachedSession();
}
private void closeCachedSession() {
if (cachedSession == null) {
return;
}
cachedSession.runtimeSession().s3Presigner().close();
cachedSession.runtimeSession().s3Client().close();
cachedSession = null;
}
private static S3FileRuntimeSession createRuntimeSession(FileStorageProperties.S3 properties, DogeCloudTemporaryS3Session session) {
StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsSessionCredentials.create(
session.accessKeyId(),
session.secretAccessKey(),
session.sessionToken()
));
Region region = Region.of(resolveRegion(properties));
URI endpoint = URI.create(session.endpoint());
return new S3FileRuntimeSession(
session.bucket(),
S3Client.builder()
.credentialsProvider(credentialsProvider)
.region(region)
.endpointOverride(endpoint)
.serviceConfiguration(S3Configuration.builder().build())
.build(),
S3Presigner.builder()
.credentialsProvider(credentialsProvider)
.region(region)
.endpointOverride(endpoint)
.serviceConfiguration(S3Configuration.builder().build())
.build()
);
}
private static String resolveRegion(FileStorageProperties.S3 properties) {
return properties.getRegion() == null || properties.getRegion().isBlank()
? "automatic"
: properties.getRegion();
}
private record CachedSession(Instant expiresAt, S3FileRuntimeSession runtimeSession) {
}
}

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.files.storage;
import java.time.Instant;
record DogeCloudTemporaryS3Session(
String bucket,
String endpoint,
String accessKeyId,
String secretAccessKey,
String sessionToken,
Instant expiresAt
) {
}

View File

@@ -0,0 +1,193 @@
package com.yoyuzh.files.storage;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class DogeCloudTmpTokenClient {
private static final String API_PATH = "/auth/tmp_token.json";
private final FileStorageProperties.S3 properties;
private final ObjectMapper objectMapper;
private final Transport transport;
DogeCloudTmpTokenClient(FileStorageProperties.S3 properties, ObjectMapper objectMapper) {
this(properties, objectMapper, new HttpTransport());
}
DogeCloudTmpTokenClient(FileStorageProperties.S3 properties, ObjectMapper objectMapper, Transport transport) {
this.properties = properties;
this.objectMapper = objectMapper;
this.transport = transport;
}
DogeCloudTemporaryS3Session fetchSession() {
validateConfiguration();
String body = buildRequestBody();
Map<String, String> headers = Map.of(
"Content-Type", "application/json",
"Authorization", buildAuthorization(body)
);
TransportResponse response = post(body, headers);
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("多吉云临时密钥请求失败: HTTP " + response.statusCode() + " " + response.body());
}
try {
JsonNode root = objectMapper.readTree(response.body());
if (root.path("code").asInt() != 200) {
throw new IllegalStateException("多吉云临时密钥请求失败: " + root.path("msg").asText("unknown"));
}
JsonNode data = root.path("data");
JsonNode credentials = data.path("Credentials");
JsonNode bucketNode = resolveBucketNode(data.path("Buckets"));
return new DogeCloudTemporaryS3Session(
requiredText(bucketNode, "s3Bucket"),
requiredText(bucketNode, "s3Endpoint"),
requiredText(credentials, "accessKeyId"),
requiredText(credentials, "secretAccessKey"),
requiredText(credentials, "sessionToken"),
resolveExpiresAt(data.path("ExpiredAt"))
);
} catch (IOException ex) {
throw new IllegalStateException("解析多吉云临时密钥响应失败", ex);
}
}
private TransportResponse post(String body, Map<String, String> headers) {
try {
return transport.post(resolveBaseUrl(), API_PATH, body, headers);
} catch (IOException ex) {
throw new IllegalStateException("请求多吉云临时密钥失败", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("请求多吉云临时密钥被中断", ex);
}
}
private void validateConfiguration() {
if (!StringUtils.hasText(properties.getApiAccessKey())
|| !StringUtils.hasText(properties.getApiSecretKey())
|| !StringUtils.hasText(properties.getScope())) {
throw new IllegalStateException("多吉云存储配置不完整");
}
}
private String buildRequestBody() {
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("channel", "OSS_FULL");
payload.put("ttl", properties.getTtlSeconds());
payload.put("scopes", List.of(properties.getScope()));
try {
return objectMapper.writeValueAsString(payload);
} catch (IOException ex) {
throw new IllegalStateException("构建多吉云临时密钥请求失败", ex);
}
}
private String buildAuthorization(String body) {
String signTarget = API_PATH + "\n" + body;
return "TOKEN " + properties.getApiAccessKey() + ":" + hmacSha1Hex(properties.getApiSecretKey(), signTarget);
}
private String resolveBaseUrl() {
String configured = properties.getApiBaseUrl();
if (!StringUtils.hasText(configured)) {
return "https://api.dogecloud.com";
}
return configured.replaceAll("/+$", "");
}
private JsonNode resolveBucketNode(JsonNode bucketsNode) {
if (!bucketsNode.isArray() || bucketsNode.isEmpty()) {
throw new IllegalStateException("多吉云临时密钥响应缺少 Buckets");
}
String bucketName = extractBucketName(properties.getScope());
for (JsonNode node : bucketsNode) {
if (bucketName.equals(node.path("name").asText())) {
return node;
}
}
if (bucketsNode.size() == 1) {
return bucketsNode.get(0);
}
throw new IllegalStateException("多吉云临时密钥响应中未找到匹配的存储桶: " + bucketName);
}
static String extractBucketName(String scope) {
int separatorIndex = scope.indexOf(':');
return separatorIndex >= 0 ? scope.substring(0, separatorIndex) : scope;
}
private static Instant resolveExpiresAt(JsonNode node) {
long epochSeconds = node.asLong(0L);
if (epochSeconds <= 0L) {
throw new IllegalStateException("多吉云临时密钥响应缺少 ExpiredAt");
}
return Instant.ofEpochSecond(epochSeconds);
}
private static String requiredText(JsonNode node, String fieldName) {
String value = node.path(fieldName).asText();
if (!StringUtils.hasText(value)) {
throw new IllegalStateException("多吉云临时密钥响应缺少字段: " + fieldName);
}
return value;
}
private static String hmacSha1Hex(String secret, String content) {
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
byte[] digest = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(digest.length * 2);
for (byte current : digest) {
builder.append(String.format("%02x", current));
}
return builder.toString();
} catch (Exception ex) {
throw new IllegalStateException("生成多吉云 API 签名失败", ex);
}
}
interface Transport {
TransportResponse post(String baseUrl, String apiPath, String body, Map<String, String> headers) throws IOException, InterruptedException;
}
record TransportResponse(int statusCode, String body) {
}
private static final class HttpTransport implements Transport {
private final HttpClient httpClient = HttpClient.newHttpClient();
@Override
public TransportResponse post(String baseUrl, String apiPath, String body, Map<String, String> headers) throws IOException, InterruptedException {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(baseUrl + apiPath))
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
for (Map.Entry<String, String> entry : headers.entrySet()) {
requestBuilder.header(entry.getKey(), entry.getValue());
}
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new TransportResponse(response.statusCode(), response.body());
}
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files.storage;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
record S3FileRuntimeSession(
String bucket,
S3Client s3Client,
S3Presigner s3Presigner
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files.storage;
@FunctionalInterface
interface S3SessionProvider extends AutoCloseable {
S3FileRuntimeSession currentSession();
@Override
default void close() {
}
}

View File

@@ -0,0 +1,86 @@
package com.yoyuzh.files.storage;
import com.yoyuzh.config.FileStorageProperties;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class DogeCloudS3SessionProviderTest {
@Test
void currentSessionCachesSessionUntilRefreshWindow() {
FileStorageProperties.S3 properties = new FileStorageProperties.S3();
properties.setRegion("automatic");
AtomicInteger fetchCount = new AtomicInteger();
AtomicInteger runtimeCount = new AtomicInteger();
MutableClock clock = new MutableClock(Instant.parse("2026-04-01T10:00:00Z"));
DogeCloudS3SessionProvider provider = new DogeCloudS3SessionProvider(
properties,
() -> {
int index = fetchCount.incrementAndGet();
return new DogeCloudTemporaryS3Session(
"bucket-" + index,
"https://cos.ap-chengdu.myqcloud.com",
"ak-" + index,
"sk-" + index,
"token-" + index,
clock.instant().plusSeconds(index == 1 ? 600 : 1200)
);
},
clock,
session -> new S3FileRuntimeSession(
session.bucket(),
mock(S3Client.class, "s3Client-" + runtimeCount.incrementAndGet()),
mock(S3Presigner.class, "presigner-" + runtimeCount.get())
)
);
S3FileRuntimeSession first = provider.currentSession();
S3FileRuntimeSession second = provider.currentSession();
assertThat(first).isSameAs(second);
assertThat(fetchCount.get()).isEqualTo(1);
clock.setInstant(Instant.parse("2026-04-01T10:09:30Z"));
S3FileRuntimeSession refreshed = provider.currentSession();
assertThat(refreshed).isNotSameAs(first);
assertThat(refreshed.bucket()).isEqualTo("bucket-2");
assertThat(fetchCount.get()).isEqualTo(2);
}
private static final class MutableClock extends Clock {
private Instant instant;
private MutableClock(Instant instant) {
this.instant = instant;
}
void setInstant(Instant instant) {
this.instant = instant;
}
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return instant;
}
}
}

View File

@@ -0,0 +1,110 @@
package com.yoyuzh.files.storage;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.config.FileStorageProperties;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DogeCloudTmpTokenClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void fetchSessionSignsJsonRequestAndReturnsMatchingBucket() {
FileStorageProperties.S3 properties = new FileStorageProperties.S3();
properties.setApiBaseUrl("https://api.dogecloud.com");
properties.setApiAccessKey("doge-ak");
properties.setApiSecretKey("doge-sk");
properties.setScope("yoyuzh-files:users/*");
properties.setRegion("automatic");
properties.setTtlSeconds(1800);
CapturingTransport transport = new CapturingTransport("""
{
"code": 200,
"msg": "OK",
"data": {
"Credentials": {
"accessKeyId": "tmp-ak",
"secretAccessKey": "tmp-sk",
"sessionToken": "tmp-token"
},
"ExpiredAt": 1777777777,
"Buckets": [
{
"name": "yoyuzh-files",
"s3Bucket": "s-cd-14873-yoyuzh-files-1258813047",
"s3Endpoint": "https://cos.ap-chengdu.myqcloud.com"
},
{
"name": "yoyuzh-front",
"s3Bucket": "s-cd-14873-yoyuzh-front-1258813047",
"s3Endpoint": "https://cos.ap-chengdu.myqcloud.com"
}
]
}
}
""");
DogeCloudTmpTokenClient client = new DogeCloudTmpTokenClient(properties, objectMapper, transport);
DogeCloudTemporaryS3Session session = client.fetchSession();
assertThat(transport.apiPath).isEqualTo("/auth/tmp_token.json");
assertThat(transport.body).isEqualTo("{\"channel\":\"OSS_FULL\",\"ttl\":1800,\"scopes\":[\"yoyuzh-files:users/*\"]}");
assertThat(transport.headers).containsEntry("Content-Type", "application/json");
assertThat(transport.headers.get("Authorization")).startsWith("TOKEN doge-ak:");
assertThat(session.accessKeyId()).isEqualTo("tmp-ak");
assertThat(session.secretAccessKey()).isEqualTo("tmp-sk");
assertThat(session.sessionToken()).isEqualTo("tmp-token");
assertThat(session.bucket()).isEqualTo("s-cd-14873-yoyuzh-files-1258813047");
assertThat(session.endpoint()).isEqualTo("https://cos.ap-chengdu.myqcloud.com");
assertThat(session.expiresAt()).isEqualTo(Instant.ofEpochSecond(1777777777));
}
@Test
void fetchSessionRejectsApiErrors() {
FileStorageProperties.S3 properties = new FileStorageProperties.S3();
properties.setApiBaseUrl("https://api.dogecloud.com");
properties.setApiAccessKey("doge-ak");
properties.setApiSecretKey("doge-sk");
properties.setScope("yoyuzh-files");
DogeCloudTmpTokenClient client = new DogeCloudTmpTokenClient(
properties,
objectMapper,
new CapturingTransport("""
{"code":401,"msg":"ERROR_UNAUTHORIZED"}
""")
);
assertThatThrownBy(client::fetchSession)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ERROR_UNAUTHORIZED");
}
private static final class CapturingTransport implements DogeCloudTmpTokenClient.Transport {
private final String responseBody;
private String apiPath;
private String body;
private Map<String, String> headers;
private CapturingTransport(String responseBody) {
this.responseBody = responseBody;
}
@Override
public DogeCloudTmpTokenClient.TransportResponse post(String baseUrl, String nextApiPath, String nextBody, Map<String, String> nextHeaders) {
this.apiPath = nextApiPath;
this.body = nextBody;
this.headers = nextHeaders;
return new DogeCloudTmpTokenClient.TransportResponse(200, responseBody);
}
}
}

View File

@@ -0,0 +1,176 @@
package com.yoyuzh.files.storage;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.config.FileStorageProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.net.URL;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class S3FileContentStorageTest {
@Mock
private S3Client s3Client;
@Mock
private S3Presigner s3Presigner;
private S3FileContentStorage storage;
@BeforeEach
void setUp() {
FileStorageProperties properties = new FileStorageProperties();
properties.setProvider("s3");
properties.getS3().setApiAccessKey("doge-ak");
properties.getS3().setApiSecretKey("doge-sk");
properties.getS3().setScope("yoyuzh-files");
properties.getS3().setRegion("automatic");
storage = new S3FileContentStorage(properties, "demo-bucket", s3Client, s3Presigner);
}
@Test
void prepareUploadCreatesDirectPutUrl() throws Exception {
PresignedPutObjectRequest presignedRequest = org.mockito.Mockito.mock(PresignedPutObjectRequest.class);
when(presignedRequest.url()).thenReturn(new URL("https://upload.example.com/users/7/docs/notes.txt"));
when(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))).thenReturn(presignedRequest);
PreparedUpload preparedUpload = storage.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
assertThat(preparedUpload.direct()).isTrue();
assertThat(preparedUpload.method()).isEqualTo("PUT");
assertThat(preparedUpload.uploadUrl()).isEqualTo("https://upload.example.com/users/7/docs/notes.txt");
assertThat(preparedUpload.headers()).containsEntry("Content-Type", "text/plain");
ArgumentCaptor<PutObjectPresignRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectPresignRequest.class);
verify(s3Presigner).presignPutObject(requestCaptor.capture());
PutObjectRequest putObjectRequest = requestCaptor.getValue().putObjectRequest();
assertThat(putObjectRequest.bucket()).isEqualTo("demo-bucket");
assertThat(putObjectRequest.key()).isEqualTo("users/7/docs/notes.txt");
assertThat(putObjectRequest.contentType()).isEqualTo("text/plain");
}
@Test
void completeUploadRejectsMissingObject() {
when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(NoSuchKeyException.builder().message("missing").build());
assertThatThrownBy(() -> storage.completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L))
.isInstanceOf(BusinessException.class)
.hasMessage("上传文件不存在");
}
@Test
void createDownloadUrlSignsGetRequestWithDownloadFilename() throws Exception {
PresignedGetObjectRequest presignedRequest = org.mockito.Mockito.mock(PresignedGetObjectRequest.class);
when(presignedRequest.url()).thenReturn(new URL("https://download.example.com/object"));
when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(presignedRequest);
when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(HeadObjectResponse.builder().build());
String url = storage.createDownloadUrl(7L, "/docs", "notes.txt", "读书笔记.txt");
assertThat(url).isEqualTo("https://download.example.com/object");
ArgumentCaptor<GetObjectPresignRequest> requestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest.class);
verify(s3Presigner).presignGetObject(requestCaptor.capture());
GetObjectRequest getObjectRequest = requestCaptor.getValue().getObjectRequest();
assertThat(getObjectRequest.bucket()).isEqualTo("demo-bucket");
assertThat(getObjectRequest.key()).isEqualTo("users/7/docs/notes.txt");
assertThat(getObjectRequest.responseContentDisposition())
.isEqualTo("attachment; filename=\"download.txt\"; filename*=UTF-8''%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0.txt");
}
@Test
void createDownloadUrlFallsBackToGenericNameWhenOriginalFilenameHasNoAsciiCharacters() throws Exception {
PresignedGetObjectRequest presignedRequest = org.mockito.Mockito.mock(PresignedGetObjectRequest.class);
when(presignedRequest.url()).thenReturn(new URL("https://download.example.com/object"));
when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(presignedRequest);
when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(HeadObjectResponse.builder().build());
storage.createDownloadUrl(7L, "/docs", "notes.txt", "你好");
ArgumentCaptor<GetObjectPresignRequest> requestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest.class);
verify(s3Presigner).presignGetObject(requestCaptor.capture());
assertThat(requestCaptor.getValue().getObjectRequest().responseContentDisposition())
.isEqualTo("attachment; filename=\"download\"; filename*=UTF-8''%E4%BD%A0%E5%A5%BD");
}
@Test
void uploadStoresMultipartContentInConfiguredBucket() {
org.springframework.mock.web.MockMultipartFile multipartFile = new org.springframework.mock.web.MockMultipartFile(
"file",
"notes.txt",
"text/plain",
"hello".getBytes()
);
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenReturn(PutObjectResponse.builder().eTag("demo").build());
storage.upload(7L, "/docs", "notes.txt", multipartFile);
ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
assertThat(requestCaptor.getValue().bucket()).isEqualTo("demo-bucket");
assertThat(requestCaptor.getValue().key()).isEqualTo("users/7/docs/notes.txt");
assertThat(requestCaptor.getValue().contentType()).isEqualTo("text/plain");
}
@Test
void renameFileCopiesThenDeletesSourceObject() {
when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(HeadObjectResponse.builder().build());
storage.renameFile(7L, "/docs", "old.txt", "new.txt");
ArgumentCaptor<CopyObjectRequest> copyCaptor = ArgumentCaptor.forClass(CopyObjectRequest.class);
verify(s3Client).copyObject(copyCaptor.capture());
assertThat(copyCaptor.getValue().sourceBucket()).isEqualTo("demo-bucket");
assertThat(copyCaptor.getValue().sourceKey()).isEqualTo("users/7/docs/old.txt");
assertThat(copyCaptor.getValue().destinationBucket()).isEqualTo("demo-bucket");
assertThat(copyCaptor.getValue().destinationKey()).isEqualTo("users/7/docs/new.txt");
ArgumentCaptor<DeleteObjectRequest> deleteCaptor = ArgumentCaptor.forClass(DeleteObjectRequest.class);
verify(s3Client).deleteObject(deleteCaptor.capture());
assertThat(deleteCaptor.getValue().bucket()).isEqualTo("demo-bucket");
assertThat(deleteCaptor.getValue().key()).isEqualTo("users/7/docs/old.txt");
}
@Test
void readFileFallsBackToLegacyObjectKeyWhenNeeded() {
when(s3Client.headObject(HeadObjectRequest.builder().bucket("demo-bucket").key("users/7/docs/notes.txt").build()))
.thenThrow(NoSuchKeyException.builder().message("missing").build());
when(s3Client.headObject(HeadObjectRequest.builder().bucket("demo-bucket").key("7/docs/notes.txt").build()))
.thenReturn(HeadObjectResponse.builder().build());
when(s3Client.getObjectAsBytes(GetObjectRequest.builder().bucket("demo-bucket").key("7/docs/notes.txt").build()))
.thenReturn(ResponseBytes.fromByteArray(GetObjectResponse.builder().build(), "hello".getBytes()));
byte[] content = storage.readFile(7L, "/docs", "notes.txt");
assertThat(content).isEqualTo("hello".getBytes());
}
}