Files
summercms/tide/capture_test.go
Jakub Zych aa165fe3d0 feat(02-02): capture, scrub, and strictly diff stateful flows
Resolve named placeholders from a private variable store, mask
dates and ids after shape checks, and keep comparing independent steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 12:32:49 +02:00

274 lines
7.5 KiB
Go

package tide
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
const testJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSJ9.signaturehere123456"
func TestCaptureAndPlaceholderResolution(t *testing.T) {
store, err := OpenStore("")
if err != nil {
t.Fatal(err)
}
step := Step{
ID: "login",
Response: Response{
Headers: map[string]string{"Content-Type": "application/json"},
Body: Body(`{"token":"` + testJWT + `"}`),
},
Capture: []CaptureRule{{
From: "response.json",
Path: "$.token",
As: "jwt:alice",
Identity: "alice",
Category: "jwt",
}},
}
if err := CaptureStep(store, &step); err != nil {
t.Fatal(err)
}
if err := ScrubStep(store, &step); err != nil {
t.Fatal(err)
}
if !strings.Contains(string(step.Response.Body), "{{jwt:alice}}") {
t.Fatalf("body not scrubbed: %s", step.Response.Body)
}
if strings.Contains(string(step.Response.Body), testJWT) {
t.Fatal("jwt left in body")
}
got, err := store.Expand("Bearer {{jwt:alice}}")
if err != nil {
t.Fatal(err)
}
if got != "Bearer "+testJWT {
t.Fatalf("expand: %q", got)
}
if _, err := store.Expand("{{missing}}"); err == nil || !strings.Contains(err.Error(), "unresolved") {
t.Fatalf("missing placeholder: %v", err)
}
step2 := Step{
ID: "pkce",
Request: Request{
Method: "POST",
Path: "/authorize",
Body: Body("code_verifier=pkceVerifierValue1&client_id=x"),
},
Response: Response{
Status: 302,
Headers: map[string]string{"Location": "/cb?code=oauthCode99"},
},
Capture: []CaptureRule{
{From: "request.form", Name: "code_verifier", As: "pkce:alice", Category: "pkce"},
{From: "response.location.query", Name: "code", As: "oauth:alice-code", Category: "oauth_code"},
},
}
if err := CaptureStep(store, &step2); err != nil {
t.Fatal(err)
}
if err := ScrubStep(store, &step2); err != nil {
t.Fatal(err)
}
if !strings.Contains(string(step2.Request.Body), "{{pkce:alice}}") || strings.Contains(string(step2.Request.Body), "pkceVerifierValue1") {
t.Fatalf("pkce not scrubbed: %s", step2.Request.Body)
}
if !strings.Contains(step2.Response.Headers["Location"], "{{oauth:alice-code}}") {
t.Fatalf("code not scrubbed: %v", step2.Response.Headers)
}
}
func TestScrubRejectsUnclassifiedAndPersistsVars(t *testing.T) {
dir := t.TempDir()
varsPath := filepath.Join(dir, "vars.yaml")
store, err := OpenStore(varsPath)
if err != nil {
t.Fatal(err)
}
step := Step{
ID: "login",
Response: Response{
Headers: map[string]string{"Content-Type": "application/json"},
Body: Body(`{"token":"` + testJWT + `"}`),
},
Capture: []CaptureRule{{From: "response.json", Path: "$.token", As: "jwt:alice", Category: "jwt"}},
}
if err := CaptureStep(store, &step); err != nil {
t.Fatal(err)
}
if err := ScrubStep(store, &step); err != nil {
t.Fatal(err)
}
if err := store.Save(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(varsPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), testJWT) {
t.Fatalf("vars missing secret:\n%s", raw)
}
st, err := os.Stat(varsPath)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Fatalf("mode %o", st.Mode().Perm())
}
unknown := Step{
ID: "bad",
Response: Response{Body: Body(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.otherSignatureValue99"}`)},
}
if err := ScrubStep(store, &unknown); err == nil {
t.Fatal("unclassified leftover jwt must fail")
}
}
func TestProxyLoginTokenAndPKCESession(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/login":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`))
case r.URL.Path == "/me":
if r.Header.Get("Authorization") != "Bearer "+testJWT {
http.Error(w, "no", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
case r.URL.Path == "/authorize":
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "code_verifier=pkceVerifierValue1") {
http.Error(w, "missing verifier", http.StatusBadRequest)
return
}
w.Header().Set("Location", "/cb?code=oauthCode99")
w.WriteHeader(http.StatusFound)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(upstream.Close)
fixtures := t.TempDir()
varsPath := filepath.Join(t.TempDir(), "vars.yaml")
rules := mustParseRules(t, ""+
"client: nuxt\n"+
"keep_request_headers:\n"+
" - Authorization\n"+
" - Content-Type\n"+
"keep_response_headers:\n"+
" - Content-Type\n"+
" - Location\n"+
"routes:\n"+
" - method: POST\n"+
" path: /login\n"+
" capture:\n"+
" - from: response.json\n"+
" path: $.token\n"+
" as: jwt:alice\n"+
" identity: alice\n"+
" category: jwt\n"+
" - method: GET\n"+
" path: /me\n"+
" - method: POST\n"+
" path: /authorize\n"+
" capture:\n"+
" - from: request.form\n"+
" name: code_verifier\n"+
" as: pkce:alice\n"+
" category: pkce\n"+
" - from: response.location.query\n"+
" name: code\n"+
" as: oauth:alice-code\n"+
" category: oauth_code\n")
proxy, err := NewProxy(ProxyConfig{
Listen: "127.0.0.1:0",
Upstream: upstream.URL,
Fixtures: fixtures,
VarsPath: varsPath,
Rules: rules,
})
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(proxy.Handler())
t.Cleanup(srv.Close)
post(t, srv.URL+"/login", "sess", "application/json", `{}`)
req, err := http.NewRequest(http.MethodGet, srv.URL+"/me", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(SessionHeader, "sess")
req.Header.Set("Authorization", "Bearer "+testJWT)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("me: %d", resp.StatusCode)
}
post(t, srv.URL+"/authorize", "sess", "application/x-www-form-urlencoded", "code_verifier=pkceVerifierValue1")
if err := proxy.Flush(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(fixtures, "nuxt", "sess.yaml"))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if strings.Contains(text, testJWT) || strings.Contains(text, "pkceVerifierValue1") || strings.Contains(text, "oauthCode99") {
t.Fatalf("secrets in fixture:\n%s", text)
}
if !strings.Contains(text, "{{jwt:alice}}") || !strings.Contains(text, "{{pkce:alice}}") || !strings.Contains(text, "{{oauth:alice-code}}") {
t.Fatalf("placeholders missing:\n%s", text)
}
flow, err := LoadFlow(filepath.Join(fixtures, "nuxt", "sess.yaml"))
if err != nil {
t.Fatal(err)
}
store, err := OpenStore(varsPath)
if err != nil {
t.Fatal(err)
}
if _, err := ReplayFlow(context.Background(), flow, ReplayConfig{Target: upstream.URL, Store: store}); err != nil {
t.Fatalf("replay captured session: %v", err)
}
}
func post(t *testing.T, url, session, ct, body string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set(SessionHeader, session)
req.Header.Set("Content-Type", ct)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode >= 400 {
t.Fatalf("%s: %d", url, resp.StatusCode)
}
}