refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
364
modules/tide/flow_contract_test.go
Normal file
364
modules/tide/flow_contract_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package tide
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlowContract(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("record replay baseline", func(t *testing.T) {
|
||||
srv := jsonServer(t, `{"ok":true}`)
|
||||
spec := Flow{Version: 1, Name: "baseline", Steps: []Step{{
|
||||
ID: "a",
|
||||
Request: Request{Method: http.MethodGet, Path: "/ok"},
|
||||
}}}
|
||||
rec, err := RecordFlow(ctx, spec, RecordConfig{Target: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Steps[0].Response.Status != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Steps[0].Response.Status)
|
||||
}
|
||||
if _, err := ReplayFlow(ctx, rec, ReplayConfig{Target: srv.URL}); err != nil {
|
||||
t.Fatalf("baseline replay: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oauth and 401 headers plus ignored date server request id", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="http://127.0.0.1/.well-known/oauth-protected-resource"`)
|
||||
w.Header().Set("Server", "php")
|
||||
w.Header().Set("X-Request-Id", "live-req")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_token"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
spec := Flow{Version: 1, Name: "oauth-401", Steps: []Step{{
|
||||
ID: "token",
|
||||
Request: Request{Method: http.MethodGet, Path: "/token"},
|
||||
}}}
|
||||
rec, err := RecordFlow(ctx, spec, RecordConfig{Target: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec.Steps[0].Response.Headers["Date"] = "Wed, 01 Jan 2020 00:00:00 GMT"
|
||||
rec.Steps[0].Response.Headers["Server"] = "old"
|
||||
rec.Steps[0].Response.Headers["X-Request-Id"] = "fixture-req"
|
||||
if _, err := ReplayFlow(ctx, rec, ReplayConfig{Target: srv.URL}); err != nil {
|
||||
t.Fatalf("Date/Server/request id must be ignored: %v", err)
|
||||
}
|
||||
|
||||
badCache := cloneRecorded(t, rec)
|
||||
badCache.Steps[0].Response.Headers["Cache-Control"] = "public"
|
||||
_, err = ReplayFlow(ctx, badCache, ReplayConfig{Target: srv.URL})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "cache-control") {
|
||||
t.Fatalf("Cache-Control mismatch: %v", err)
|
||||
}
|
||||
|
||||
badPragma := cloneRecorded(t, rec)
|
||||
badPragma.Steps[0].Response.Headers["Pragma"] = "public"
|
||||
_, err = ReplayFlow(ctx, badPragma, ReplayConfig{Target: srv.URL})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "pragma") {
|
||||
t.Fatalf("Pragma mismatch: %v", err)
|
||||
}
|
||||
|
||||
badWWW := cloneRecorded(t, rec)
|
||||
badWWW.Steps[0].Response.Headers["WWW-Authenticate"] = `Bearer error="other"`
|
||||
_, err = ReplayFlow(ctx, badWWW, ReplayConfig{Target: srv.URL})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "www-authenticate") {
|
||||
t.Fatalf("WWW-Authenticate mismatch: %v", err)
|
||||
}
|
||||
|
||||
badStatus := cloneRecorded(t, rec)
|
||||
badStatus.Steps[0].Response.Status = http.StatusOK
|
||||
_, err = ReplayFlow(ctx, badStatus, ReplayConfig{Target: srv.URL})
|
||||
if err == nil || !strings.Contains(err.Error(), "status") {
|
||||
t.Fatalf("status mismatch: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv content-disposition mismatch", func(t *testing.T) {
|
||||
srv := csvServer(t, "a,b\n1,2\n", `attachment; filename="albums.csv"`)
|
||||
spec := Flow{Version: 1, Name: "csv-disp", Steps: []Step{{
|
||||
ID: "export",
|
||||
Request: Request{Method: http.MethodGet, Path: "/export"},
|
||||
}}}
|
||||
rec, err := RecordFlow(ctx, spec, RecordConfig{Target: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReplayFlow(ctx, rec, ReplayConfig{Target: srv.URL}); err != nil {
|
||||
t.Fatalf("csv header baseline: %v", err)
|
||||
}
|
||||
bad := cloneRecorded(t, rec)
|
||||
bad.Steps[0].Response.Headers["Content-Disposition"] = `attachment; filename="other.csv"`
|
||||
_, err = ReplayFlow(ctx, bad, ReplayConfig{Target: srv.URL})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "content-disposition") {
|
||||
t.Fatalf("Content-Disposition mismatch: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sidecar digest and path", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
payload := []byte("hello-bin")
|
||||
sum := sha256.Sum256(payload)
|
||||
if err := os.WriteFile(filepath.Join(dir, "data.bin"), payload, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := "version: 1\nname: bin\nsteps:\n - id: a\n request:\n method: GET\n path: /bin\n response:\n body_file: ../secret.bin\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "unsafe.yaml"), []byte(unsafe), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadFlow(filepath.Join(dir, "unsafe.yaml")); err == nil || !strings.Contains(err.Error(), "body_file") {
|
||||
t.Fatalf("parent sidecar must fail: %v", err)
|
||||
}
|
||||
abs := "version: 1\nname: bin\nsteps:\n - id: a\n request:\n method: GET\n path: /bin\n response:\n body_file: /tmp/secret.bin\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "abs.yaml"), []byte(abs), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadFlow(filepath.Join(dir, "abs.yaml")); err == nil || !strings.Contains(err.Error(), "body_file") {
|
||||
t.Fatalf("absolute sidecar must fail: %v", err)
|
||||
}
|
||||
|
||||
srv := binaryServer(t, "application/octet-stream", payload)
|
||||
good := "version: 1\nname: bin\nsteps:\n - id: a\n request:\n method: GET\n path: /bin\n response:\n status: 200\n headers:\n Content-Type: application/octet-stream\n body_file: data.bin\n sha256: " + hex.EncodeToString(sum[:]) + "\n"
|
||||
gp := filepath.Join(dir, "good.yaml")
|
||||
if err := os.WriteFile(gp, []byte(good), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flow, err := LoadFlow(gp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReplayFlow(ctx, flow, ReplayConfig{Target: srv.URL, BaseDir: dir}); err != nil {
|
||||
t.Fatalf("matching sidecar digest must pass: %v", err)
|
||||
}
|
||||
bad := strings.Replace(good, hex.EncodeToString(sum[:]), strings.Repeat("ab", 32), 1)
|
||||
bp := filepath.Join(dir, "bad-digest.yaml")
|
||||
if err := os.WriteFile(bp, []byte(bad), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flow, err = LoadFlow(bp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ReplayFlow(ctx, flow, ReplayConfig{Target: srv.URL, BaseDir: dir})
|
||||
if err == nil || !strings.Contains(err.Error(), "digest") {
|
||||
t.Fatalf("digest mismatch: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comparison failure continues later steps", func(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/one":
|
||||
_, _ = w.Write([]byte(`{"data":"no"}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"data":"ok"}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
flow := Flow{
|
||||
Version: 1,
|
||||
Name: "mismatch-continue",
|
||||
Steps: []Step{
|
||||
{ID: "a", Request: Request{Method: http.MethodGet, Path: "/one"}, Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"yes"}`)}},
|
||||
{ID: "b", Request: Request{Method: http.MethodGet, Path: "/two"}, Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"ok"}`)}},
|
||||
},
|
||||
}
|
||||
res, err := ReplayFlow(ctx, flow, ReplayConfig{Target: srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("mismatch must error")
|
||||
}
|
||||
if hits.Load() != 2 {
|
||||
t.Fatalf("later step must run, hits=%d", hits.Load())
|
||||
}
|
||||
if len(res.Steps) != 2 || res.Steps[1].Skipped || !res.Steps[1].OK {
|
||||
t.Fatalf("step b should pass: %+v", res.Steps)
|
||||
}
|
||||
assertPathMismatch(t, err, "$.data")
|
||||
})
|
||||
|
||||
t.Run("capture failure skips remainder", func(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":"ok"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
flow := Flow{
|
||||
Version: 1,
|
||||
Name: "capture-skip",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "a",
|
||||
Request: Request{Method: http.MethodGet, Path: "/one"},
|
||||
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"ok"}`)},
|
||||
Capture: []CaptureRule{{From: "response.json", Path: "$.token", As: "jwt:alice"}},
|
||||
},
|
||||
{ID: "b", Request: Request{Method: http.MethodGet, Path: "/two"}, Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"ok"}`)}},
|
||||
},
|
||||
}
|
||||
res, err := ReplayFlow(ctx, flow, ReplayConfig{Target: srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("failed capture must error")
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("capture fail should skip rest, hits=%d", hits.Load())
|
||||
}
|
||||
if len(res.Steps) != 2 || !res.Steps[1].Skipped {
|
||||
t.Fatalf("step b should skip: %+v", res.Steps)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "capture") {
|
||||
t.Fatalf("capture diagnostic: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing variable fails before send", func(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
flow := Flow{Version: 1, Name: "missing-var", Steps: []Step{
|
||||
{ID: "a", Request: Request{Method: http.MethodGet, Path: "/items/{{missing}}"}, Response: Response{Status: 200}},
|
||||
{ID: "b", Request: Request{Method: http.MethodGet, Path: "/later"}, Response: Response{Status: 200}},
|
||||
}}
|
||||
res, err := ReplayFlow(ctx, flow, ReplayConfig{Target: srv.URL})
|
||||
if err == nil || !strings.Contains(err.Error(), "unresolved") && !strings.Contains(err.Error(), "placeholder") {
|
||||
t.Fatalf("missing variable: %v", err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("must not send unresolved placeholder, hits=%d", hits.Load())
|
||||
}
|
||||
if len(res.Steps) != 2 || !res.Steps[1].Skipped {
|
||||
t.Fatalf("remainder must skip: %+v", res.Steps)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown capture source", func(t *testing.T) {
|
||||
store, err := OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := jsonServer(t, `{"ok":true}`)
|
||||
spec := Flow{Version: 1, Name: "bad-capture", Steps: []Step{{
|
||||
ID: "a",
|
||||
Request: Request{Method: http.MethodGet, Path: "/ok"},
|
||||
Capture: []CaptureRule{{From: "response.unknown", Path: "$.ok", As: "x"}},
|
||||
}}}
|
||||
_, err = RecordFlow(ctx, spec, RecordConfig{Target: srv.URL, Store: store})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown") {
|
||||
t.Fatalf("unknown capture from: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("capture-by-reference mismatch", func(t *testing.T) {
|
||||
store, err := OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/create":
|
||||
_, _ = w.Write([]byte(`{"token":"shareTokValue99"}`))
|
||||
case "/show":
|
||||
n++
|
||||
if n == 1 {
|
||||
_, _ = w.Write([]byte(`{"token":"shareTokValue99"}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"token":"shareTokOther00"}`))
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
spec := Flow{
|
||||
Version: 1,
|
||||
Name: "capture-ref",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "create",
|
||||
Request: Request{Method: http.MethodGet, Path: "/create"},
|
||||
Capture: []CaptureRule{{From: "response.json", Path: "$.token", As: "share:item"}},
|
||||
},
|
||||
{ID: "show", Request: Request{Method: http.MethodGet, Path: "/show"}},
|
||||
},
|
||||
}
|
||||
rec, err := RecordFlow(ctx, spec, RecordConfig{Target: srv.URL, Store: store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(rec.Steps[0].Response.Body), "{{share:item}}") {
|
||||
t.Fatalf("create not scrubbed: %s", rec.Steps[0].Response.Body)
|
||||
}
|
||||
if !strings.Contains(string(rec.Steps[1].Response.Body), "{{share:item}}") {
|
||||
t.Fatalf("show not scrubbed by reference: %s", rec.Steps[1].Response.Body)
|
||||
}
|
||||
replayStore, err := OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ReplayFlow(ctx, rec, ReplayConfig{Target: srv.URL, Store: replayStore})
|
||||
if err == nil {
|
||||
t.Fatal("capture-by-reference mismatch must fail")
|
||||
}
|
||||
assertPathMismatch(t, err, "token")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlowContractTwoFlowsContinue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"v":2}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
first := Flow{Version: 1, Name: "first", Steps: []Step{{
|
||||
ID: "a", Request: Request{Method: http.MethodGet, Path: "/a"},
|
||||
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"v":1}`)},
|
||||
}}}
|
||||
second := Flow{Version: 1, Name: "second", Steps: []Step{{
|
||||
ID: "b", Request: Request{Method: http.MethodGet, Path: "/b"},
|
||||
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"v":2}`)},
|
||||
}}}
|
||||
_, err := ReplayFlow(ctx, first, ReplayConfig{Target: srv.URL})
|
||||
if err == nil {
|
||||
t.Fatal("first flow must fail")
|
||||
}
|
||||
if _, err := ReplayFlow(ctx, second, ReplayConfig{Target: srv.URL}); err != nil {
|
||||
t.Fatalf("later flow must still run: %v", err)
|
||||
}
|
||||
if hits.Load() != 2 {
|
||||
t.Fatalf("both flows must execute, hits=%d", hits.Load())
|
||||
}
|
||||
var mis *MismatchError
|
||||
if !errors.As(err, &mis) || mis.Result.OK {
|
||||
t.Fatalf("first flow mismatch result: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user