package tide import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "io" "mime" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" ) // multipartFixture writes files/cover.png under a temp fixture directory and // returns the directory and a one-step upload flow that references it. func multipartFixture(t *testing.T) (string, Flow) { t.Helper() dir := t.TempDir() content := []byte("\x89PNG\r\n\x1a\nfake image bytes") if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "files", "cover.png"), content, 0o644); err != nil { t.Fatal(err) } sum := sha256.Sum256(content) flow := Flow{ Version: CurrentVersion, Name: "upload", Steps: []Step{{ ID: "upload", Request: Request{ Method: "POST", Path: "/posts/1/photos", Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=recorded-elsewhere"}, Parts: []Part{ {Name: "title", Value: "Cover {{id:post}}"}, {Name: "file", File: "files/cover.png", ContentType: "image/png", SHA256: hex.EncodeToString(sum[:])}, }, }, }}, } return dir, flow } type rawCapture struct { mu sync.Mutex bodies [][]byte types []string } func (c *rawCapture) handler(t *testing.T) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) if err != nil { t.Error(err) } c.mu.Lock() c.bodies = append(c.bodies, raw) c.types = append(c.types, r.Header.Get("Content-Type")) c.mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) } } func TestMultipartRecordReplaySendsIdenticalBytes(t *testing.T) { dir, flow := multipartFixture(t) capture := &rawCapture{} srv := httptest.NewServer(capture.handler(t)) t.Cleanup(srv.Close) store := mustMemoryStore() store.Set("id:post", "7") recorded, err := RecordFlow(t.Context(), flow, RecordConfig{Target: srv.URL, Store: store, BaseDir: dir}) if err != nil { t.Fatalf("record: %v", err) } if _, err := RecordFlow(t.Context(), flow, RecordConfig{Target: srv.URL, Store: store, BaseDir: dir}); err != nil { t.Fatalf("second record: %v", err) } if _, err := ReplayFlow(t.Context(), recorded, ReplayConfig{Target: srv.URL, Store: store, BaseDir: dir}); err != nil { t.Fatalf("replay: %v", err) } if len(capture.bodies) != 3 { t.Fatalf("requests = %d", len(capture.bodies)) } for i := 1; i < 3; i++ { if !bytes.Equal(capture.bodies[0], capture.bodies[i]) { t.Fatalf("body %d differs:\n%q\n%q", i, capture.bodies[0], capture.bodies[i]) } if capture.types[i] != capture.types[0] { t.Fatalf("content type %d = %q", i, capture.types[i]) } } media, params, err := mime.ParseMediaType(capture.types[0]) if err != nil || media != "multipart/form-data" || params["boundary"] != MultipartBoundary { t.Fatalf("content type = %q", capture.types[0]) } form, err := multipart.NewReader(bytes.NewReader(capture.bodies[0]), MultipartBoundary).ReadForm(1 << 20) if err != nil { t.Fatal(err) } if got := form.Value["title"]; len(got) != 1 || got[0] != "Cover 7" { t.Fatalf("title = %q (variables expand in values)", got) } fh := form.File["file"] if len(fh) != 1 || fh[0].Filename != "cover.png" || fh[0].Header.Get("Content-Type") != "image/png" { t.Fatalf("file part = %+v", fh) } // The recording keeps the parts, never the file bytes. raw, err := marshalFlow(recorded) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "fake image bytes") || !strings.Contains(string(raw), "file: files/cover.png") { t.Fatalf("recorded flow:\n%s", raw) } parsed, err := ParseFlow(raw) if err != nil { t.Fatalf("parse recorded flow: %v", err) } if len(parsed.Steps[0].Request.Parts) != 2 || parsed.Steps[0].Request.Parts[1].SHA256 != flow.Steps[0].Request.Parts[1].SHA256 { t.Fatalf("parts after round trip = %+v", parsed.Steps[0].Request.Parts) } } func TestMultipartTamperedPartFileFailsLoad(t *testing.T) { dir, flow := multipartFixture(t) raw, err := marshalFlow(flow) if err != nil { t.Fatal(err) } path := filepath.Join(dir, "upload.yaml") if err := os.WriteFile(path, raw, 0o644); err != nil { t.Fatal(err) } if _, err := LoadFlow(path); err != nil { t.Fatalf("intact fixture: %v", err) } if err := os.WriteFile(filepath.Join(dir, "files", "cover.png"), []byte("tampered"), 0o644); err != nil { t.Fatal(err) } _, err = LoadFlow(path) if err == nil || !strings.Contains(err.Error(), `part "file"`) || !strings.Contains(err.Error(), "sha256") { t.Fatalf("tampered file: %v", err) } if err := os.Remove(filepath.Join(dir, "files", "cover.png")); err != nil { t.Fatal(err) } if _, err := LoadFlow(path); err == nil { t.Fatal("missing part file must fail the load") } } func TestMultipartRejectsInvalidParts(t *testing.T) { _, flow := multipartFixture(t) cases := map[string]func(*Request){ "body and parts": func(r *Request) { r.Body = "x=1" }, "escaping file": func(r *Request) { r.Parts[1].File = "../secret.png" }, "missing sha256": func(r *Request) { r.Parts[1].SHA256 = "" }, "value and file": func(r *Request) { r.Parts[1].Value = "x" }, "missing name": func(r *Request) { r.Parts[0].Name = "" }, } for name, mutate := range cases { f := flow f.Steps = []Step{flow.Steps[0]} f.Steps[0].Request.Parts = append([]Part(nil), flow.Steps[0].Request.Parts...) mutate(&f.Steps[0].Request) if err := validateFlow(f); err == nil { t.Fatalf("%s: validateFlow accepted the flow", name) } } } func TestMultipartKeepsNonMultipartContentType(t *testing.T) { dir, flow := multipartFixture(t) req := flow.Steps[0].Request req.Headers = map[string]string{"Content-Type": "text/plain"} out, err := prepareRequest(req, dir) if err != nil { t.Fatal(err) } if out.Headers["Content-Type"] != "text/plain" { t.Fatalf("content type = %q", out.Headers["Content-Type"]) } req.Headers = nil out, err = prepareRequest(req, dir) if err != nil { t.Fatal(err) } if !strings.Contains(out.Headers["Content-Type"], MultipartBoundary) { t.Fatalf("content type = %q", out.Headers["Content-Type"]) } } func uploadBody(url, thumb string) []byte { b, _ := json.Marshal(map[string]any{"data": map[string]any{"photos": []any{map[string]any{"id": 1, "url": url, "thumb_url": thumb}}}}) return b } func TestNormalizeUploadURLMasksRandomParts(t *testing.T) { step := Step{ID: "u"} php := uploadBody( "/storage/app/uploads/public/651/a2b/3c4/651a2b3c4d5e61234567.png", "/storage/app/uploads/public/651/a2b/3c4/thumb_12_200_200_0_0_crop.png") goBody := uploadBody( "/storage/app/uploads/public/9f8/e7d/6c5/9f8e7d6c5b4a39281706f5e4d3c2b1a0.png", "/storage/app/uploads/public/9f8/e7d/6c5/thumb_3_200_200_0_0_crop.png") if diffs := compareBodies(Response{Headers: jsonCT(), Body: Body(php)}, Response{Headers: jsonCT(), Body: Body(goBody)}, step); len(diffs) != 0 { t.Fatalf("diffs = %+v", diffs) } // An external URL under url is not an upload and is compared as is. ext := uploadBody("https://img.example.com/a.jpg", "https://img.example.com/b.jpg") if diffs := compareBodies(Response{Headers: jsonCT(), Body: Body(ext)}, Response{Headers: jsonCT(), Body: Body(ext)}, step); len(diffs) != 0 { t.Fatalf("external diffs = %+v", diffs) } } func TestNormalizeUploadURLReportsWrongShape(t *testing.T) { step := Step{ID: "u"} php := uploadBody( "/storage/app/uploads/public/651/a2b/3c4/651a2b3c4d5e61234567.png", "/storage/app/uploads/public/651/a2b/3c4/thumb_12_200_200_0_0_crop.png") cases := map[string][]byte{ "thumb size": uploadBody( "/storage/app/uploads/public/9f8/e7d/6c5/9f8e7d6c5b4a39281706f5e4d3c2b1a0.png", "/storage/app/uploads/public/9f8/e7d/6c5/thumb_3_100_100_0_0_crop.png"), "prefix": uploadBody( "/storage/uploads/9f8/e7d/6c5/9f8e7d6c5b4a39281706f5e4d3c2b1a0.png", "/storage/app/uploads/public/9f8/e7d/6c5/thumb_3_200_200_0_0_crop.png"), "partition": uploadBody( "/storage/app/uploads/public/9f8e7d6c5b4a39281706f5e4d3c2b1a0.png", "/storage/app/uploads/public/9f8/e7d/6c5/thumb_3_200_200_0_0_crop.png"), "partition not from disk name": uploadBody( "/storage/app/uploads/public/aaa/bbb/ccc/9f8e7d6c5b4a39281706f5e4d3c2b1a0.png", "/storage/app/uploads/public/9f8/e7d/6c5/thumb_3_200_200_0_0_crop.png"), "extension": uploadBody( "/storage/app/uploads/public/9f8/e7d/6c5/9f8e7d6c5b4a39281706f5e4d3c2b1a0.jpg", "/storage/app/uploads/public/9f8/e7d/6c5/thumb_3_200_200_0_0_crop.png"), } for name, got := range cases { diffs := compareBodies(Response{Headers: jsonCT(), Body: Body(php)}, Response{Headers: jsonCT(), Body: Body(got)}, step) if len(diffs) == 0 { t.Fatalf("%s: masking hid the mismatch", name) } } // A configured prefix replaces the WinterCMS default. alt := uploadBody("/files/9f8/e7d/6c5/9f8e7d6c5b4a39281706f5e4d3c2b1a0.png", "/files/9f8/e7d/6c5/thumb_3_200_200_0_0_crop.png") alt2 := uploadBody("/files/123/456/789/1234567890abcdef.png", "/files/123/456/789/thumb_9_200_200_0_0_crop.png") if diffs := compareBodiesWith(Response{Headers: jsonCT(), Body: Body(alt)}, Response{Headers: jsonCT(), Body: Body(alt2)}, step, maskOptions{uploadPrefix: "/files"}); len(diffs) != 0 { t.Fatalf("custom prefix diffs = %+v", diffs) } } func TestNormalizePublicationAlbumDates(t *testing.T) { store := mustMemoryStore() pub := func(created, updated string) []Publication { return []Publication{{Method: "POST", Path: "/api/publish", Body: json.RawMessage( `{"channel":"c","data":{"payload":{"album":{"name":"x","created_at":"` + created + `","updated_at":"` + updated + `","market_price_checked_at":null,"photos":[{"created_at":"` + created + `"}]},"created_at":"2026-01-01T00:00:00+00:00"}}}`)}} } a, err := NormalizePublications(pub("2026-09-30T11:21:55+00:00", "2026-09-30T11:21:56+00:00"), store) if err != nil { t.Fatal(err) } b, err := NormalizePublications(pub("2026-10-02T08:00:00+00:00", "2026-10-02T08:00:01+00:00"), store) if err != nil { t.Fatal(err) } if diffs := DiffPublications(a, b); len(diffs) != 0 { t.Fatalf("diffs = %+v", diffs) } if !strings.Contains(string(a[0].Body), `"updated_at":"{{datetime}}"`) || !strings.Contains(string(a[0].Body), `"market_price_checked_at":null`) { t.Fatalf("album dates not masked: %s", a[0].Body) } // Only the album subtree is masked; a payload date outside it stays. if !strings.Contains(string(a[0].Body), `},"created_at":"2026-01-01T00:00:00+00:00"`) { t.Fatalf("over-normalised: %s", a[0].Body) } // A Z-suffixed album date keeps its value, so a format change is a diff. z, err := NormalizePublications(pub("2026-10-02T08:00:00Z", "2026-10-02T08:00:01+00:00"), store) if err != nil { t.Fatal(err) } if diffs := DiffPublications(a, z); len(diffs) == 0 { t.Fatal("a Z album date must show as a diff") } } // TestMultipartPartEdges covers part validation, file reading and encoding // edges: file attributes without a file, empty and value-only parts, // default file names and content types, unicode and quoted names, a part // that contains the boundary, a missing or tampered file, a file outside the // fixture directory and the Content-Type handling of prepareRequest. func TestMultipartPartEdges(t *testing.T) { dir := t.TempDir() png := []byte("\x89PNG\r\n\x1a\nfixture") if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "files", "okładka.png"), png, 0o600); err != nil { t.Fatal(err) } sum := sha256.Sum256(png) pin := hex.EncodeToString(sum[:]) for name, req := range map[string]Request{ "file attributes without a file": {Parts: []Part{{Name: "a", Filename: "x.png"}}}, "content type without a file": {Parts: []Part{{Name: "a", ContentType: "image/png"}}}, "sha without a file": {Parts: []Part{{Name: "a", SHA256: pin}}}, "blank name": {Parts: []Part{{Name: " ", Value: "x"}}}, "absolute file": {Parts: []Part{{Name: "a", File: "/etc/passwd", SHA256: pin}}}, "short sha": {Parts: []Part{{Name: "a", File: "files/okładka.png", SHA256: "abc"}}}, } { if err := validateParts(req); err == nil { t.Errorf("%s: validateParts accepted it", name) } } if err := validateParts(Request{}); err != nil { t.Fatalf("no parts: %v", err) } parts := []Part{ {Name: "empty", Value: ""}, {Name: `tit"le`, Value: "Łódź"}, {Name: "file", File: "files/okładka.png", SHA256: strings.ToUpper(pin)}, } body, ct, err := encodeParts(dir, parts) if err != nil { t.Fatal(err) } if ct != "multipart/form-data; boundary="+MultipartBoundary { t.Fatalf("content type %q", ct) } text := string(body) for _, want := range []string{ `Content-Disposition: form-data; name="empty"`, `Content-Disposition: form-data; name="tit\"le"` + "\r\n\r\nŁódź", `Content-Disposition: form-data; name="file"; filename="okładka.png"`, "Content-Type: application/octet-stream", } { if !strings.Contains(text, want) { t.Errorf("encoded body lacks %q:\n%s", want, text) } } again, _, err := encodeParts(dir, parts) if err != nil || !bytes.Equal(body, again) { t.Fatal("encoding is not deterministic") } if _, _, err := encodeParts(dir, []Part{{Name: "x", Value: "--" + MultipartBoundary}}); err == nil { t.Error("a value holding the boundary was encoded") } for name, p := range map[string]Part{ "missing": {Name: "f", File: "files/nope.png", SHA256: pin}, "tampered": {Name: "f", File: "files/okładka.png", SHA256: strings.Repeat("0", 64)}, "escaping": {Name: "f", File: "../okładka.png", SHA256: pin}, } { if _, err := readPartFile(dir, p); err == nil { t.Errorf("%s part file was read", name) } } outside := t.TempDir() if err := os.WriteFile(filepath.Join(outside, "x.png"), png, 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(outside, "x.png"), filepath.Join(dir, "files", "link.png")); err != nil { t.Fatal(err) } if _, err := readPartFile(dir, Part{Name: "f", File: "files/link.png", SHA256: pin}); err == nil { t.Error("a symlink leaving the fixture directory was read") } if raw, err := readPartFile("", Part{Name: "f", File: filepath.Join(dir, "files", "okładka.png")[len(dir)+1:], SHA256: pin}); err == nil && raw != nil { t.Error("a part file resolved without its fixture directory") } for name, tc := range map[string]struct { headers map[string]string want string }{ "none recorded": {nil, "multipart/form-data; boundary=" + MultipartBoundary}, "stale boundary": {map[string]string{"content-type": "multipart/form-data; boundary=old"}, "multipart/form-data; boundary=" + MultipartBoundary}, "unparsable": {map[string]string{"Content-Type": ";;;"}, "multipart/form-data; boundary=" + MultipartBoundary}, "kept non-multipart": {map[string]string{"Content-Type": "text/plain"}, "text/plain"}, } { out, err := prepareRequest(Request{Method: "POST", Headers: tc.headers, Parts: parts[:1]}, dir) if err != nil { t.Fatal(err) } got := "" for k, v := range out.Headers { if strings.EqualFold(k, "Content-Type") { got = v } } if got != tc.want { t.Errorf("%s: Content-Type %q, want %q", name, got, tc.want) } } if out, err := prepareRequest(Request{Body: "a=1"}, dir); err != nil || out.Body != "a=1" { t.Fatalf("no parts: %+v %v", out, err) } if _, err := prepareRequest(Request{Body: "a=1", Parts: parts[:1]}, dir); err == nil { t.Fatal("body and parts were prepared") } if _, err := prepareRequest(Request{Parts: []Part{{Name: "f", File: "files/nope.png", SHA256: pin}}}, dir); err == nil { t.Fatal("a missing part file was prepared") } }