Files
ttstd-infinite-canvas/backend/internal/service/storage_s3_test.go
echoD886 19ebfbb3c1 fix(create): 参考内容 - 支持添加、独立删除与筛选管理
- 支持参考内容按稳定 ID 删除、筛选、清空、拖拽与触屏交互
- 保留 Vite 生产环境模型 Logo 加载并兼容 Bun 测试
- 同步最新 main,修正相关格式和陈旧测试断言

Co-authored-by: echoD886 <dingxiaojie66@gmail.com>
2026-08-28 09:07:33 +08:00

180 lines
6.4 KiB
Go

package service
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestS3ObjectOperationsUsePathStyleSessionTokenAndNoManagedHeaders(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
var methods []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
if r.URL.Path != "/bucket/prefix/object.txt" {
t.Errorf("path = %q", r.URL.Path)
}
if r.Header.Get("X-Amz-Security-Token") != "session-token" {
t.Errorf("X-Amz-Security-Token = %q", r.Header.Get("X-Amz-Security-Token"))
}
if r.Header.Get("X-Amz-Acl") != "" || r.Header.Get("X-Amz-Server-Side-Encryption") != "" {
t.Errorf("unexpected ACL/SSE headers: %#v", r.Header)
}
switch r.Method {
case http.MethodPut:
data, _ := io.ReadAll(r.Body)
if string(data) != "payload" {
t.Errorf("body = %q", data)
}
w.Header().Set("ETag", `"etag-value"`)
case http.MethodGet:
if r.Header.Get("Range") != "bytes=0-3" {
t.Errorf("Range = %q", r.Header.Get("Range"))
}
w.Header().Set("Content-Range", "bytes 0-3/7")
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, _ = io.WriteString(w, "payl")
case http.MethodDelete:
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>`)
}
}))
defer server.Close()
setting := ossSettingValue{Provider: s3Provider, Region: "us-east-1", Endpoint: server.URL, Bucket: "bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", SessionToken: "session-token"}
etag, err := putS3Object(setting, "prefix/object.txt", "text/plain", 7, bytes.NewReader([]byte("payload")))
if err != nil || etag != "etag-value" {
t.Fatalf("putS3Object() = %q, %v", etag, err)
}
stream, err := getS3ObjectRange(setting, "prefix/object.txt", "bytes=0-3")
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(stream.body)
_ = stream.body.Close()
if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "payl" {
t.Fatalf("stream = %#v, body = %q", stream, data)
}
if err := deleteS3Object(setting, "prefix/object.txt"); err != nil {
t.Fatalf("deleteS3Object(404) error = %v", err)
}
if strings.Join(methods, ",") != "PUT,GET,DELETE" {
t.Fatalf("methods = %v", methods)
}
}
func TestSignedS3ObjectURLUsesSDKPresignAndSessionToken(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
value, err := signedS3ObjectURL(ossSettingValue{
Provider: s3Provider, Region: "us-east-1", Endpoint: server.URL, Bucket: "bucket",
AccessKeyID: "access-id", AccessKeySecret: "secret-value", SessionToken: "session-token",
}, "folder/object.png", time.Now().Add(time.Minute))
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(value)
if err != nil {
t.Fatal(err)
}
if parsed.Path != "/bucket/folder/object.png" || parsed.Query().Get("X-Amz-Security-Token") != "session-token" || parsed.Query().Get("X-Amz-Signature") == "" {
t.Fatalf("presigned URL = %q", value)
}
if strings.Contains(value, "secret-value") {
t.Fatal("presigned URL leaked secret key")
}
}
func TestS3EndpointAndDigestsEnforceStorageContract(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "storage.internal")
if _, err := validateStorageEndpoint("https://example.com/path"); err == nil {
t.Fatal("validateStorageEndpoint() accepted a path")
}
if _, err := validateStorageEndpoint("http://example.com"); err == nil {
t.Fatal("validateStorageEndpoint() accepted public HTTP")
}
base := ossSettingValue{Provider: s3Provider, Region: "us-east-1", Endpoint: "https://s3.example.com", Bucket: "bucket", PathPrefix: "assets", AccessKeyID: "id", AccessKeySecret: "secret"}
rotated := base
rotated.AccessKeySecret = "rotated"
moved := base
moved.PathPrefix = "other"
if storageLocationDigest(base) != storageLocationDigest(rotated) {
t.Fatal("credential rotation changed the location digest")
}
if storageTestDigest(base) == storageTestDigest(rotated) {
t.Fatal("credential rotation did not invalidate the tested digest")
}
if storageLocationDigest(base) == storageLocationDigest(moved) {
t.Fatal("location change did not create a different digest")
}
}
func TestOSSSettingKeepsS3SecretsWhenLocationChanges(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
current := ossSettingValue{
Provider: s3Provider,
Region: "us-east-1",
Endpoint: "https://127.0.0.1",
Bucket: "old-bucket",
AccessKeyID: "access-id",
AccessKeySecret: "secret-value",
SessionToken: "session-token",
PathPrefix: defaultOSSPathPrefix,
}
next, err := ossSettingFromRequest(OSSSettingRequest{
Enabled: true,
Provider: s3Provider,
S3Preset: "custom",
Region: "us-east-1",
Endpoint: "https://127.0.0.1",
Bucket: "new-bucket",
AccessKeyID: "access-id",
PathPrefix: "changed-prefix",
}, current)
if err != nil {
t.Fatal(err)
}
if next.AccessKeySecret != current.AccessKeySecret || next.SessionToken != current.SessionToken {
t.Fatalf("secrets were not retained: %#v", next)
}
}
func TestDefaultOSSPathPrefix(t *testing.T) {
value := normalizeOSSSetting(ossSettingValue{})
if value.PathPrefix != defaultOSSPathPrefix || defaultOSSSetting().PathPrefix != defaultOSSPathPrefix {
t.Fatalf("default path prefix = %q, default setting = %q", value.PathPrefix, defaultOSSSetting().PathPrefix)
}
}
func TestResourceObjectKeyPreservesOrInfersExtension(t *testing.T) {
setting := ossSettingValue{PathPrefix: "assets"}
now := time.Date(2026, time.August, 27, 20, 31, 0, 0, time.UTC)
for _, test := range []struct {
name string
fileName string
mimeType string
kind string
ext string
}{
{name: "original filename", fileName: "photo.JPEG", mimeType: "image/jpeg", kind: "image", ext: ".jpeg"},
{name: "mime type", mimeType: "image/png", kind: "image", ext: ".png"},
{name: "kind fallback", kind: "video", ext: ".mp4"},
} {
t.Run(test.name, func(t *testing.T) {
objectKey := ossObjectKey(setting, "user-1", test.kind, test.fileName, test.mimeType, now)
if !strings.HasSuffix(objectKey, test.ext) {
t.Fatalf("object key = %q, want suffix %q", objectKey, test.ext)
}
})
}
}