test(13-06): cover the Phase 13 framework edges in surf, conga, lagoon and tide

- surf: a transitive three-route family, HEAD and sorted Allow on family
  paths, a family across two plugins with per-member middleware, refused
  trailing-slash and multi-segment shapes
- conga: refusal messages name kind and queue, a configured queue counts
  as served, a delayed unregistered dispatch waits scheduled
- lagoon: prohibited under a wildcard, on a dotted path and after bail
- tide: quoted, RFC 5987 and multi-date download names; a captured id
  beside a masked notification id
This commit is contained in:
Jakub Zych
2026-10-03 11:01:58 +02:00
parent 5d0ce7f5bb
commit 4ed45c1b03
4 changed files with 278 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ package conga
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -217,3 +218,45 @@ func TestUnregisteredKindWithoutWorker(t *testing.T) {
t.Fatalf("river job = %+v, want available on default", row)
}
}
// TestUnregisteredKindRefusalAndDelay pins the edges of the workerless
// path: the refusal names the kind and the queue (a configured queue is
// served too, and nothing is written), and a delayed Dispatch of an
// unregistered kind waits scheduled with its summer_jobs row.
func TestUnregisteredKindRefusalAndDelay(t *testing.T) {
db, dsn := migratedDB(t)
app, gdb := testApp(t, db, dsn, map[string]any{"queue.queues": map[string]any{"acme_imports": 2}})
m, err := From(app)
if err != nil {
t.Fatal(err)
}
startTestWorker(t, app, WorkerOptions{pollOnly: true, pollInterval: unregisteredPoll},
Job(func(context.Context, acmeMailArgs) error { return nil }, OnQueue("acme_mail")))
ctx := t.Context()
jobs, records := countKind(t, gdb, "acme.pending_import"), countRecords(t, gdb)
_, err = m.Dispatch(ctx, gdb, acmePendingImportArgs{ImportID: 1}, DispatchOpts{Label: "acme.pending", Queue: "acme_imports"})
if !errors.Is(err, ErrUnregisteredKindQueue) || !strings.Contains(err.Error(), `kind "acme.pending_import" on queue "acme_imports", which a worker serves`) {
t.Fatalf("configured queue: err = %v", err)
}
err = m.Enqueue(ctx, gdb, acmePendingImportArgs{ImportID: 1}, EnqueueOpts{})
if !errors.Is(err, ErrUnregisteredKindQueue) || !strings.Contains(err.Error(), `kind "acme.pending_import" names no queue`) {
t.Fatalf("no queue: err = %v", err)
}
if countKind(t, gdb, "acme.pending_import") != jobs || countRecords(t, gdb) != records {
t.Fatal("a refused insert wrote a row")
}
id, err := m.Dispatch(ctx, gdb, acmePendingImportArgs{ImportID: 2}, DispatchOpts{Label: "acme.later", Queue: "acme_later", Delay: time.Hour})
if err != nil {
t.Fatal(err)
}
rec := mustGet(t, m, id)
if rec.Label != "acme.later" || rec.RiverJobID == nil {
t.Fatalf("record = %+v", rec)
}
row := riverJob(t, gdb, *rec.RiverJobID)
if row.State != "scheduled" || row.Queue != "acme_later" || row.Attempt != 0 {
t.Fatalf("delayed unregistered job = %+v, want scheduled on acme_later", row)
}
}

View File

