diff --git a/internal/signature/golden_test.go b/internal/signature/golden_test.go new file mode 100644 index 0000000..409b992 --- /dev/null +++ b/internal/signature/golden_test.go @@ -0,0 +1,105 @@ +package signature + +import ( + "testing" + "time" +) + +// goldenExpiresUnix is the fixed expiration timestamp used by all golden +// vectors: 2024-01-01T00:00:00Z. +const goldenExpiresUnix int64 = 1704067200 + +// goldenSigningKey is the fixed signing key used by all golden vectors. +const goldenSigningKey = "golden-test-key" + +// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and +// the exact generated signed URL path for fully-specified requests with a +// hardcoded signing key. The expected values were computed once and are +// hardcoded here as known answers. +// +// If any of these assertions fail, the signed byte format +// ("host:path:query:width:height:format:expiration"), the base64url +// encoding, or the signed URL layout has changed. Such a change breaks +// every signature already issued to clients, so it must be made +// deliberately: update these constants only as part of an intentional, +// documented signature format migration. +func TestSigner_GoldenVectors(t *testing.T) { + signer := New(goldenSigningKey) + + vectors := []struct { + name string + req Request + // wantSignature is the exact base64url (RFC 4648 URL-safe, + // padded) HMAC-SHA256 signature for the request with Expires + // set to goldenExpiresUnix. + wantSignature string + // wantSignedPath is the exact path returned by + // GenerateSignedURL for the request. The signature and + // expiration are returned separately by GenerateSignedURL and + // are not embedded in the path. + wantSignedPath string + }{ + { + name: "resized without query", + req: Request{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + SourceQuery: "", + Width: 800, + Height: 600, + Format: "webp", + }, + // Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200" + wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=", + wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp", + }, + { + name: "resized with query string", + req: Request{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + SourceQuery: "token=abc&v=2", + Width: 800, + Height: 600, + Format: "webp", + }, + // Signed data: "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200" + wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=", + wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg%3Ftoken=abc&v=2/800x600.webp", + }, + { + name: "original size without query", + req: Request{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + SourceQuery: "", + Width: 0, + Height: 0, + Format: "png", + }, + // Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200" + wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=", + wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png", + }, + } + + for _, tt := range vectors { + t.Run(tt.name, func(t *testing.T) { + signReq := tt.req + signReq.Expires = time.Unix(goldenExpiresUnix, 0) + + gotSignature := signer.Sign(&signReq) + if gotSignature != tt.wantSignature { + t.Errorf("Sign() = %q, want %q (signed byte format changed?)", + gotSignature, tt.wantSignature) + } + + urlReq := tt.req + gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour) + if gotPath != tt.wantSignedPath { + t.Errorf("GenerateSignedURL() path = %q, want %q (signed URL layout changed?)", + gotPath, tt.wantSignedPath) + } + }) + } +}