feat(13-01): add the prohibited rule and dated-download and notification masks

- lagoon.ValidateRequest supports Laravel 9 prohibited (!required, not
  implicit); with no catalog line its message is validation.prohibited
- tide compares Content-Disposition with real calendar dates masked on both
  sides; a different name, an invalid date or a one-sided date still diffs
- tide.NormalizePublications masks a Carbon +00:00 $.data.payload.created_at
  and an uncaptured positive integer $.data.payload.id as {{id}}
- the album-date test's outside-album sibling moves off payload.created_at,
  which now has its own mask
- READMEs and docs describe the rule and both masks
This commit is contained in:
Jakub Zych
2026-10-03 06:32:46 +02:00
parent 55a4092019
commit 2b94dfd2d2
10 changed files with 243 additions and 9 deletions

View File

@@ -0,0 +1,124 @@
package tide
import (
"encoding/json"
"strings"
"testing"
)
// TestNormalizeContentDispositionDate: a download name built from the day
// of the request replays on a later day, while a different stem, an invalid
// date, a date on one side only or a missing header still diff.
func TestNormalizeContentDispositionDate(t *testing.T) {
const recorded = "attachment; filename=export-2026-09-17.csv"
cases := []struct {
name string
got map[string]string
diff bool
}{
{"same stem on a later day", map[string]string{"Content-Disposition": "attachment; filename=export-2026-10-03.csv"}, false},
{"same day", map[string]string{"Content-Disposition": recorded}, false},
{"header name case", map[string]string{"content-disposition": "attachment; filename=export-2027-01-01.csv"}, false},
{"different stem", map[string]string{"Content-Disposition": "attachment; filename=report-2026-10-03.csv"}, true},
{"invalid date", map[string]string{"Content-Disposition": "attachment; filename=export-2026-13-45.csv"}, true},
{"date on one side only", map[string]string{"Content-Disposition": "attachment; filename=export.csv"}, true},
{"inline instead of attachment", map[string]string{"Content-Disposition": "inline; filename=export-2026-10-03.csv"}, true},
{"header absent", map[string]string{}, true},
}
for _, c := range cases {
diffs := compareHeaders(map[string]string{"Content-Disposition": recorded}, c.got, nil)
if (len(diffs) > 0) != c.diff {
t.Errorf("%s: diffs = %+v, want diff=%v", c.name, diffs, c.diff)
}
}
// A recorded value that holds an invalid date keeps the byte comparison.
odd := "attachment; filename=export-2026-02-30.csv"
if diffs := compareHeaders(map[string]string{"Content-Disposition": odd}, map[string]string{"Content-Disposition": "attachment; filename=export-2026-02-28.csv"}, nil); len(diffs) != 1 {
t.Errorf("invalid recorded date: diffs = %+v, want one", diffs)
}
// Two dates must both be real and both present.
two := "attachment; filename=export-2026-09-01-2026-09-17.csv"
if diffs := compareHeaders(map[string]string{"Content-Disposition": two}, map[string]string{"Content-Disposition": "attachment; filename=export-2026-10-01-2026-10-03.csv"}, nil); len(diffs) != 0 {
t.Errorf("two dates: diffs = %+v, want none", diffs)
}
// The mask applies to Content-Disposition only.
if diffs := compareHeaders(map[string]string{"Location": "/files/2026-09-17"}, map[string]string{"Location": "/files/2026-10-03"}, nil); len(diffs) != 1 {
t.Errorf("Location: diffs = %+v, want one", diffs)
}
if _, n, ok := maskDispositionDates("x12026-09-170"); n != 0 || !ok {
t.Errorf("a date inside a longer digit run is not a date token")
}
}
// TestNormalizeNotificationPublication: a notification:new publication's id
// and created_at under $.data.payload normalize equal across two runs, while
// a Z date, a string id and every other path stay visible.
func TestNormalizeNotificationPublication(t *testing.T) {
store := mustMemoryStore()
pub := func(id, created string) []Publication {
return []Publication{{Method: "POST", Path: "/api/publish", Body: json.RawMessage(
`{"channel":"acme#5","data":{"event":"notification:new","payload":{"id":` + id +
`,"type":"item_added","payload":{"album_id":3,"created_at":"2026-01-01T00:00:00+00:00"},"read_at":null,"created_at":"` + created + `"}}}`)}}
}
a, err := NormalizePublications(pub("10000001", "2026-09-30T11:21:55+00:00"), store)
if err != nil {
t.Fatal(err)
}
b, err := NormalizePublications(pub("42", "2026-10-03T08:00:00+00:00"), store)
if err != nil {
t.Fatal(err)
}
if diffs := DiffPublications(a, b); len(diffs) != 0 {
t.Fatalf("diffs = %+v", diffs)
}
body := string(a[0].Body)
if !strings.Contains(body, `"payload":{"id":{{id}},`) || !strings.HasSuffix(body, `"read_at":null,"created_at":"{{datetime}}"}}}`) {
t.Fatalf("notification not masked: %s", body)
}
// Only $.data.payload.created_at is masked, not a nested payload date.
if !strings.Contains(body, `"album_id":3,"created_at":"2026-01-01T00:00:00+00:00"`) {
t.Fatalf("over-normalised: %s", body)
}
// A Z date, a zero or negative id and a string id stay visible.
for _, c := range []struct{ id, created, want string }{
{"42", "2026-10-03T08:00:00Z", `"created_at":"2026-10-03T08:00:00Z"`},
{`"42"`, "2026-10-03T08:00:00+00:00", `"payload":{"id":"42",`},
{"0", "2026-10-03T08:00:00+00:00", `"payload":{"id":0,`},
{"-3", "2026-10-03T08:00:00+00:00", `"payload":{"id":-3,`},
{"4.5", "2026-10-03T08:00:00+00:00", `"payload":{"id":4.5,`},
} {
got, err := NormalizePublications(pub(c.id, c.created), store)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got[0].Body), c.want) {
t.Errorf("id %s created %s: %s lacks %s", c.id, c.created, got[0].Body, c.want)
}
if diffs := DiffPublications(a, got); len(diffs) == 0 {
t.Errorf("id %s created %s must diff against the masked publication", c.id, c.created)
}
}
// A captured id keeps its own placeholder.
store.Set("id:note", "77")
got, err := NormalizePublications(pub("77", "2026-10-03T08:00:00+00:00"), store)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got[0].Body), `"payload":{"id":{{id:note}},`) {
t.Fatalf("captured id: %s", got[0].Body)
}
// An album publication's existing masks are unchanged.
album := []Publication{{Method: "POST", Path: "/api/publish", Body: json.RawMessage(
`{"channel":"c","data":{"payload":{"album":{"created_at":"2026-09-30T11:21:55+00:00"},"actor":{"user_id":1,"name":null},"timestamp":"2026-09-30T11:21:55+00:00"}}}`)}}
got, err = NormalizePublications(album, store)
if err != nil {
t.Fatal(err)
}
want := `{"channel":"c","data":{"payload":{"album":{"created_at":"{{datetime}}"},"actor":"{{actor}}","timestamp":"{{timestamp}}"}}}`
if string(got[0].Body) != want {
t.Fatalf("album publication\n got %s\nwant %s", got[0].Body, want)
}
}