@@ -713,3 +713,43 @@ func TestValidateRequestProhibited(t *testing.T) {
t.Errorf("mixed = %#v, want %#v", got, want)
}
}
// TestValidateRequestProhibitedNested: prohibited under a wildcard names
// each offending element, passes on elements that leave the key empty, and
// works on a dotted path into a nested object; a bail before it stops at
// the first failure as for any rule.
func TestValidateRequestProhibitedNested(t *testing.T) {
rules := []RequestRule{{Field: "albums.*.shelf", Rules: ParseRules("prohibited")}}
input := map[string]any{"albums": []any{
map[string]any{"name": "a", "shelf": "A1"},
map[string]any{"name": "b"},
map[string]any{"name": "c", "shelf": ""},
map[string]any{"name": "d", "shelf": []any{"x"}},
}}
got := mustValidate(t, inLocale("en"), input, rules)
want := map[string][]string{
"albums.0.shelf": {"validation.prohibited"},
"albums.3.shelf": {"validation.prohibited"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("wildcard = %#v, want %#v", got, want)
}
if got := mustValidate(t, inLocale("en"), map[string]any{"albums": []any{}}, rules); len(got) != 0 {
t.Errorf("empty list = %#v, want none", got)
}
dotted := []RequestRule{{Field: "meta.condition", Rules: ParseRules("prohibited")}}
if got := mustValidate(t, inLocale("pl"), map[string]any{"meta": map[string]any{"condition": nil}}, dotted); len(got) != 0 {
t.Errorf("dotted null = %#v, want none", got)
}
got = mustValidate(t, inLocale("pl"), map[string]any{"meta": map[string]any{"condition": "VG+"}}, dotted)
if want := map[string][]string{"meta.condition": {"validation.prohibited"}}; !reflect.DeepEqual(got, want) {
t.Errorf("dotted = %#v, want %#v", got, want)
}
bailed := []RequestRule{{Field: "shelf", Rules: ParseRules("bail|prohibited|string|max:2")}}
got = mustValidate(t, inLocale("en"), map[string]any{"shelf": "ABC"}, bailed)
if want := map[string][]string{"shelf": {"validation.prohibited"}}; !reflect.DeepEqual(got, want) {
t.Errorf("bail = %#v, want %#v", got, want)
}
}

View File

@@ -0,0 +1,148 @@
package surf
import (
"net/http"
"strings"
"testing"
)
// rackTable is a three-member family joined transitively: token/{token}
// conflicts with {rackId}/items, which conflicts with items/{id}, while
// token/{token} and items/{id} differ in a literal and never conflict.
var rackTable = []overlapRoute{
{name: "rack-token", method: "GET", path: "/racks/token/{token}"},
{name: "rack-items", method: "GET", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}},
{name: "rack-items-delete", method: "DELETE", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}},
{name: "item-show", method: "GET", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}},
{name: "item-put", method: "PUT", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}},
{name: "rack-create", method: "POST", path: "/racks/{kind}/new"},
}
func TestOverlapFamilyOfThree(t *testing.T) {
// The conflict closure holds the three GET routes; the other methods
// join the family at compile time through its generated pattern.
r := buildOverlapRouter(t, rackTable)
fams, err := overlapFamilies(r.routes)
if err != nil {
t.Fatal(err)
}
if len(fams) != 1 || fams[0].pattern != "/racks/{surfOverlap1}/{surfOverlap2}" {
t.Fatalf("families = %+v, want one family on one pattern", fams)
}
var members []string
for _, idx := range fams[0].members {
members = append(members, r.routes[idx].method+" "+r.routes[idx].path)
}
if strings.Join(members, ",") != "GET /racks/token/{token},GET /racks/{rackId}/items,GET /racks/items/{id}" {
t.Fatalf("family members = %v", members)
}
h := compileOverlap(t, rackTable)
for _, c := range []struct{ method, path, want string }{
{"GET", "/racks/token/abc", "rack-token token=abc"},
{"GET", "/racks/token/7", "rack-token token=7"},
{"GET", "/racks/7/items", "rack-items rackId=7"},
{"DELETE", "/racks/7/items", "rack-items-delete rackId=7"},
{"GET", "/racks/items/9", "item-show id=9"},
{"PUT", "/racks/items/9", "item-put id=9"},
{"POST", "/racks/steel/new", "rack-create kind=steel"},
} {
rec := serve(h, c.method, c.path)
if rec.Code != http.StatusOK || rec.Body.String() != c.want {
t.Errorf("%s %s = %d %q, want 200 %q", c.method, c.path, rec.Code, rec.Body.String(), c.want)
}
}
for _, path := range []string{"/racks/items/items", "/racks/x7/items", "/racks/items/9x"} {
if rec := serve(h, "GET", path); rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" {
t.Errorf("GET %s = %d %q, want the bare 404", path, rec.Code, rec.Body.String())
}
}
}
func TestOverlapHeadAndAllow(t *testing.T) {
h := compileOverlap(t, rackTable)
// A GET member answers HEAD, with its own handler.
for _, path := range []string{"/racks/token/abc", "/racks/7/items", "/racks/items/9"} {
if rec := serve(h, "HEAD", path); rec.Code != http.StatusOK {
t.Errorf("HEAD %s = %d, want 200", path, rec.Code)
}
}
// Allow lists the methods of every route that matches the path, sorted,
// with HEAD beside GET, as ServeMux does for unrelated routes.
for _, c := range []struct{ method, path, allow string }{
{"POST", "/racks/items/9", "GET, HEAD, PUT"},
{"PATCH", "/racks/7/items", "DELETE, GET, HEAD"},
{"PUT", "/racks/token/abc", "GET, HEAD"},
{"GET", "/racks/steel/new", "POST"},
} {
rec := serve(h, c.method, c.path)
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != c.allow {
t.Errorf("%s %s = %d Allow %q, want 405 %q", c.method, c.path, rec.Code, rec.Header().Get("Allow"), c.allow)
}
}
// A method a member serves, whose constraint fails, is the bare 404,
// never a 405 naming the member.
if rec := serve(h, "DELETE", "/racks/abc/items"); rec.Code != http.StatusNotFound || rec.Header().Get("Allow") != "" {
t.Errorf("DELETE /racks/abc/items = %d Allow %q, want the bare 404", rec.Code, rec.Header().Get("Allow"))
}
// A path no member and no route matches stays the router's 404.
if rec := serve(h, "GET", "/racks/a/b/c"); rec.Code != http.StatusNotFound {
t.Errorf("GET /racks/a/b/c = %d, want 404", rec.Code)
}
}
func TestOverlapFamilyAcrossPlugins(t *testing.T) {
r := New(nil)
register := func(plugin string, rt overlapRoute) {
r.BindPlugin(plugin)
mw := "mw." + rt.name
if err := r.RegisterMiddleware(plugin, mw, markMiddleware(rt.name)); err != nil {
t.Fatal(err)
}
r.Get(rt.path, overlapHandler(rt), mw)
for p, re := range rt.where {
r.Where(p, re)
}
}
register("acme.alpha", overlapRoute{name: "alpha-token", path: "/bins/token/{token}"})
register("acme.beta", overlapRoute{name: "beta-items", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}})
h, err := r.compile()
if err != nil {
t.Fatalf("a family across plugins must compile: %v", err)
}
for _, c := range []struct{ path, body, mw string }{
{"/bins/token/q", "alpha-token token=q", "alpha-token"},
{"/bins/3/items", "beta-items binId=3", "beta-items"},
} {
rec := serve(h, "GET", c.path)
if rec.Code != http.StatusOK || rec.Body.String() != c.body || strings.Join(rec.Header().Values("X-Mw"), ",") != c.mw {
t.Errorf("GET %s = %d %q mw %v, want %q with only %s's middleware", c.path, rec.Code, rec.Body.String(), rec.Header().Values("X-Mw"), c.body, c.mw)
}
}
plugins := map[string]string{}
for _, info := range r.Routes() {
plugins[info.Pattern] = info.PluginID
}
if plugins["/bins/token/{token}"] != "acme.alpha" || plugins["/bins/{binId}/items"] != "acme.beta" {
t.Fatalf("route table plugins = %v", plugins)
}
}
func TestOverlapUnsupportedShapes(t *testing.T) {
for name, table := range map[string][]overlapRoute{
"trailing-slash": {
{name: "dir", method: "GET", path: "/bins/token/{token}/"},
{name: "items", method: "GET", path: "/bins/{binId}/items/", where: map[string]string{"binId": "[0-9]+"}},
},
"multi-segment": {
{name: "rest", method: "GET", path: "/bins/token/{rest...}"},
{name: "items", method: "GET", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}},
},
} {
t.Run(name, func(t *testing.T) {
_, err := buildOverlapRouter(t, table).compile()
if err == nil || !strings.Contains(err.Error(), "route conflict") || !strings.Contains(err.Error(), `plugin "acme.shelves"`) {
t.Fatalf("err = %v, want a route conflict naming both routes and their plugin", err)
}
})
}
}

