Files
multica/server/internal/storage/s3_test.go
Bohan Jiang 89b939b07c fix(storage): build region-qualified S3 public URLs (#2051) (#2065)
* fix(storage): build region-qualified S3 public URLs (#2051)

The uploadedURL fallback (no CloudFront, no custom endpoint) wrote
"https://<bucket>/<key>" — missing the ".s3.<region>.amazonaws.com"
suffix — so any deployment that pointed S3_BUCKET at a real AWS bucket
without a CDN got broken image URLs back to the client. Avatar URLs
were persisted in this broken form on the user/agent rows, so profile
pictures uploaded via the SDK never rendered.

- Track S3_REGION on S3Storage and emit
  https://<bucket>.s3.<region>.amazonaws.com/<key> by default;
  fall back to path-style https://s3.<region>.amazonaws.com/<bucket>/<key>
  when the bucket name contains dots, since the AWS wildcard cert
  can't validate dotted virtual-hosted hosts.
- Teach KeyFromURL to recognise the new region-qualified hosts (both
  styles) and keep recognising the legacy bucket-only host so historical
  records can still be deleted/migrated.
- Document that S3_BUCKET is the bucket name only, not a hostname,
  in env-vars docs (en+zh), self-hosting guides, and .env.example.

Co-authored-by: multica-agent <github@multica.ai>

* feat(storage): warn at startup when S3_BUCKET looks like a hostname

Catches the most common misconfiguration shape (S3_BUCKET set to
"<bucket>.s3.<region>.amazonaws.com") with a startup log line so
operators don't silently end up with a config that signs uploads
against an invalid bucket name.

A real bucket name can never legitimately contain "amazonaws.com",
so the check is a single substring match — no false positives
worth carving out.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 12:45:55 +08:00

162 lines
4.5 KiB
Go

package storage
import "testing"
func TestS3StorageKeyFromURL_CustomEndpointPreservesNestedKey(t *testing.T) {
s := &S3Storage{
bucket: "test-bucket",
endpointURL: "http://localhost:9000",
}
rawURL := "http://localhost:9000/test-bucket/uploads/abc/file.png"
if got := s.KeyFromURL(rawURL); got != "uploads/abc/file.png" {
t.Fatalf("KeyFromURL(%q) = %q, want %q", rawURL, got, "uploads/abc/file.png")
}
}
func TestS3StorageKeyFromURL_CustomEndpointWithTrailingSlash(t *testing.T) {
s := &S3Storage{
bucket: "test-bucket",
endpointURL: "http://localhost:9000/",
}
rawURL := "http://localhost:9000/test-bucket/uploads/abc/file.png"
if got := s.KeyFromURL(rawURL); got != "uploads/abc/file.png" {
t.Fatalf("KeyFromURL(%q) = %q, want %q", rawURL, got, "uploads/abc/file.png")
}
}
func TestS3StorageKeyFromURL_VirtualHostedStylePreservesNestedKey(t *testing.T) {
s := &S3Storage{
bucket: "test-bucket",
region: "us-east-1",
}
rawURL := "https://test-bucket.s3.us-east-1.amazonaws.com/uploads/abc/file.png"
if got := s.KeyFromURL(rawURL); got != "uploads/abc/file.png" {
t.Fatalf("KeyFromURL(%q) = %q, want %q", rawURL, got, "uploads/abc/file.png")
}
}
func TestS3StorageKeyFromURL_PathStylePreservesNestedKey(t *testing.T) {
s := &S3Storage{
bucket: "bucket.with.dots",
region: "us-east-1",
}
rawURL := "https://s3.us-east-1.amazonaws.com/bucket.with.dots/uploads/abc/file.png"
if got := s.KeyFromURL(rawURL); got != "uploads/abc/file.png" {
t.Fatalf("KeyFromURL(%q) = %q, want %q", rawURL, got, "uploads/abc/file.png")
}
}
func TestS3StorageKeyFromURL_LegacyBucketOnlyHostStillRoundTrips(t *testing.T) {
// Old records written before the suffix bug was fixed look like
// "https://<bucket>/<key>". They were broken at fetch time but were still
// stored, so KeyFromURL must continue to recognise that prefix when we
// migrate or delete those records.
s := &S3Storage{
bucket: "test-bucket",
region: "us-east-1",
}
rawURL := "https://test-bucket/uploads/abc/file.png"
if got := s.KeyFromURL(rawURL); got != "uploads/abc/file.png" {
t.Fatalf("KeyFromURL(%q) = %q, want %q", rawURL, got, "uploads/abc/file.png")
}
}
func TestLooksLikeS3Hostname(t *testing.T) {
cases := []struct {
bucket string
want bool
}{
{"my-bucket", false},
{"bucket.with.dots", false},
{"my-bucket.s3.us-east-1.amazonaws.com", true},
{"my-bucket.s3.amazonaws.com", true},
{"s3.us-east-1.amazonaws.com", true},
}
for _, tc := range cases {
t.Run(tc.bucket, func(t *testing.T) {
if got := looksLikeS3Hostname(tc.bucket); got != tc.want {
t.Fatalf("looksLikeS3Hostname(%q) = %v, want %v", tc.bucket, got, tc.want)
}
})
}
}
func TestS3StorageUploadedURL(t *testing.T) {
const key = "uploads/abc/file.png"
cases := []struct {
name string
bucket string
region string
cdnDomain string
endpointURL string
want string
}{
{
name: "default aws virtual hosted style",
bucket: "test-bucket",
region: "us-east-1",
want: "https://test-bucket.s3.us-east-1.amazonaws.com/uploads/abc/file.png",
},
{
name: "default aws path style when bucket contains dots",
bucket: "bucket.with.dots",
region: "us-east-1",
want: "https://s3.us-east-1.amazonaws.com/bucket.with.dots/uploads/abc/file.png",
},
{
name: "cdn only",
bucket: "test-bucket",
region: "us-east-1",
cdnDomain: "cdn.example.com",
want: "https://cdn.example.com/uploads/abc/file.png",
},
{
name: "endpoint only",
bucket: "test-bucket",
region: "us-east-1",
endpointURL: "http://localhost:9000",
want: "http://localhost:9000/test-bucket/uploads/abc/file.png",
},
{
name: "endpoint with trailing slash",
bucket: "test-bucket",
region: "us-east-1",
endpointURL: "http://localhost:9000/",
want: "http://localhost:9000/test-bucket/uploads/abc/file.png",
},
{
name: "endpoint and cdn both set prefers cdn",
bucket: "test-bucket",
region: "us-east-1",
cdnDomain: "cdn.example.com",
endpointURL: "http://localhost:9000",
want: "https://cdn.example.com/uploads/abc/file.png",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &S3Storage{
bucket: tc.bucket,
region: tc.region,
cdnDomain: tc.cdnDomain,
endpointURL: tc.endpointURL,
}
if got := s.uploadedURL(key); got != tc.want {
t.Fatalf("uploadedURL() = %q, want %q", got, tc.want)
}
})
}
}