Re-running bootstrap against an already seeded PHP instance 409s. Route --update must not recapture a complete seed fixture. Co-authored-by: Cursor <cursoragent@cursor.com>
562 lines
14 KiB
Go
562 lines
14 KiB
Go
package tide
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
const MaxBatch = 15
|
|
|
|
const (
|
|
StatusPending = "pending"
|
|
StatusPorted = "ported"
|
|
)
|
|
|
|
const (
|
|
ModeAllowIncomplete = "allow-incomplete"
|
|
ModeRequireRecorded = "require-recorded"
|
|
)
|
|
|
|
// Manifest is a generic ordered list of route cases. It has no app-specific names.
|
|
type Manifest struct {
|
|
Version int `yaml:"version"`
|
|
AuthGroups []string `yaml:"auth_groups"`
|
|
Seed *Seed `yaml:"seed,omitempty"`
|
|
Routes []Route `yaml:"routes"`
|
|
}
|
|
|
|
// Seed names an optional bootstrap flow recorded before route cases.
|
|
type Seed struct {
|
|
Hook string `yaml:"hook,omitempty"`
|
|
Spec string `yaml:"spec,omitempty"`
|
|
Fixture string `yaml:"fixture,omitempty"`
|
|
}
|
|
|
|
// Route is one manifest entry with identity, cases and fixture path.
|
|
type Route struct {
|
|
ID string `yaml:"id"`
|
|
Method string `yaml:"method"`
|
|
Path string `yaml:"path"`
|
|
AuthGroup string `yaml:"auth_group"`
|
|
Status string `yaml:"status"`
|
|
Identities []string `yaml:"identities,omitempty"`
|
|
Cases []RouteCase `yaml:"cases,omitempty"`
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
Normalize []NormalizeRule `yaml:"normalize,omitempty"`
|
|
SeedHook string `yaml:"seed_hook,omitempty"`
|
|
Fixture string `yaml:"fixture,omitempty"`
|
|
}
|
|
|
|
// RouteCase is one recorded request/response expectation for a route.
|
|
type RouteCase struct {
|
|
ID string `yaml:"id"`
|
|
Identity string `yaml:"identity,omitempty"`
|
|
Status int `yaml:"status,omitempty"`
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
Fixture string `yaml:"fixture,omitempty"`
|
|
Request *Request `yaml:"request,omitempty"`
|
|
Capture []CaptureRule `yaml:"capture,omitempty"`
|
|
}
|
|
|
|
// ManifestConfig drives recording or replay of a manifest.
|
|
type ManifestConfig struct {
|
|
Target string
|
|
Fixtures string
|
|
Store *Store
|
|
Rules Rules
|
|
Update bool
|
|
Resume bool
|
|
NextBatch int
|
|
Mode string
|
|
SelfCheck bool
|
|
}
|
|
|
|
// LoadManifest reads a strict YAML manifest.
|
|
func LoadManifest(path string) (Manifest, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("tide: read manifest %s: %w", path, err)
|
|
}
|
|
return ParseManifest(raw)
|
|
}
|
|
|
|
// ParseManifest decodes a manifest, rejecting unknown fields.
|
|
func ParseManifest(raw []byte) (Manifest, error) {
|
|
var m Manifest
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
|
if err := dec.Decode(&m); err != nil {
|
|
return Manifest{}, fmt.Errorf("tide: parse manifest: %w", err)
|
|
}
|
|
if m.Version != CurrentVersion {
|
|
return Manifest{}, fmt.Errorf("tide: unsupported manifest version %d", m.Version)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ValidateManifest checks identities in both modes; case/fixture completeness depends on mode.
|
|
func ValidateManifest(m Manifest, fixtures, mode string) error {
|
|
if len(m.AuthGroups) == 0 {
|
|
return fmt.Errorf("tide: manifest auth_groups is required")
|
|
}
|
|
groups := make(map[string]struct{}, len(m.AuthGroups))
|
|
for _, g := range m.AuthGroups {
|
|
if strings.TrimSpace(g) == "" {
|
|
return fmt.Errorf("tide: empty auth group")
|
|
}
|
|
groups[g] = struct{}{}
|
|
}
|
|
if len(m.Routes) == 0 {
|
|
return fmt.Errorf("tide: manifest has no routes")
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for i, route := range m.Routes {
|
|
if strings.TrimSpace(route.ID) == "" {
|
|
return fmt.Errorf("tide: routes[%d] is missing id", i)
|
|
}
|
|
if _, dup := seen[route.ID]; dup {
|
|
return fmt.Errorf("tide: duplicate route id %q", route.ID)
|
|
}
|
|
seen[route.ID] = struct{}{}
|
|
if strings.TrimSpace(route.Method) == "" || strings.TrimSpace(route.Path) == "" {
|
|
return fmt.Errorf("tide: route %s is missing method or path", route.ID)
|
|
}
|
|
if _, ok := groups[route.AuthGroup]; !ok {
|
|
return fmt.Errorf("tide: route %s has unknown auth group %q", route.ID, route.AuthGroup)
|
|
}
|
|
if route.Status != StatusPending && route.Status != StatusPorted {
|
|
return fmt.Errorf("tide: route %s has unknown status %q", route.ID, route.Status)
|
|
}
|
|
if err := validateFixturePath(route.Fixture); err != nil {
|
|
return fmt.Errorf("tide: route %s: %w", route.ID, err)
|
|
}
|
|
for j, c := range route.Cases {
|
|
if err := validateFixturePath(c.Fixture); err != nil {
|
|
return fmt.Errorf("tide: route %s case[%d]: %w", route.ID, j, err)
|
|
}
|
|
if mode == ModeRequireRecorded {
|
|
if err := requireCase(route, c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if mode == ModeRequireRecorded {
|
|
if len(route.Cases) == 0 {
|
|
return fmt.Errorf("tide: route %s is missing cases", route.ID)
|
|
}
|
|
for _, c := range routeCases(route) {
|
|
path := caseFixturePath(fixtures, route, c)
|
|
if _, err := LoadFlow(path); err != nil {
|
|
return fmt.Errorf("tide: route %s missing valid fixture %s: %w", route.ID, path, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireCase(route Route, c RouteCase) error {
|
|
if strings.TrimSpace(c.ID) == "" {
|
|
return fmt.Errorf("tide: route %s has a case without id", route.ID)
|
|
}
|
|
if c.Status == 0 {
|
|
return fmt.Errorf("tide: route %s case %s is missing status", route.ID, c.ID)
|
|
}
|
|
req := caseRequest(route, c)
|
|
if strings.TrimSpace(req.Method) == "" || strings.TrimSpace(req.Path) == "" {
|
|
return fmt.Errorf("tide: route %s case %s is missing request", route.ID, c.ID)
|
|
}
|
|
if caseFixturePath("", route, c) == "" {
|
|
return fmt.Errorf("tide: route %s case %s is missing fixture", route.ID, c.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFixturePath(p string) error {
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
if filepath.IsAbs(p) {
|
|
return fmt.Errorf("fixture %q must be a relative path", p)
|
|
}
|
|
clean := filepath.ToSlash(filepath.Clean(p))
|
|
if clean == ".." || strings.HasPrefix(clean, "../") {
|
|
return fmt.Errorf("fixture %q escapes the fixture directory", p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func routeCases(route Route) []RouteCase {
|
|
if len(route.Cases) > 0 {
|
|
return route.Cases
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func caseRequest(route Route, c RouteCase) Request {
|
|
if c.Request != nil {
|
|
return *c.Request
|
|
}
|
|
return Request{Method: route.Method, Path: route.Path}
|
|
}
|
|
|
|
func caseFixturePath(root string, route Route, c RouteCase) string {
|
|
rel := c.Fixture
|
|
if rel == "" {
|
|
rel = route.Fixture
|
|
}
|
|
if rel == "" && c.ID != "" {
|
|
rel = filepath.ToSlash(filepath.Join("routes", sanitizeFile(route.ID)+"__"+sanitizeFile(c.ID)+".yaml"))
|
|
}
|
|
if rel == "" && route.ID != "" {
|
|
rel = filepath.ToSlash(filepath.Join("routes", sanitizeFile(route.ID)+".yaml"))
|
|
}
|
|
if root == "" {
|
|
return rel
|
|
}
|
|
return filepath.Join(root, rel)
|
|
}
|
|
|
|
func sanitizeFile(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
repl := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
|
|
return repl.Replace(s)
|
|
}
|
|
|
|
func fixtureHash(path string) (string, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(bytes.TrimSpace(raw)) == 0 {
|
|
return "", fmt.Errorf("empty fixture")
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func recordedCase(fixtures string, route Route, c RouteCase) (bool, error) {
|
|
path := caseFixturePath(fixtures, route, c)
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
if _, err := LoadFlow(path); err != nil {
|
|
return false, fmt.Errorf("tide: existing fixture %s is invalid: %w", path, err)
|
|
}
|
|
if _, err := fixtureHash(path); err != nil {
|
|
return false, fmt.Errorf("tide: existing fixture %s hash: %w", path, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// RecordManifest records seed then route cases. NextBatch>15 is refused.
|
|
func RecordManifest(ctx context.Context, m Manifest, cfg ManifestConfig) (Coverage, error) {
|
|
if cfg.NextBatch > MaxBatch {
|
|
return Coverage{}, fmt.Errorf("tide: --next-batch %d exceeds %d", cfg.NextBatch, MaxBatch)
|
|
}
|
|
if err := ValidateManifest(m, cfg.Fixtures, ModeAllowIncomplete); err != nil {
|
|
return Coverage{}, err
|
|
}
|
|
cov := newCoverage(m)
|
|
if m.Seed != nil && strings.TrimSpace(m.Seed.Spec) != "" {
|
|
if err := recordSeed(ctx, m.Seed, cfg); err != nil {
|
|
return cov, err
|
|
}
|
|
}
|
|
limit := cfg.NextBatch
|
|
if limit <= 0 {
|
|
limit = len(m.Routes)
|
|
}
|
|
taken := 0
|
|
for _, route := range m.Routes {
|
|
cases := route.Cases
|
|
if len(cases) == 0 {
|
|
cov.markUnrecorded(route)
|
|
continue
|
|
}
|
|
allRecorded := true
|
|
for _, c := range cases {
|
|
ok, err := recordedCase(cfg.Fixtures, route, c)
|
|
if err != nil {
|
|
return cov, err
|
|
}
|
|
if !ok {
|
|
allRecorded = false
|
|
break
|
|
}
|
|
}
|
|
if allRecorded && !cfg.Update {
|
|
cov.markRecorded(route)
|
|
continue
|
|
}
|
|
if taken >= limit {
|
|
cov.markUnrecorded(route)
|
|
continue
|
|
}
|
|
if err := recordRoute(ctx, route, cfg); err != nil {
|
|
return cov, err
|
|
}
|
|
cov.markRecorded(route)
|
|
taken++
|
|
}
|
|
cov.ResumeRemaining = countUnrecorded(m, cfg.Fixtures)
|
|
return cov, nil
|
|
}
|
|
|
|
func recordSeed(ctx context.Context, seed *Seed, cfg ManifestConfig) error {
|
|
dest := seed.Fixture
|
|
if dest == "" {
|
|
dest = seed.Spec
|
|
}
|
|
if cfg.Fixtures != "" && !filepath.IsAbs(dest) {
|
|
dest = filepath.Join(cfg.Fixtures, dest)
|
|
}
|
|
if recorded, err := seedAlreadyRecorded(dest); err != nil {
|
|
return err
|
|
} else if recorded {
|
|
return nil
|
|
}
|
|
spec, err := LoadFlow(resolveSeedSpec(seed.Spec, cfg.Fixtures))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flow, err := RecordFlow(ctx, spec, RecordConfig{Target: cfg.Target, Store: cfg.Store, Rules: cfg.Rules})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return SaveFlow(dest, flow)
|
|
}
|
|
|
|
func seedAlreadyRecorded(path string) (bool, error) {
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
if st.IsDir() || st.Size() == 0 {
|
|
return false, nil
|
|
}
|
|
flow, err := LoadFlow(path)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
for _, step := range flow.Steps {
|
|
if step.Response.Status == 0 {
|
|
return false, nil
|
|
}
|
|
}
|
|
return len(flow.Steps) > 0, nil
|
|
}
|
|
|
|
func resolveSeedSpec(spec, fixtures string) string {
|
|
if spec == "" || filepath.IsAbs(spec) || fixtures == "" {
|
|
return spec
|
|
}
|
|
joined := filepath.Join(fixtures, spec)
|
|
if _, err := os.Stat(joined); err == nil {
|
|
return joined
|
|
}
|
|
return spec
|
|
}
|
|
|
|
func recordRoute(ctx context.Context, route Route, cfg ManifestConfig) error {
|
|
for _, c := range route.Cases {
|
|
dest := caseFixturePath(cfg.Fixtures, route, c)
|
|
if _, err := os.Stat(dest); err == nil {
|
|
if cfg.Resume && !cfg.Update {
|
|
if _, err := LoadFlow(dest); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if !cfg.Update {
|
|
return fmt.Errorf("tide: fixture %s exists; pass --update to overwrite", dest)
|
|
}
|
|
}
|
|
spec := Flow{
|
|
Version: CurrentVersion,
|
|
Name: route.ID + "/" + c.ID,
|
|
SeedHook: route.SeedHook,
|
|
Steps: []Step{{
|
|
ID: c.ID,
|
|
RouteID: route.ID,
|
|
Request: caseRequest(route, c),
|
|
Capture: c.Capture,
|
|
Normalize: route.Normalize,
|
|
Headers: mergeHeaders(route.Headers, c.Headers),
|
|
}},
|
|
}
|
|
flow, err := RecordFlow(ctx, spec, RecordConfig{Target: cfg.Target, Store: cfg.Store, Rules: cfg.Rules})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := SaveFlow(dest, flow); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mergeHeaders(a, b map[string]string) map[string]string {
|
|
if len(a) == 0 && len(b) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string)
|
|
for k, v := range a {
|
|
out[k] = v
|
|
}
|
|
for k, v := range b {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func countUnrecorded(m Manifest, fixtures string) int {
|
|
n := 0
|
|
for _, route := range m.Routes {
|
|
if len(route.Cases) == 0 {
|
|
n++
|
|
continue
|
|
}
|
|
for _, c := range route.Cases {
|
|
ok, err := recordedCase(fixtures, route, c)
|
|
if err != nil || !ok {
|
|
n++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ReplayManifest replays recorded route fixtures and builds a coverage table.
|
|
func ReplayManifest(ctx context.Context, m Manifest, cfg ManifestConfig) (Coverage, error) {
|
|
if err := ValidateManifest(m, cfg.Fixtures, ModeAllowIncomplete); err != nil {
|
|
return Coverage{}, err
|
|
}
|
|
cov := newCoverage(m)
|
|
var fail error
|
|
for _, route := range m.Routes {
|
|
cases := route.Cases
|
|
if len(cases) == 0 {
|
|
cov.markUnrecorded(route)
|
|
if cfg.Mode == ModeRequireRecorded {
|
|
fail = firstErr(fail, fmt.Errorf("tide: unrecorded required route %s", route.ID))
|
|
}
|
|
continue
|
|
}
|
|
routeFail := false
|
|
routeRecorded := true
|
|
for _, c := range cases {
|
|
path := caseFixturePath(cfg.Fixtures, route, c)
|
|
flow, err := LoadFlow(path)
|
|
if err != nil {
|
|
routeRecorded = false
|
|
if cfg.Mode == ModeRequireRecorded {
|
|
fail = firstErr(fail, fmt.Errorf("tide: unrecorded required route %s", route.ID))
|
|
}
|
|
continue
|
|
}
|
|
res, err := ReplayFlow(ctx, flow, ReplayConfig{Target: cfg.Target, Store: cfg.Store, BaseDir: filepath.Dir(path)})
|
|
if err != nil {
|
|
var mis *MismatchError
|
|
if !errors.As(err, &mis) {
|
|
return cov, err
|
|
}
|
|
res = mis.Result
|
|
}
|
|
if !res.OK {
|
|
routeFail = true
|
|
cov.addDiffs(route.ID, res)
|
|
if cfg.SelfCheck || route.Status == StatusPorted {
|
|
fail = firstErr(fail, fmt.Errorf("tide: route %s failed", route.ID))
|
|
}
|
|
}
|
|
}
|
|
if !routeRecorded {
|
|
cov.markUnrecorded(route)
|
|
continue
|
|
}
|
|
if routeFail {
|
|
cov.markFailing(route)
|
|
continue
|
|
}
|
|
cov.markPassing(route)
|
|
}
|
|
return cov, fail
|
|
}
|
|
|
|
func firstErr(cur, next error) error {
|
|
if cur == nil {
|
|
return next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func newCoverage(m Manifest) Coverage {
|
|
c := Coverage{Total: len(m.Routes), Rows: make([]CoverageRow, 0, len(m.Routes))}
|
|
for _, route := range m.Routes {
|
|
if route.Status == StatusPending {
|
|
c.Pending++
|
|
} else if route.Status == StatusPorted {
|
|
c.Ported++
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (c *Coverage) markRecorded(route Route) {
|
|
c.Recorded++
|
|
c.Rows = append(c.Rows, CoverageRow{ID: route.ID, Status: route.Status, Outcome: "recorded"})
|
|
}
|
|
|
|
func (c *Coverage) markPassing(route Route) {
|
|
c.Recorded++
|
|
c.Passing++
|
|
c.Rows = append(c.Rows, CoverageRow{ID: route.ID, Status: route.Status, Outcome: "passing"})
|
|
}
|
|
|
|
func (c *Coverage) markFailing(route Route) {
|
|
c.Recorded++
|
|
c.Failing++
|
|
c.Rows = append(c.Rows, CoverageRow{ID: route.ID, Status: route.Status, Outcome: "failing"})
|
|
}
|
|
|
|
func (c *Coverage) markUnrecorded(route Route) {
|
|
c.Unrecorded++
|
|
c.Rows = append(c.Rows, CoverageRow{ID: route.ID, Status: route.Status, Outcome: "unrecorded"})
|
|
}
|
|
|
|
func (c *Coverage) addDiffs(id string, res Result) {
|
|
for _, sr := range res.Steps {
|
|
for _, d := range sr.Diffs {
|
|
c.Diffs = append(c.Diffs, fmt.Sprintf("%s %s: %s expected %s actual %s", id, sr.ID, d.Path, d.Expected, d.Actual))
|
|
}
|
|
}
|
|
}
|
|
|
|
func ParseNextBatch(s string) (int, error) {
|
|
if strings.TrimSpace(s) == "" {
|
|
return 0, nil
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("tide: --next-batch: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|