View File

@@ -122,3 +122,50 @@ func TestNormalizeNotificationPublication(t *testing.T) {
t.Fatalf("album publication\n got %s\nwant %s", got[0].Body, want)
}
}
// TestNormalizePhase13Edges: the download-date mask also holds for a
// quoted filename, an RFC 5987 filename* and three dates in one name,
// while a changed count of dates diffs; a notification publication keeps
// a captured id beside its masked own id.
func TestNormalizePhase13Edges(t *testing.T) {
for _, c := range []struct {
name, recorded, got string
diff bool
}{
{"quoted", `attachment; filename="export-2026-09-17.csv"`, `attachment; filename="export-2026-10-03.csv"`, false},
{"quoted stem change", `attachment; filename="export-2026-09-17.csv"`, `attachment; filename="backup-2026-10-03.csv"`, true},
{"rfc5987", `attachment; filename*=UTF-8''p%C5%82yty-2026-09-17.csv`, `attachment; filename*=UTF-8''p%C5%82yty-2026-10-03.csv`, false},
{"three dates", "attachment; filename=a-2026-01-01-2026-02-01-2026-03-01.csv", "attachment; filename=a-2027-01-01-2027-02-01-2027-03-01.csv", false},
{"one date fewer", "attachment; filename=a-2026-01-01-2026-02-01.csv", "attachment; filename=a-2026-01-01.csv", true},
{"leap day", "attachment; filename=a-2028-02-29.csv", "attachment; filename=a-2026-10-03.csv", false},
{"not a leap year", "attachment; filename=a-2026-02-29.csv", "attachment; filename=a-2026-10-03.csv", true},
} {
diffs := compareHeaders(map[string]string{"Content-Disposition": c.recorded}, map[string]string{"Content-Disposition": c.got}, nil)
if (len(diffs) > 0) != c.diff {
t.Errorf("%s: diffs = %+v, want diff=%v", c.name, diffs, c.diff)
}
}
store := mustMemoryStore()
store.Set("id:album", "5")
pub := func(id, albumID string) []Publication {
return []Publication{{Method: "POST", Path: "/api/publish", Body: json.RawMessage(
`{"channel":"acme#9","data":{"event":"notification:new","payload":{"id":` + id +
`,"type":"item_added","payload":{"album_id":` + albumID + `},"read_at":null,"created_at":"2026-10-03T08:00:00+00:00"}}}`)}}
}
got, err := NormalizePublications(pub("31", "5"), store)
if err != nil {
t.Fatal(err)
}
body := string(got[0].Body)
if !strings.Contains(body, `"payload":{"id":{{id}},`) || !strings.Contains(body, `"album_id":{{id:album}}`) {
t.Fatalf("masks beside a captured id: %s", body)
}
other, err := NormalizePublications(pub("32", "6"), store)
if err != nil {
t.Fatal(err)
}
if diffs := DiffPublications(got, other); len(diffs) == 0 {
t.Fatal("a different album id must still diff")
}
}