package storage import ( "net/url" "os" "strings" "testing" "time" ) // PresignGet is what serves a customer their parcel photographs. A parcel photo // frames the inside of someone's doorway, so the properties tested here are // privacy properties, not formatting ones: the link must expire, and it must // never carry the bucket's secret key. func withSpaces(t *testing.T, access, secret string) { t.Helper() for _, kv := range [][2]string{ {"DO_SPACES_ACCESS_KEY", access}, {"DO_SPACES_SECRET_KEY", secret}, {"DO_SPACES_REGION", "sgp1"}, {"DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"}, {"DO_SPACES_BUCKET", "nearle"}, {"DO_SPACES_CDN_BASE", "https://images.nearle.app"}, } { key, value := kv[0], kv[1] previous, had := os.LookupEnv(key) if value == "" { _ = os.Unsetenv(key) } else { _ = os.Setenv(key, value) } t.Cleanup(func() { if had { _ = os.Setenv(key, previous) } else { _ = os.Unsetenv(key) } }) } } // The signed URL must never contain the secret key. A leaked secret is the // whole bucket, not one photo — and this is exactly the mistake the legacy // rider app made by shipping the key inside the binary. func TestPresignGetNeverLeaksTheSecretKey(t *testing.T) { const secret = "s3cr3t-do-not-emit-this-anywhere" withSpaces(t, "AKIAEXAMPLE", secret) signed, err := PresignGet("pv/booking-70/parcel-1.jpg", 30*time.Minute) if err != nil { t.Fatalf("PresignGet: %v", err) } if strings.Contains(signed, secret) { t.Fatal("the signed URL contains the secret key") } if strings.Contains(strings.ToLower(signed), "secret") { t.Errorf("suspicious content in the signed URL: %s", signed) } // The ACCESS key is expected — it identifies the caller, it is not a // credential on its own. if !strings.Contains(signed, "AKIAEXAMPLE") { t.Error("the signed URL carries no credential scope, so it cannot authenticate") } } // A link that does not expire is a permanent link, which defeats the point of // signing it at all. func TestPresignGetCarriesAnExpiry(t *testing.T) { withSpaces(t, "AKIAEXAMPLE", "shhh") signed, err := PresignGet("pv/abc.jpg", 30*time.Minute) if err != nil { t.Fatalf("PresignGet: %v", err) } parsed, err := url.Parse(signed) if err != nil { t.Fatalf("the signed URL does not parse: %v", err) } q := parsed.Query() if got := q.Get("X-Amz-Expires"); got != "1800" { t.Errorf("X-Amz-Expires = %q, want 1800 (30 minutes)", got) } for _, param := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-SignedHeaders", "X-Amz-Signature"} { if q.Get(param) == "" { t.Errorf("missing %s — the URL would be rejected by the object store", param) } } if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" { t.Errorf("unexpected algorithm %q", q.Get("X-Amz-Algorithm")) } } // A non-positive expiry must fall back to a real one rather than minting a link // that is already dead, or worse, one the store treats as unbounded. func TestPresignGetDefaultsAZeroExpiry(t *testing.T) { withSpaces(t, "AKIAEXAMPLE", "shhh") signed, err := PresignGet("pv/abc.jpg", 0) if err != nil { t.Fatalf("PresignGet: %v", err) } parsed, _ := url.Parse(signed) if got := parsed.Query().Get("X-Amz-Expires"); got != "900" { t.Errorf("X-Amz-Expires = %q on a zero expiry, want the 900s default", got) } } // Two different objects must produce different signatures. A signature that // does not cover the key would let one link fetch any file in the bucket. func TestPresignGetSignatureCoversTheObjectKey(t *testing.T) { withSpaces(t, "AKIAEXAMPLE", "shhh") a, err := PresignGet("pv/booking-70/parcel-1.jpg", time.Hour) if err != nil { t.Fatalf("PresignGet: %v", err) } b, err := PresignGet("pv/booking-99/parcel-4.jpg", time.Hour) if err != nil { t.Fatalf("PresignGet: %v", err) } sigA, _ := url.Parse(a) sigB, _ := url.Parse(b) if sigA.Query().Get("X-Amz-Signature") == sigB.Query().Get("X-Amz-Signature") { t.Fatal("two different objects produced the same signature — the key is not signed") } if !strings.Contains(a, "booking-70") || !strings.Contains(b, "booking-99") { t.Error("the object key is missing from the URL path") } } // With no credentials configured it degrades to the plain CDN link rather than // failing. An unsigned photo the customer can see beats a receipt with a broken // image — and the objects are currently written public-read anyway. func TestPresignGetFallsBackToTheCdnWhenUnconfigured(t *testing.T) { withSpaces(t, "", "") got, err := PresignGet("pv/abc.jpg", time.Hour) if err != nil { t.Fatalf("PresignGet should degrade, not fail: %v", err) } if got != "https://images.nearle.app/pv/abc.jpg" { t.Errorf("fallback URL = %q, want the plain CDN link", got) } } // Configured() gates the presign path; it must not claim to be configured on a // half-set environment. func TestConfiguredRequiresBothKeys(t *testing.T) { withSpaces(t, "AKIAEXAMPLE", "") if Configured() { t.Error("Configured() = true with no secret key") } withSpaces(t, "", "shhh") if Configured() { t.Error("Configured() = true with no access key") } withSpaces(t, "AKIAEXAMPLE", "shhh") if !Configured() { t.Error("Configured() = false with both keys present") } } // Object keys reach this from user-influenced paths, so the encoder has to // survive spaces and reserved characters without breaking the signature. func TestEncodePathHandlesAwkwardKeys(t *testing.T) { cases := []struct{ in, want string }{ {"pv/abc.jpg", "pv/abc.jpg"}, {"pv/a b.jpg", "pv/a%20b.jpg"}, {"pv/a+b.jpg", "pv/a%2Bb.jpg"}, {"pv/sub dir/x.jpg", "pv/sub%20dir/x.jpg"}, } for _, tc := range cases { if got := encodePath(tc.in); got != tc.want { t.Errorf("encodePath(%q) = %q, want %q", tc.in, got, tc.want) } } }