Files
doormile_backend/internal/storage/spaces_test.go

183 lines
5.7 KiB
Go

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)
}
}
}