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
This commit is contained in:
@@ -2,7 +2,13 @@ package tide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -148,3 +154,135 @@ func TestLoadUpstreamAndPath(t *testing.T) {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user