添加storage
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user