Turns a request path plus the two routing fields (`do`/`action`) into a single Resolution for BOTH doors (D-039): the RESERVED level-1 switch first (home/access/setup/api/static/media/ws/dev — D-004), then the GUID classifier, then the registry. Identity and the module registry are injected interfaces, so the package is PURE — no HTTP stack, no DB (asserted by purity_test.go). All 22 spec rows are covered.
231 lines
8.5 KiB
Go
231 lines
8.5 KiB
Go
package routing
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// TestParse covers the SPEC-002 §3.4 table: empty segments are preserved.
|
|
func TestParse(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want Params
|
|
}{
|
|
{"/", Params{}},
|
|
{"/home", Params{"home"}},
|
|
{"/home/", Params{"home", ""}},
|
|
{"/{guid}/request/pto", Params{"{guid}", "request", "pto"}},
|
|
{"/a//b", Params{"a", "", "b"}},
|
|
{"", Params{}},
|
|
}
|
|
for _, tc := range cases {
|
|
got := Parse(tc.in)
|
|
if !slices.Equal(got, tc.want) {
|
|
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDispatchMatrix is the SPEC-002 §5 acceptance matrix — all 22 rows, no HTTP
|
|
// and no DB.
|
|
func TestDispatchMatrix(t *testing.T) {
|
|
const guid = "AbCdEf0123456789"
|
|
|
|
enrolled := &fakeResolver{users: map[string]bool{guid: true}}
|
|
notEnrolled := &fakeResolver{users: map[string]bool{guid: false}}
|
|
unknown := &fakeResolver{users: map[string]bool{}}
|
|
|
|
cases := []struct {
|
|
n int
|
|
desc string
|
|
path string
|
|
do string
|
|
action string
|
|
resolver Resolver
|
|
want Resolution
|
|
}{
|
|
// 1 — the ONE canonical redirect.
|
|
{1, "redirect", "/", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindRedirect, Redirect: "/home"}},
|
|
// 2 — reserved home; the resolver must NOT be called.
|
|
{2, "home", "/home", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindRoute, Workflow: "home"}},
|
|
// 3 — a trailing slash is absent -> default (no phantom param).
|
|
{3, "home/", "/home/", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindRoute, Workflow: "home"}},
|
|
// 4 — the recovery workflow.
|
|
{4, "access", "/access", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindRoute, Workflow: "access"}},
|
|
// 5 — the setup wizard.
|
|
{5, "setup", "/setup", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindSetup, Workflow: "setup"}},
|
|
// 6 — unknown, not reserved, not a GUID.
|
|
{6, "unknown", "/holy-shit", "", "", unknown,
|
|
Resolution{Kind: KindNotFound, Workflow: "holy-shit"}},
|
|
// 7 — GUID found + enrolled -> the default module (dashboard).
|
|
{7, "guid enrolled", "/" + guid, "", "", enrolled,
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: DefaultModule, Function: "dashboard"}},
|
|
// 8 — GUID not found.
|
|
{8, "guid not found", "/" + guid, "", "", unknown,
|
|
Resolution{Kind: KindNotFound, Workflow: guid}},
|
|
// 9 — GUID found but not enrolled.
|
|
{9, "guid not enrolled", "/" + guid, "", "", notEnrolled,
|
|
Resolution{Kind: KindEnroll, Workflow: guid}},
|
|
// 10 — module requests / function pto.
|
|
{10, "requests pto", "/" + guid + "/requests/pto", "", "", enrolled,
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "pto"}},
|
|
// 11 — module requests / function open.
|
|
{11, "requests open", "/" + guid + "/requests/open", "", "", enrolled,
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "open"}},
|
|
// 12 — numeric -> classifier -> detail with params {id}.
|
|
{12, "requests 42", "/" + guid + "/requests/42", "", "", enrolled,
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "detail", Params: Params{"42"}}},
|
|
// 13 — module whitelist reject.
|
|
{13, "requests bogus", "/" + guid + "/requests/bogus", "", "", enrolled,
|
|
Resolution{Kind: KindNotFound, Workflow: guid}},
|
|
// 14 — unknown module.
|
|
{14, "bad module", "/" + guid + "/badmodule/x", "", "", enrolled,
|
|
Resolution{Kind: KindNotFound, Workflow: guid}},
|
|
// 15 — interior empty = mismatch.
|
|
{15, "interior empty", "/" + guid + "//pto", "", "", enrolled,
|
|
Resolution{Kind: KindNotFound, Workflow: guid}},
|
|
// 16 — module default function (the list).
|
|
{16, "requests default", "/" + guid + "/requests", "", "", enrolled,
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "list"}},
|
|
// 17 — reserved infra workflows carry their remainder.
|
|
{17, "api", "/api/v1/users", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindRoute, Workflow: "api", Params: Params{"v1", "users"}}},
|
|
{17, "static", "/static/x.css", "", "", forbiddenResolver{t},
|
|
Resolution{Kind: KindRoute, Workflow: "static", Params: Params{"x.css"}}},
|
|
// 18 — action door: do=hr&action=save-data.
|
|
{18, "action", "/" + guid, "hr", "save-data", forbiddenResolver{t},
|
|
Resolution{Kind: KindAction, Workflow: guid, Module: "hr", Function: "save-data"}},
|
|
// 19 — unknown module -> 404.
|
|
{19, "unknown do", "/" + guid, "nope", "x", forbiddenResolver{t},
|
|
Resolution{Kind: KindNotFound, Workflow: guid}},
|
|
// 20 — unknown action on a known module -> 404.
|
|
{20, "unknown action", "/" + guid, "hr", "nope", forbiddenResolver{t},
|
|
Resolution{Kind: KindNotFound, Workflow: guid}},
|
|
// 21 — routing fields win over the path.
|
|
{21, "handler wins", "/some/path", "hr", "save-data", forbiddenResolver{t},
|
|
Resolution{Kind: KindAction, Workflow: "some", Module: "hr", Function: "save-data"}},
|
|
}
|
|
|
|
d := New(nil, testRegistry)
|
|
for _, tc := range cases {
|
|
d.Resolver = tc.resolver
|
|
got := d.Dispatch(tc.path, tc.do, tc.action)
|
|
if !eqResolution(got, tc.want) {
|
|
t.Errorf("row %d (%s): Dispatch(%q, do=%q, action=%q)\n got %s\n want %s",
|
|
tc.n, tc.desc, tc.path, tc.do, tc.action, resolveFmt(got), resolveFmt(tc.want))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRow2ResolverNotCalled asserts the reserved switch precedes identity.
|
|
func TestRow2ResolverNotCalled(t *testing.T) {
|
|
res := &fakeResolver{users: map[string]bool{}}
|
|
d := New(res, testRegistry)
|
|
_ = d.Router("/home")
|
|
if len(res.calls) != 0 {
|
|
t.Errorf("resolver called %d time(s) for /home, want 0", len(res.calls))
|
|
}
|
|
}
|
|
|
|
// TestTrailingSlashNormalization pins the §3.4 rule: a trailing slash is
|
|
// "absent" and leaves the function with no phantom param.
|
|
func TestTrailingSlashNormalization(t *testing.T) {
|
|
const guid = "AbCdEf0123456789"
|
|
d := New(&fakeResolver{users: map[string]bool{guid: true}}, testRegistry)
|
|
|
|
cases := []struct {
|
|
path string
|
|
want Resolution
|
|
}{
|
|
{"/" + guid + "/requests/",
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "list"}},
|
|
{"/" + guid + "/requests/open/",
|
|
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "open"}},
|
|
{"/static/", Resolution{Kind: KindRoute, Workflow: "static"}},
|
|
}
|
|
for _, tc := range cases {
|
|
got := d.Router(tc.path)
|
|
if !eqResolution(got, tc.want) {
|
|
t.Errorf("Router(%q)\n got %s\n want %s", tc.path, resolveFmt(got), resolveFmt(tc.want))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRow22ReservedGUID covers row 22: a GUID equal to a reserved workflow is
|
|
// impossible — validation rejects it on create AND update (same guard).
|
|
func TestRow22ReservedGUID(t *testing.T) {
|
|
for _, name := range ReservedWorkflows {
|
|
if !IsReservedWorkflow(name) {
|
|
t.Errorf("IsReservedWorkflow(%q) = false, want true", name)
|
|
}
|
|
if err := ValidateWorkflowName(name); err == nil {
|
|
t.Errorf("ValidateWorkflowName(%q) = nil, want an error", name)
|
|
}
|
|
}
|
|
|
|
// The reserved set is exactly the SPEC-002 §4 set.
|
|
want := []string{"home", "access", "setup", "api", "static", "media", "ws", "dev"}
|
|
if !slices.Equal(ReservedWorkflows, want) {
|
|
t.Errorf("ReservedWorkflows = %q, want %q", ReservedWorkflows, want)
|
|
}
|
|
|
|
// A normal GUID is accepted...
|
|
const guid = "AbCdEf0123456789"
|
|
if IsReservedWorkflow(guid) {
|
|
t.Errorf("IsReservedWorkflow(%q) = true, want false", guid)
|
|
}
|
|
if err := ValidateWorkflowName(guid); err != nil {
|
|
t.Errorf("ValidateWorkflowName(%q) = %v, want nil", guid, err)
|
|
}
|
|
// ...and the match is exact/case-sensitive.
|
|
if IsReservedWorkflow("Home") {
|
|
t.Error(`IsReservedWorkflow("Home") = true, want false (exact match)`)
|
|
}
|
|
}
|
|
|
|
// TestIsNumeric covers the built-in classifier used by modules.
|
|
func TestIsNumeric(t *testing.T) {
|
|
for _, s := range []string{"0", "42", "007"} {
|
|
if !IsNumeric(s) {
|
|
t.Errorf("IsNumeric(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"", "-1", "1.5", "42a", "a", " ", "١٢٣"} {
|
|
if IsNumeric(s) {
|
|
t.Errorf("IsNumeric(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKindString guards the Kind labels used in logs.
|
|
func TestKindString(t *testing.T) {
|
|
cases := map[Kind]string{
|
|
KindRoute: "Route",
|
|
KindAction: "Action",
|
|
KindRedirect: "Redirect",
|
|
KindNotFound: "NotFound",
|
|
KindEnroll: "Enroll",
|
|
KindLogin: "Login",
|
|
KindSetup: "Setup",
|
|
Kind(99): "Kind(99)",
|
|
}
|
|
for k, want := range cases {
|
|
if got := k.String(); got != want {
|
|
t.Errorf("Kind(%d).String() = %q, want %q", int(k), got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func eqResolution(a, b Resolution) bool {
|
|
return a.Kind == b.Kind &&
|
|
a.Workflow == b.Workflow &&
|
|
a.Module == b.Module &&
|
|
a.Function == b.Function &&
|
|
a.Redirect == b.Redirect &&
|
|
slices.Equal(a.Params, b.Params)
|
|
}
|