diff --git a/modules/conga/unregistered_kind_test.go b/modules/conga/unregistered_kind_test.go index abb7310..7ef1345 100644 --- a/modules/conga/unregistered_kind_test.go +++ b/modules/conga/unregistered_kind_test.go @@ -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) + } +} diff --git a/modules/lagoon/validate_request_test.go b/modules/lagoon/validate_request_test.go index f8dafca..b2a9612 100644 --- a/modules/lagoon/validate_request_test.go +++ b/modules/lagoon/validate_request_test.go @@ -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) + } +} diff --git a/modules/surf/overlap_edges_test.go b/modules/surf/overlap_edges_test.go new file mode 100644 index 0000000..f12aa88 --- /dev/null +++ b/modules/surf/overlap_edges_test.go @@ -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) + } + }) + } +} diff --git a/modules/tide/normalize_phase13_test.go b/modules/tide/normalize_phase13_test.go index 2329fdd..0cc231d 100644 --- a/modules/tide/normalize_phase13_test.go +++ b/modules/tide/normalize_phase13_test.go @@ -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") + } +}