- attach.PublicURL and (*File).URL build Winter File::getPath() URLs; the thumbnailer decodes webp via golang.org/x/image v0.46.0 and checks the image size from the header before decoding - tide requests carry multipart parts (files beside the fixture pinned by sha256) encoded with the fixed MultipartBoundary, so PHP and Go receive byte-identical bodies - tide masks the random partition, disk name and file id of url/thumb_url upload URLs while still diffing prefix, size, mode and extension, and NormalizePublications masks Carbon dates in the published album
249 lines
6.9 KiB
Go
249 lines
6.9 KiB
Go
package tide
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const CurrentVersion = 1
|
|
|
|
// DefaultMaxBody is the default cap on recorded or replayed HTTP bodies.
|
|
const DefaultMaxBody = 8 << 20
|
|
|
|
// Flow is a versioned ordered list of HTTP steps.
|
|
type Flow struct {
|
|
Version int `yaml:"version"`
|
|
Name string `yaml:"name"`
|
|
Description string `yaml:"description,omitempty"`
|
|
SeedHook string `yaml:"seed_hook,omitempty"`
|
|
Steps []Step `yaml:"steps"`
|
|
}
|
|
|
|
// Step is one request/response pair in a flow.
|
|
type Step struct {
|
|
ID string `yaml:"id"`
|
|
RouteID string `yaml:"route_id,omitempty"`
|
|
Request Request `yaml:"request"`
|
|
Response Response `yaml:"response"`
|
|
Capture []CaptureRule `yaml:"capture,omitempty"`
|
|
Normalize []NormalizeRule `yaml:"normalize,omitempty"`
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
}
|
|
|
|
// Request is the outbound HTTP call for a step.
|
|
type Request struct {
|
|
Method string `yaml:"method"`
|
|
Path string `yaml:"path"`
|
|
Query string `yaml:"query,omitempty"`
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
Body Body `yaml:"body,omitempty"`
|
|
// Parts is a multipart/form-data body, encoded with MultipartBoundary
|
|
// when the step runs. A request has Body or Parts, never both.
|
|
Parts []Part `yaml:"parts,omitempty"`
|
|
}
|
|
|
|
// Response is the recorded or expected HTTP reply.
|
|
type Response struct {
|
|
Status int `yaml:"status,omitempty"`
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
Body Body `yaml:"body,omitempty"`
|
|
BodyFile string `yaml:"body_file,omitempty"`
|
|
SHA256 string `yaml:"sha256,omitempty"`
|
|
}
|
|
|
|
// CaptureRule maps a named source onto a variable name.
|
|
type CaptureRule struct {
|
|
From string `yaml:"from,omitempty"`
|
|
Path string `yaml:"path,omitempty"`
|
|
Name string `yaml:"name,omitempty"`
|
|
As string `yaml:"as"`
|
|
Identity string `yaml:"identity,omitempty"`
|
|
Category string `yaml:"category,omitempty"`
|
|
}
|
|
|
|
// NormalizeRule names a per-step normalizer override.
|
|
type NormalizeRule struct {
|
|
Path string `yaml:"path,omitempty"`
|
|
Disable bool `yaml:"disable,omitempty"`
|
|
}
|
|
|
|
// Body is verbatim request or response bytes stored as a YAML literal scalar.
|
|
type Body string
|
|
|
|
// RecordConfig injects the HTTP target, client and body bound for recording.
|
|
// BaseDir is the fixture directory that request part files are read from.
|
|
type RecordConfig struct {
|
|
Target string
|
|
Client *http.Client
|
|
MaxBody int64
|
|
Store *Store
|
|
Rules Rules
|
|
BaseDir string
|
|
}
|
|
|
|
// ReplayConfig injects the HTTP target, client and body bound for replay.
|
|
// BaseDir is the fixture directory that body_file sidecars and request part
|
|
// files are read from.
|
|
// UploadPrefix is the public URL prefix of uploaded files whose url and
|
|
// thumb_url values are compared by shape; empty means DefaultUploadPrefix.
|
|
type ReplayConfig struct {
|
|
Target string
|
|
Client *http.Client
|
|
MaxBody int64
|
|
Store *Store
|
|
BaseDir string
|
|
UploadPrefix string
|
|
}
|
|
|
|
// Result is the outcome of replaying a flow.
|
|
type Result struct {
|
|
OK bool
|
|
Steps []StepResult
|
|
}
|
|
|
|
// StepResult is the outcome of one replayed step.
|
|
type StepResult struct {
|
|
ID string
|
|
OK bool
|
|
Skipped bool
|
|
Diffs []Diff
|
|
}
|
|
|
|
// Diff is one structural JSON or raw-byte mismatch.
|
|
type Diff struct {
|
|
Path string
|
|
Expected string
|
|
Actual string
|
|
Offset int
|
|
Byte bool
|
|
}
|
|
|
|
// MismatchError is returned when replay finds one or more differences.
|
|
type MismatchError struct {
|
|
Result Result
|
|
}
|
|
|
|
func (e *MismatchError) Error() string {
|
|
if e == nil {
|
|
return "tide: mismatch"
|
|
}
|
|
var b strings.Builder
|
|
for _, step := range e.Result.Steps {
|
|
for _, d := range step.Diffs {
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
if d.Byte {
|
|
fmt.Fprintf(&b, "step %s: body mismatch at byte %d: expected %s actual %s", step.ID, d.Offset, redactSecrets(d.Expected), redactSecrets(d.Actual))
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "step %s: %s: expected %s actual %s", step.ID, d.Path, redactSecrets(d.Expected), redactSecrets(d.Actual))
|
|
}
|
|
}
|
|
if b.Len() == 0 {
|
|
return "tide: mismatch"
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func validateFlow(flow Flow) error {
|
|
if flow.Version != CurrentVersion {
|
|
return fmt.Errorf("tide: unsupported version %d (want %d)", flow.Version, CurrentVersion)
|
|
}
|
|
if strings.TrimSpace(flow.Name) == "" {
|
|
return fmt.Errorf("tide: flow name is required")
|
|
}
|
|
if len(flow.Steps) == 0 {
|
|
return fmt.Errorf("tide: flow %q has no steps", flow.Name)
|
|
}
|
|
seen := make(map[string]struct{}, len(flow.Steps))
|
|
for i, step := range flow.Steps {
|
|
if strings.TrimSpace(step.ID) == "" {
|
|
return fmt.Errorf("tide: steps[%d] is missing id", i)
|
|
}
|
|
if _, dup := seen[step.ID]; dup {
|
|
return fmt.Errorf("tide: duplicate step id %q", step.ID)
|
|
}
|
|
seen[step.ID] = struct{}{}
|
|
if strings.TrimSpace(step.Request.Method) == "" {
|
|
return fmt.Errorf("tide: step %s is missing request method", step.ID)
|
|
}
|
|
if strings.TrimSpace(step.Request.Path) == "" {
|
|
return fmt.Errorf("tide: step %s is missing request path", step.ID)
|
|
}
|
|
if err := validateSidecar(step.Response.BodyFile); err != nil {
|
|
return fmt.Errorf("tide: step %s: %w", step.ID, err)
|
|
}
|
|
if err := validateParts(step.Request); err != nil {
|
|
return fmt.Errorf("tide: step %s: %w", step.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func materializeSidecar(base string, resp *Response) error {
|
|
if resp == nil || resp.BodyFile == "" {
|
|
return nil
|
|
}
|
|
if err := validateSidecar(resp.BodyFile); err != nil {
|
|
return err
|
|
}
|
|
path := resp.BodyFile
|
|
if base != "" {
|
|
path = filepath.Join(base, resp.BodyFile)
|
|
}
|
|
resolved, err := resolvePath(path)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: read body_file %s: %w", resp.BodyFile, err)
|
|
}
|
|
if base != "" {
|
|
root, err := resolvePath(base)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: fixture dir: %w", err)
|
|
}
|
|
if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
|
return fmt.Errorf("tide: body_file %q escapes the fixture directory", resp.BodyFile)
|
|
}
|
|
}
|
|
raw, err := os.ReadFile(resolved)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: read body_file %s: %w", resp.BodyFile, err)
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
got := hex.EncodeToString(sum[:])
|
|
if resp.SHA256 != "" && !strings.EqualFold(got, resp.SHA256) {
|
|
return fmt.Errorf("tide: body_file %s digest mismatch", resp.BodyFile)
|
|
}
|
|
resp.Body = Body(raw)
|
|
return nil
|
|
}
|
|
|
|
func validateSidecar(path string) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
if filepath.IsAbs(path) {
|
|
return fmt.Errorf("body_file %q must be a relative path", path)
|
|
}
|
|
clean := filepath.ToSlash(filepath.Clean(path))
|
|
if clean == ".." || strings.HasPrefix(clean, "../") {
|
|
return fmt.Errorf("body_file %q escapes the fixture directory", path)
|
|
}
|
|
if _, err := resolvePath(path); err != nil {
|
|
return fmt.Errorf("body_file %q: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func maxBody(n int64) int64 {
|
|
if n <= 0 {
|
|
return DefaultMaxBody
|
|
}
|
|
return n
|
|
}
|