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) } if _, err := resolvePath(p); err != nil { return fmt.Errorf("fixture %q: %w", p, err) } 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 } if _, err := os.Stat(dest); err == nil { return SaveFlow(dest, flow) } return SaveFlowExclusive(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 cfg.Update { if err := SaveFlow(dest, flow); err != nil { return err } continue } if err := SaveFlowExclusive(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, redactSecrets(d.Expected), redactSecrets(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 }