Files
summercms/modules/tide/upstream_test.go
Jakub Zych ee0004fb65 feat(14-01): record vendor calls with summer parity:upstream and replay them offline
- WriteUpstream masks vars, hashes long base64 JSON strings and refuses unmasked Authorization/X-Api-Key
- multipart requests recorded as ordered parts; the fake compares parts and hashed payloads
- loopback CONNECT recording proxy with a local ECDSA parity CA, script and forward modes
- parity:upstream command, README and parity docs
2026-10-03 19:55:42 +02:00

289 lines
9.8 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()))
}
}