package tide import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "net/http" "os" "path/filepath" "slices" "strings" "testing" ) func upstreamTestSidecar() UpstreamSidecar { return UpstreamSidecar{Version: 1, Exchanges: []UpstreamExchange{{ Request: UpstreamRequest{ Method: "POST", URL: "https://api.example.test/v1/things?mode=fast&lang=en", Headers: map[string]string{ "Authorization": "Bearer {{secret:example-token}}", "Content-Type": "application/json", "User-Agent": "example-client/1.0", }, Body: `{"name":"widget","count":2}`, }, Response: UpstreamResponse{Status: 201, Body: `{"id":7}`}, }}} } func upstreamTestStore(t *testing.T) *Store { t.Helper() s, err := OpenStore("") if err != nil { t.Fatal(err) } s.Set("secret:example-token", "example-token-value") return s } type upstreamCall struct { method, url, ua, auth, body string } func goodUpstreamCall() upstreamCall { return upstreamCall{ method: "POST", url: "https://api.example.test/v1/things?lang=en&mode=fast", ua: "example-client/1.0", auth: "Bearer example-token-value", body: `{"count":2,"name":"widget"}`, } } func (c upstreamCall) send(t *testing.T, f *UpstreamFake) error { t.Helper() req, err := http.NewRequest(c.method, c.url, bytes.NewReader([]byte(c.body))) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", c.ua) req.Header.Set("Authorization", c.auth) resp, err := f.RoundTrip(req) if err == nil { _ = resp.Body.Close() } return err } func TestUpstreamFakeRejectsMismatchedRequest(t *testing.T) { ok := goodUpstreamCall() t.Run("match", func(t *testing.T) { f := NewUpstreamFake(upstreamTestSidecar(), upstreamTestStore(t)) if err := ok.send(t, f); err != nil { t.Fatal(err) } if err := f.Verify(); err != nil { t.Fatal(err) } }) cases := []struct { name string edit func(*upstreamCall) field string }{ {"method", func(c *upstreamCall) { c.method = "PUT" }, "method"}, {"path", func(c *upstreamCall) { c.url = strings.Replace(c.url, "/v1/things", "/v1/other", 1) }, "path"}, {"query", func(c *upstreamCall) { c.url = strings.Replace(c.url, "mode=fast", "mode=slow", 1) }, "query mode"}, {"host", func(c *upstreamCall) { c.url = strings.Replace(c.url, "api.example.test", "api2.example.test", 1) }, "host"}, {"user agent", func(c *upstreamCall) { c.ua = "other/2.0" }, "header User-Agent"}, {"authorization", func(c *upstreamCall) { c.auth = "Bearer wrong-token" }, "header Authorization"}, {"json body", func(c *upstreamCall) { c.body = `{"count":3,"name":"widget"}` }, "body $.count"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := NewUpstreamFake(upstreamTestSidecar(), upstreamTestStore(t)) c := ok tc.edit(&c) if err := c.send(t, f); err == nil || !strings.Contains(err.Error(), tc.field) { t.Fatalf("RoundTrip err = %v, want it to name %q", err, tc.field) } err := f.Verify() if err == nil || !strings.Contains(err.Error(), tc.field) { t.Fatalf("Verify = %v, want it to name %q", err, tc.field) } if strings.Contains(err.Error(), "wrong-token") || strings.Contains(err.Error(), "example-token-value") { t.Fatalf("Verify leaks a credential: %v", err) } }) } t.Run("extra request", func(t *testing.T) { f := NewUpstreamFake(upstreamTestSidecar(), upstreamTestStore(t)) if err := ok.send(t, f); err != nil { t.Fatal(err) } if err := ok.send(t, f); err == nil { t.Fatal("second request must fail") } if err := f.Verify(); err == nil || !strings.Contains(err.Error(), "extra request") { t.Fatalf("Verify = %v, want extra request", err) } }) t.Run("unconsumed exchange", func(t *testing.T) { f := NewUpstreamFake(upstreamTestSidecar(), upstreamTestStore(t)) if err := f.Verify(); err == nil || !strings.Contains(err.Error(), "unconsumed") { t.Fatalf("Verify = %v, want unconsumed", err) } }) t.Run("unresolved placeholder", func(t *testing.T) { f := NewUpstreamFake(upstreamTestSidecar(), nil) if err := ok.send(t, f); err == nil || !strings.Contains(err.Error(), "header Authorization") { t.Fatalf("err = %v, want unresolved Authorization placeholder", err) } }) } func TestLoadUpstreamAndPath(t *testing.T) { if got := UpstreamPath("fixtures/routes/GET_x__ok.yaml"); got != "fixtures/routes/GET_x__ok.upstream.yaml" { t.Fatalf("UpstreamPath = %q", got) } if got := UpstreamPath("a.yml"); got != "a.upstream.yaml" { t.Fatalf("UpstreamPath(.yml) = %q", got) } if got := UpstreamPath("a"); got != "a.upstream.yaml" { t.Fatalf("UpstreamPath(no ext) = %q", got) } s, err := LoadUpstream("testdata/upstream/post_json.upstream.yaml") if err != nil { t.Fatal(err) } if len(s.Exchanges) != 1 || s.Exchanges[0].Response.Status != 201 { t.Fatalf("sidecar = %+v", s) } } func TestWriteUpstreamRefusesUnmaskedCredential(t *testing.T) { store := upstreamTestStore(t) dir := t.TempDir() base := func(h map[string]string) UpstreamSidecar { return UpstreamSidecar{Version: 1, Exchanges: []UpstreamExchange{{ Request: UpstreamRequest{Method: "GET", URL: "https://api.example.test/v1/me", Headers: h}, Response: UpstreamResponse{Status: 200}, }}} } refused := []map[string]string{ {"Authorization": "Bearer live-unknown-token"}, {"X-Api-Key": "sk-live-unknown"}, {"x-api-key": "plainvalue"}, {"Authorization": "Bearer abc{{secret:example-token}}"}, {"Authorization": "Bearer example-token-value extra"}, } for _, h := range refused { path := filepath.Join(dir, "refused.upstream.yaml") err := WriteUpstream(path, base(h), store) if err == nil { t.Fatalf("%v: unmasked credential written", h) } for name, v := range h { if !strings.Contains(err.Error(), name) { t.Fatalf("error %q does not name header %s", err, name) } if strings.Contains(err.Error(), v) { t.Fatalf("error leaks the value: %v", err) } } if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("%v: file written despite refusal", h) } } accepted := []map[string]string{ {"Authorization": "Bearer example-token-value"}, {"Authorization": "Discogs token=example-token-value"}, {"X-Api-Key": "example-token-value"}, {"Authorization": ""}, } for _, h := range accepted { path := filepath.Join(dir, "ok.upstream.yaml") if err := WriteUpstream(path, base(h), store); err != nil { t.Fatalf("%v: %v", h, err) } raw, _ := os.ReadFile(path) if strings.Contains(string(raw), "example-token-value") { t.Fatalf("written sidecar leaks the value:\n%s", raw) } if _, err := LoadUpstream(path); err != nil { t.Fatalf("written sidecar does not load: %v", err) } } // The masked URL query and response body round-trip through the fake. s := base(map[string]string{"Authorization": "Bearer example-token-value"}) s.Exchanges[0].Request.URL = "https://api.example.test/v1/me?token=example-token-value" s.Exchanges[0].Response.Body = `{"token":"example-token-value"}` path := filepath.Join(dir, "query.upstream.yaml") if err := WriteUpstream(path, s, store); err != nil { t.Fatal(err) } raw, _ := os.ReadFile(path) if strings.Contains(string(raw), "example-token-value") || !strings.Contains(string(raw), "token={{secret:example-token}}") { t.Fatalf("query not masked:\n%s", raw) } if err := WriteUpstream(path, UpstreamSidecar{Exchanges: []UpstreamExchange{{Request: UpstreamRequest{Method: "GET", URL: "relative"}}}}, store); err == nil { t.Fatal("invalid sidecar must not be written") } } func TestUpstreamFakeHashesBase64Bodies(t *testing.T) { img := make([]byte, 1536) // 2 KiB of base64 for i := range img { img[i] = byte(i * 7) } b64 := base64.StdEncoding.EncodeToString(img) if len(b64) < 2000 { t.Fatalf("fixture too small: %d", len(b64)) } sent := `{"model":"m","messages":[{"content":[{"type":"image","source":{"data":"` + b64 + `"}},{"type":"image_url","url":"data:image/png;base64,` + b64 + `"}]}]}` s := UpstreamSidecar{Version: 1, Exchanges: []UpstreamExchange{{ Request: UpstreamRequest{ Method: "POST", URL: "https://api.example.test/v1/messages", Headers: map[string]string{"Content-Type": "application/json", "X-Api-Key": "example-token-value"}, // PHP escapes "/" in JSON strings; the stored form must still hash. Body: strings.ReplaceAll(sent, "/", `\/`), }, Response: UpstreamResponse{Status: 200, Body: `{"ok":true}`}, }}} path := filepath.Join(t.TempDir(), "img.upstream.yaml") store := upstreamTestStore(t) if err := WriteUpstream(path, s, store); err != nil { t.Fatal(err) } raw, _ := os.ReadFile(path) if strings.Contains(string(raw), b64[:64]) { t.Fatalf("base64 payload kept in the sidecar") } sum := sha256.Sum256(img) ph := "{{sha256:" + hex.EncodeToString(sum[:]) + "}}" if !strings.Contains(string(raw), ph) || !strings.Contains(string(raw), "data:image\\\\/png;base64,"+ph) && !strings.Contains(string(raw), "data:image/png;base64,"+ph) { t.Fatalf("sidecar lacks the hash placeholders:\n%s", raw) } loaded, err := LoadUpstream(path) if err != nil { t.Fatal(err) } send := func(body string) error { req, _ := http.NewRequest("POST", "https://api.example.test/v1/messages", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Api-Key", "example-token-value") f := NewUpstreamFake(loaded, store) _, err := f.RoundTrip(req) return err } if err := send(sent); err != nil { t.Fatalf("same bytes rejected: %v", err) } changed := slices.Clone(img) changed[100] ^= 1 b64c := base64.StdEncoding.EncodeToString(changed) err = send(strings.Replace(sent, b64, b64c, 1)) if err == nil || !strings.Contains(err.Error(), "body $.messages[0].content[0].source.data") { t.Fatalf("one changed byte accepted or misreported: %v", err) } if len(err.Error()) > 1000 { t.Fatalf("mismatch message not clipped: %d bytes", len(err.Error())) } } // TestUpstreamBinaryBodyRoundTrip pins that a response body that is not // valid UTF-8 (an image) is written as !!binary, kept unmasked, read back // byte for byte and replayed by the fake unchanged. func TestUpstreamBinaryBodyRoundTrip(t *testing.T) { store := upstreamTestStore(t) store.Set("id:short", "7") img := "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01 7 {{ example-token-value \xff\xd9" s := UpstreamSidecar{Version: 1, Exchanges: []UpstreamExchange{{ Request: UpstreamRequest{Method: "GET", URL: "https://img.example.test/cover.jpg"}, Response: UpstreamResponse{Status: 200, Headers: map[string]string{"Content-Type": "image/jpeg"}, Body: img}, }, { Request: UpstreamRequest{Method: "GET", URL: "https://api.example.test/v1/me"}, Response: UpstreamResponse{Status: 200, Body: `{"token":"example-token-value"}`}, }}} path := filepath.Join(t.TempDir(), "bin.upstream.yaml") if err := WriteUpstream(path, s, store); err != nil { t.Fatal(err) } raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !bytes.Contains(raw, []byte("body: !!binary "+base64.StdEncoding.EncodeToString([]byte(img)))) { t.Fatalf("binary body not written as !!binary:\n%s", raw) } if !bytes.Contains(raw, []byte(`{{secret:example-token}}`)) { t.Fatalf("text body not masked:\n%s", raw) } got, err := LoadUpstream(path) if err != nil { t.Fatal(err) } if got.Exchanges[0].Response.Body != img { t.Fatalf("binary body changed: %q", got.Exchanges[0].Response.Body) } fake := NewUpstreamFake(got, store) req, _ := http.NewRequest(http.MethodGet, "https://img.example.test/cover.jpg", nil) res, err := fake.RoundTrip(req) if err != nil { t.Fatal(err) } var buf bytes.Buffer _, _ = buf.ReadFrom(res.Body) if buf.String() != img { t.Fatalf("replayed body %q", buf.String()) } }