Files
summercms/modules/tide/diff.go
Jakub Zych e06e0cc8bf feat(12-01): record multipart uploads and match Winter upload URLs
- 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
2026-10-02 11:33:42 +02:00

293 lines
6.3 KiB
Go

package tide
import (
"bytes"
"encoding/json"
"fmt"
"mime"
"net/http"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
func compareBodies(want, got Response, step Step) []Diff {
return compareBodiesWith(want, got, step, maskOptions{})
}
func compareBodiesWith(want, got Response, step Step, opts maskOptions) []Diff {
wantJSON := isJSONContentType(want.Headers)
gotJSON := isJSONContentType(got.Headers)
if wantJSON && gotJSON {
wantRaw, wantDiffs := normalizeJSON([]byte(want.Body), step, opts)
gotRaw, gotDiffs := normalizeJSON([]byte(got.Body), step, opts)
diffs := append([]Diff{}, wantDiffs...)
diffs = append(diffs, gotDiffs...)
diffs = append(diffs, diffJSON(wantRaw, gotRaw)...)
return diffs
}
return diffBytes([]byte(want.Body), []byte(got.Body))
}
var globalCompareHeaders = []string{
"Content-Type",
"X-Total-Count",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Methods",
"Access-Control-Expose-Headers",
}
var extraCompareHeaders = []string{
"Cache-Control",
"Pragma",
"WWW-Authenticate",
"Content-Disposition",
"Link",
"Location",
}
var neverCompareHeaders = []string{
"Date",
"Server",
"X-Request-Id",
"X-Request-ID",
"X-Correlation-Id",
}
func compareHeaders(want, got, extra map[string]string) []Diff {
allow := map[string]bool{}
for _, n := range globalCompareHeaders {
allow[http.CanonicalHeaderKey(n)] = true
}
for _, n := range extraCompareHeaders {
allow[http.CanonicalHeaderKey(n)] = true
}
for n := range extra {
allow[http.CanonicalHeaderKey(n)] = true
}
for _, n := range neverCompareHeaders {
delete(allow, http.CanonicalHeaderKey(n))
}
var diffs []Diff
for k, wv := range want {
ck := http.CanonicalHeaderKey(k)
if !allow[ck] {
continue
}
gv := headerValue(got, k)
if gv == wv {
continue
}
if gv == "" {
gv = "<missing>"
}
diffs = append(diffs, Diff{Path: "header." + ck, Expected: wv, Actual: gv})
}
return diffs
}
func isJSONContentType(headers map[string]string) bool {
ct := headerValue(headers, "Content-Type")
if ct == "" {
return false
}
media, _, err := mime.ParseMediaType(ct)
if err != nil {
return strings.Contains(strings.ToLower(ct), "json")
}
return media == "application/json" || strings.HasSuffix(media, "+json")
}
func headerValue(headers map[string]string, name string) string {
if v, ok := headers[name]; ok {
return v
}
for k, v := range headers {
if strings.EqualFold(k, name) {
return v
}
}
return ""
}
func diffJSON(want, got []byte) []Diff {
wantVal, err := decodeJSON(want)
if err != nil {
return []Diff{{Path: "$", Expected: "valid JSON", Actual: err.Error()}}
}
gotVal, err := decodeJSON(got)
if err != nil {
return []Diff{{Path: "$", Expected: formatValue(wantVal), Actual: err.Error()}}
}
var diffs []Diff
compareValue("$", wantVal, gotVal, &diffs)
return diffs
}
func decodeJSON(raw []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
if dec.More() {
return nil, fmt.Errorf("trailing JSON after first value")
}
return v, nil
}
func compareValue(path string, want, got any, diffs *[]Diff) {
switch w := want.(type) {
case map[string]any:
g, ok := got.(map[string]any)
if !ok {
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
return
}
for k, wv := range w {
gv, exists := g[k]
child := pathJoin(path, k)
if !exists {
*diffs = append(*diffs, Diff{Path: child, Expected: formatValue(wv), Actual: "<missing>"})
continue
}
compareValue(child, wv, gv, diffs)
}
for k, gv := range g {
if _, exists := w[k]; !exists {
*diffs = append(*diffs, Diff{Path: pathJoin(path, k), Expected: "<missing>", Actual: formatValue(gv)})
}
}
case []any:
g, ok := got.([]any)
if !ok {
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
return
}
if len(w) != len(g) {
*diffs = append(*diffs, Diff{
Path: path,
Expected: fmt.Sprintf("array[%d]", len(w)),
Actual: fmt.Sprintf("array[%d]", len(g)),
})
}
n := min(len(w), len(g))
for i := 0; i < n; i++ {
compareValue(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], diffs)
}
default:
if !scalarEqual(want, got) {
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
}
}
}
func scalarEqual(want, got any) bool {
if want == nil || got == nil {
return want == nil && got == nil
}
switch w := want.(type) {
case json.Number:
g, ok := got.(json.Number)
return ok && w == g
case string:
g, ok := got.(string)
return ok && w == g
case bool:
g, ok := got.(bool)
return ok && w == g
default:
return false
}
}
func formatValue(v any) string {
switch t := v.(type) {
case nil:
return "null"
case json.Number:
return "number " + string(t)
case string:
return strconv.Quote(t)
case bool:
return fmt.Sprintf("%v", t)
case map[string]any:
return "object"
case []any:
return fmt.Sprintf("array[%d]", len(t))
default:
return fmt.Sprintf("%v", t)
}
}
func pathJoin(parent, key string) string {
if parent == "$" {
return "$." + key
}
return parent + "." + key
}
func diffBytes(want, got []byte) []Diff {
n := min(len(want), len(got))
off := n
for i := 0; i < n; i++ {
if want[i] != got[i] {
off = i
break
}
}
if off == n && len(want) == len(got) {
return nil
}
return []Diff{{
Path: fmt.Sprintf("body[%d]", off),
Expected: printableWindow(want, off),
Actual: printableWindow(got, off),
Offset: off,
Byte: true,
}}
}
func printableWindow(b []byte, off int) string {
if len(b) == 0 {
return `""`
}
start := off - 8
if start < 0 {
start = 0
}
end := off + 8
if end > len(b) {
end = len(b)
}
return quotePrintable(b[start:end])
}
func quotePrintable(b []byte) string {
var buf strings.Builder
buf.WriteByte('"')
for i := 0; i < len(b); {
r, size := utf8.DecodeRune(b[i:])
if r == utf8.RuneError && size == 1 {
fmt.Fprintf(&buf, "\\x%02x", b[i])
i++
continue
}
if r == '\\' || r == '"' {
buf.WriteByte('\\')
buf.WriteRune(r)
} else if unicode.IsPrint(r) {
buf.WriteRune(r)
} else {
fmt.Fprintf(&buf, "\\u%04x", r)
}
i += size
}
buf.WriteByte('"')
return buf.String()
}