- A response body that is not valid UTF-8 (a cover image) is written as a YAML !!binary scalar, never masked and replayed byte for byte - The upload URL normalizer covers every key ending in _url (cover_url), not only url and thumb_url - README and parity-testing docs updated
337 lines
12 KiB
Go
337 lines
12 KiB
Go
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())
|
|
}
|
|
}
|