Files
summercms/modules/tide/centrifugo_golden.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

548 lines
15 KiB
Go

package tide
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/goccy/go-yaml"
)
// BroadcastGolden is a versioned file of normalised Centrifugo requests a
// reference backend sent while a flow ran. Pending, when set, says why the
// golden is recorded but not yet asserted.
type BroadcastGolden struct {
Version int
Name string
Flow string
Pending string
Publications []Publication
}
type goldenFile struct {
Version int `yaml:"version"`
Name string `yaml:"name"`
Flow string `yaml:"flow,omitempty"`
Pending string `yaml:"pending,omitempty"`
Publications []goldenPubFile `yaml:"publications"`
}
type goldenPubFile struct {
Method string `yaml:"method"`
Path string `yaml:"path"`
Authorization bool `yaml:"authorization"`
Body Body `yaml:"body"`
}
// LoadBroadcastGolden reads a golden strictly (unknown fields rejected) and
// checks that every body parses.
func LoadBroadcastGolden(path string) (BroadcastGolden, error) {
raw, err := os.ReadFile(path)
if err != nil {
return BroadcastGolden{}, fmt.Errorf("tide: read %s: %w", path, err)
}
var f goldenFile
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&f); err != nil {
return BroadcastGolden{}, fmt.Errorf("tide: parse golden %s: %w", path, err)
}
g := BroadcastGolden{Version: f.Version, Name: f.Name, Flow: f.Flow, Pending: f.Pending}
for _, p := range f.Publications {
g.Publications = append(g.Publications, Publication{
Method: p.Method,
Path: p.Path,
Authorization: p.Authorization,
Body: json.RawMessage(p.Body),
})
}
if err := validateGolden(g); err != nil {
return BroadcastGolden{}, fmt.Errorf("tide: golden %s: %w", path, err)
}
return g, nil
}
// WriteBroadcastGolden writes g atomically. It refuses a golden whose
// bodies still hold a token-, secret- or password-shaped value.
func WriteBroadcastGolden(path string, g BroadcastGolden) error {
if err := validateGolden(g); err != nil {
return err
}
for i, p := range g.Publications {
if hit := remainingCredential(string(p.Body)); hit != "" {
return fmt.Errorf("tide: golden publication %d holds an unclassified credential-shaped value (%s)", i, hit)
}
}
var b strings.Builder
fmt.Fprintf(&b, "version: %d\n", g.Version)
writeKV(&b, 0, "name", g.Name)
if g.Flow != "" {
writeKV(&b, 0, "flow", g.Flow)
}
if g.Pending != "" {
writeKV(&b, 0, "pending", g.Pending)
}
if len(g.Publications) == 0 {
b.WriteString("publications: []\n")
} else {
b.WriteString("publications:\n")
}
for _, p := range g.Publications {
fmt.Fprintf(&b, " - method: %s\n", encodeScalar(p.Method))
writeKV(&b, 4, "path", p.Path)
fmt.Fprintf(&b, " authorization: %t\n", p.Authorization)
writeBody(&b, 4, string(p.Body))
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("tide: create golden dir: %w", err)
}
tmp, err := os.CreateTemp(dir, ".tide-golden-*.tmp")
if err != nil {
return fmt.Errorf("tide: create temp golden: %w", err)
}
name := tmp.Name()
if _, err := tmp.WriteString(b.String()); err != nil {
_ = tmp.Close()
_ = os.Remove(name)
return fmt.Errorf("tide: write golden: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
_ = os.Remove(name)
return fmt.Errorf("tide: sync golden: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return fmt.Errorf("tide: close golden: %w", err)
}
if err := os.Chmod(name, 0o644); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return fmt.Errorf("tide: commit golden: %w", err)
}
return nil
}
func validateGolden(g BroadcastGolden) error {
if g.Version != CurrentVersion {
return fmt.Errorf("tide: unsupported golden version %d (want %d)", g.Version, CurrentVersion)
}
if strings.TrimSpace(g.Name) == "" {
return fmt.Errorf("tide: golden name is required")
}
for i, p := range g.Publications {
if strings.TrimSpace(p.Method) == "" || strings.TrimSpace(p.Path) == "" {
return fmt.Errorf("tide: publication %d is missing method or path", i)
}
if _, err := decodePlaceholderJSON(p.Body); err != nil {
return fmt.Errorf("tide: publication %d body: %w", i, err)
}
}
return nil
}
// isoOffsetRe is the ISO 8601 shape of Carbon's toIso8601String, which
// Centrifugo payload timestamps use. Other shapes are left unmasked so a
// format change shows up as a diff.
var isoOffsetRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$`)
// channelIDRe matches a channel name whose last segment is numeric, such as
// collection:12, acme:collection:12 or presence:room:7.
var channelIDRe = regexp.MustCompile(`^((?:[a-z][a-z0-9_.-]*:)+)([0-9]+)$`)
// NormalizePublications masks the values that legitimately differ between
// two backends and nothing else:
// - $.data.timestamp and $.data.payload.timestamp become "{{timestamp}}"
// when they have the ISO 8601 offset shape;
// - $.data.payload.actor becomes "{{actor}}" when it is an object with
// exactly user_id and name;
// - a *_at value anywhere under $.data.payload.album becomes
// "{{datetime}}" when it has Carbon's +00:00 shape; any other shape is
// left as it is, so a format change shows up as a diff;
// - a number or string under an id, *_id or *_ids key that equals an id:*
// variable of store becomes that {{id:name}} placeholder (bare for a
// number, quoted for a string), and so does the numeric last segment of
// a channel name such as collection:12.
//
// A value that matches two id variables is an error. Bodies are re-encoded
// with object keys in their original order.
func NormalizePublications(pubs []Publication, store *Store) ([]Publication, error) {
ids := idValues(store)
out := make([]Publication, len(pubs))
for i, p := range pubs {
root, err := decodeOrdered(p.Body)
if err != nil {
return nil, fmt.Errorf("tide: publication %d body: %w", i, err)
}
n := &normalizer{ids: ids}
root = n.walk("$", "", root)
if n.err != nil {
return nil, fmt.Errorf("tide: publication %d: %w", i, n.err)
}
var buf bytes.Buffer
if err := encodeOrdered(&buf, root); err != nil {
return nil, err
}
p.Body = json.RawMessage(buf.Bytes())
out[i] = p
}
return out, nil
}
// DiffPublications compares expected with actual: the count, then per
// publication the method, path, authorization flag and the body with the
// structural JSON diff (key order ignored). Paths look like $[0].body.data.
func DiffPublications(expected, actual []Publication) []Diff {
var diffs []Diff
if len(expected) != len(actual) {
diffs = append(diffs, Diff{
Path: "$",
Expected: fmt.Sprintf("%d publications", len(expected)),
Actual: fmt.Sprintf("%d publications", len(actual)),
})
}
for i := 0; i < min(len(expected), len(actual)); i++ {
w, g := expected[i], actual[i]
base := fmt.Sprintf("$[%d]", i)
if w.Method != g.Method {
diffs = append(diffs, Diff{Path: base + ".method", Expected: w.Method, Actual: g.Method})
}
if w.Path != g.Path {
diffs = append(diffs, Diff{Path: base + ".path", Expected: w.Path, Actual: g.Path})
}
if w.Authorization != g.Authorization {
diffs = append(diffs, Diff{Path: base + ".authorization", Expected: fmt.Sprint(w.Authorization), Actual: fmt.Sprint(g.Authorization)})
}
wv, err := decodePlaceholderJSON(w.Body)
if err != nil {
diffs = append(diffs, Diff{Path: base + ".body", Expected: "valid JSON", Actual: err.Error()})
continue
}
gv, err := decodePlaceholderJSON(g.Body)
if err != nil {
diffs = append(diffs, Diff{Path: base + ".body", Expected: formatValue(wv), Actual: err.Error()})
continue
}
compareValue(base+".body", wv, gv, &diffs)
}
sort.SliceStable(diffs, func(i, j int) bool { return diffs[i].Path < diffs[j].Path })
return diffs
}
func idValues(store *Store) map[string][]string {
out := map[string][]string{}
if store == nil {
return out
}
store.mu.Lock()
defer store.mu.Unlock()
for k, v := range store.vals {
if strings.HasPrefix(k, "id:") && v != "" {
out[v] = append(out[v], k)
}
}
for v := range out {
sort.Strings(out[v])
}
return out
}
type normalizer struct {
ids map[string][]string
err error
}
func (n *normalizer) lookup(v string) (string, bool) {
names := n.ids[v]
switch len(names) {
case 0:
return "", false
case 1:
return names[0], true
default:
if n.err == nil {
n.err = fmt.Errorf("id value %s is ambiguous: %s", v, strings.Join(names, ", "))
}
return "", false
}
}
func (n *normalizer) walk(path, key string, v *onode) *onode {
switch path {
case "$.data.timestamp", "$.data.payload.timestamp":
if v.kind == kindString && isoOffsetRe.MatchString(v.str) {
return &onode{kind: kindString, str: "{{timestamp}}"}
}
return v
case "$.data.payload.actor":
if v.kind == kindObject && len(v.keys) == 2 && hasKeys(v, "user_id", "name") {
return &onode{kind: kindString, str: "{{actor}}"}
}
return v
}
switch v.kind {
case kindObject:
for i, k := range v.keys {
v.vals[i] = n.walk(pathJoin(path, k), k, v.vals[i])
}
return v
case kindArray:
for i := range v.vals {
v.vals[i] = n.walk(fmt.Sprintf("%s[%d]", path, i), key, v.vals[i])
}
return v
case kindNumber:
if key != "" && isIDKey(key) {
if name, ok := n.lookup(v.str); ok {
return &onode{kind: kindPlaceholder, str: name}
}
}
return v
case kindString:
if strings.HasPrefix(path, "$.data.payload.album.") && strings.HasSuffix(key, "_at") && carbonOffsetRe.MatchString(v.str) {
return &onode{kind: kindString, str: "{{datetime}}"}
}
if key != "" && isIDKey(key) {
if name, ok := n.lookup(v.str); ok {
return &onode{kind: kindString, str: "{{" + name + "}}"}
}
}
if m := channelIDRe.FindStringSubmatch(v.str); m != nil {
if name, ok := n.lookup(m[2]); ok {
return &onode{kind: kindString, str: m[1] + "{{" + name + "}}"}
}
}
return v
default:
return v
}
}
func hasKeys(v *onode, keys ...string) bool {
for _, want := range keys {
found := false
for _, k := range v.keys {
if k == want {
found = true
break
}
}
if !found {
return false
}
}
return true
}
const (
kindNull = iota
kindBool
kindNumber
kindString
kindArray
kindObject
// kindPlaceholder is a bare {{name}} standing for a number.
kindPlaceholder
)
// onode is a JSON value that keeps object keys in document order.
type onode struct {
kind int
str string // string value, number literal or placeholder name
b bool
keys []string
vals []*onode
}
func decodeOrdered(raw []byte) (*onode, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
v, err := decodeOrderedValue(dec)
if err != nil {
return nil, err
}
if dec.More() {
return nil, fmt.Errorf("trailing JSON after first value")
}
if _, err := dec.Token(); err == nil {
return nil, fmt.Errorf("trailing JSON after first value")
}
return v, nil
}
func decodeOrderedValue(dec *json.Decoder) (*onode, error) {
tok, err := dec.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case json.Delim:
switch t {
case '{':
n := &onode{kind: kindObject}
for dec.More() {
kt, err := dec.Token()
if err != nil {
return nil, err
}
k, ok := kt.(string)
if !ok {
return nil, fmt.Errorf("object key %v is not a string", kt)
}
v, err := decodeOrderedValue(dec)
if err != nil {
return nil, err
}
n.keys = append(n.keys, k)
n.vals = append(n.vals, v)
}
if _, err := dec.Token(); err != nil {
return nil, err
}
return n, nil
case '[':
n := &onode{kind: kindArray}
for dec.More() {
v, err := decodeOrderedValue(dec)
if err != nil {
return nil, err
}
n.vals = append(n.vals, v)
}
if _, err := dec.Token(); err != nil {
return nil, err
}
return n, nil
default:
return nil, fmt.Errorf("unexpected delimiter %v", t)
}
case string:
return &onode{kind: kindString, str: t}, nil
case json.Number:
return &onode{kind: kindNumber, str: string(t)}, nil
case bool:
return &onode{kind: kindBool, b: t}, nil
case nil:
return &onode{kind: kindNull}, nil
default:
return nil, fmt.Errorf("unexpected token %v", tok)
}
}
func encodeOrdered(buf *bytes.Buffer, n *onode) error {
switch n.kind {
case kindNull:
buf.WriteString("null")
case kindBool:
if n.b {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case kindNumber:
buf.WriteString(n.str)
case kindPlaceholder:
buf.WriteString("{{" + n.str + "}}")
case kindString:
return encodeJSONString(buf, n.str)
case kindArray:
buf.WriteByte('[')
for i, v := range n.vals {
if i > 0 {
buf.WriteByte(',')
}
if err := encodeOrdered(buf, v); err != nil {
return err
}
}
buf.WriteByte(']')
case kindObject:
buf.WriteByte('{')
for i, k := range n.keys {
if i > 0 {
buf.WriteByte(',')
}
if err := encodeJSONString(buf, k); err != nil {
return err
}
buf.WriteByte(':')
if err := encodeOrdered(buf, n.vals[i]); err != nil {
return err
}
}
buf.WriteByte('}')
default:
return fmt.Errorf("tide: unknown JSON node kind %d", n.kind)
}
return nil
}
func encodeJSONString(buf *bytes.Buffer, s string) error {
var tmp bytes.Buffer
enc := json.NewEncoder(&tmp)
enc.SetEscapeHTML(false)
if err := enc.Encode(s); err != nil {
return err
}
buf.Write(bytes.TrimSuffix(tmp.Bytes(), []byte("\n")))
return nil
}
// numberPlaceholderSuffix marks a bare {{name}} (a masked number) when a
// normalised body is parsed for diffing, so a number that turned into a
// string, or the reverse, still shows up as a difference.
const numberPlaceholderSuffix = " (number)"
// decodePlaceholderJSON parses a normalised body: JSON in which a bare
// {{name}} may stand where a number was.
func decodePlaceholderJSON(raw []byte) (any, error) {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(raw); i++ {
c := raw[i]
if inString {
out.WriteByte(c)
switch {
case escaped:
escaped = false
case c == '\\':
escaped = true
case c == '"':
inString = false
}
continue
}
if c == '"' {
inString = true
out.WriteByte(c)
continue
}
if c == '{' && i+1 < len(raw) && raw[i+1] == '{' {
end := bytes.Index(raw[i:], []byte("}}"))
if end < 0 {
return nil, fmt.Errorf("unterminated placeholder at byte %d", i)
}
name := string(raw[i+2 : i+end])
if name == "" || strings.ContainsAny(name, "{}\"\\") {
return nil, fmt.Errorf("invalid placeholder at byte %d", i)
}
if err := encodeJSONString(&out, "{{"+name+"}}"+numberPlaceholderSuffix); err != nil {
return nil, err
}
i += end + 1
continue
}
out.WriteByte(c)
}
return decodeJSON(out.Bytes())
}