feat(routing): add the pure routing model (SPEC-002)
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.
This commit is contained in:
parent
489321c971
commit
e114a2be61
5 changed files with 788 additions and 0 deletions
229
internal/core/routing/dispatch.go
Normal file
229
internal/core/routing/dispatch.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
// Resolver answers the one identity question the model needs: does a GUID name
|
||||||
|
// a known identity, and is that identity enrolled? It is INJECTED — the pure
|
||||||
|
// model never touches a database (SPEC-002 §3.1, §5).
|
||||||
|
type Resolver interface {
|
||||||
|
// GUID reports whether guid names a known identity and, if so, whether
|
||||||
|
// that identity is enrolled (spec §3.5: found · not enrolled -> ENROLL;
|
||||||
|
// found · enrolled -> continue to the module).
|
||||||
|
GUID(guid string) (found, enrolled bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module is one entry of the module registry: its PARAM2 name plus its own
|
||||||
|
// function whitelist and its classifier for unknown values (SPEC-002 §3.7).
|
||||||
|
// The core never parses a module's internals — it asks the module.
|
||||||
|
type Module interface {
|
||||||
|
// Name is the PARAM2 segment — the registry key ("requests", "time", …).
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// Route resolves a path-mode function (§3.7). function is the PARAM3
|
||||||
|
// segment ("" means absent/trailing — the module default); tail is the
|
||||||
|
// remaining segments (PARAM4+). It returns the resolved function name,
|
||||||
|
// the params that function receives, and ok=false when the value is
|
||||||
|
// neither whitelisted nor classified (-> 404).
|
||||||
|
Route(function string, tail Params) (fn string, params Params, ok bool)
|
||||||
|
|
||||||
|
// Action resolves an action-mode function — the `action` routing field.
|
||||||
|
// ok=false -> 404.
|
||||||
|
Action(action string) (fn string, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry is the module whitelist: PARAM2 in path mode and `do` in action mode
|
||||||
|
// (SPEC-002 §3.6/§3.7). SPEC-008 owns the concrete registry; the model depends
|
||||||
|
// only on this interface.
|
||||||
|
type Registry interface {
|
||||||
|
// Module returns the registered module by name. Unknown OR disabled
|
||||||
|
// modules are reported as not found (-> 404).
|
||||||
|
Module(name string) (Module, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultModule is the module a resolved GUID falls back to when PARAM2 is
|
||||||
|
// absent/trailing — the dashboard (SPEC-002 §3.5, §4.4 level 2: "" -> home).
|
||||||
|
const DefaultModule = "home"
|
||||||
|
|
||||||
|
// Dispatcher is the pure model's entry point. It holds the injected identity
|
||||||
|
// resolver and module registry and resolves a request into a Resolution.
|
||||||
|
type Dispatcher struct {
|
||||||
|
Resolver Resolver
|
||||||
|
Registry Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Dispatcher from the injected dependencies.
|
||||||
|
func New(res Resolver, reg Registry) *Dispatcher {
|
||||||
|
return &Dispatcher{Resolver: res, Registry: reg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch is the level-1 dispatch (SPEC-002 §3.3, D-039): keyed on the routing
|
||||||
|
// fields, never the HTTP method.
|
||||||
|
//
|
||||||
|
// if do != "" || action != "" -> Handler(do, action, path)
|
||||||
|
// else -> Router(path)
|
||||||
|
func (d *Dispatcher) Dispatch(path, do, action string) Resolution {
|
||||||
|
if do != "" || action != "" {
|
||||||
|
return d.Handler(do, action, path)
|
||||||
|
}
|
||||||
|
return d.Router(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch is a convenience wrapper around (*Dispatcher).Dispatch for a one-shot
|
||||||
|
// resolution with explicit dependencies.
|
||||||
|
func Dispatch(res Resolver, reg Registry, path, do, action string) Resolution {
|
||||||
|
return (&Dispatcher{Resolver: res, Registry: reg}).Dispatch(path, do, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router resolves a path request (SPEC-002 §3.5): the RESERVED switch first (so
|
||||||
|
// reserved names never touch identity), then the GUID classifier, then the
|
||||||
|
// module cascade.
|
||||||
|
func (d *Dispatcher) Router(path string) Resolution {
|
||||||
|
p := Parse(path)
|
||||||
|
|
||||||
|
// Absent PARAM1 ("" / "/") -> the ONE canonical redirect.
|
||||||
|
if len(p) == 0 {
|
||||||
|
return Resolution{Kind: KindRedirect, Redirect: "/home"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An interior empty segment ("//") is a mismatch at any level -> 404.
|
||||||
|
if hasInteriorEmpty(p) {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: p[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved cases are checked BEFORE the resolver (§3.5).
|
||||||
|
switch p[0] {
|
||||||
|
case "setup":
|
||||||
|
return Resolution{Kind: KindSetup, Workflow: "setup"}
|
||||||
|
case "home", "access", "api", "static", "media", "ws", "dev":
|
||||||
|
return reserved(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.guid(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler resolves an action request (SPEC-002 §3.6). The GUID still rides the
|
||||||
|
// path (identity is never a form field); `do` selects the module and `action`
|
||||||
|
// the function — both whitelisted against the registry.
|
||||||
|
func (d *Dispatcher) Handler(do, action, path string) Resolution {
|
||||||
|
p := Parse(path)
|
||||||
|
wf := ""
|
||||||
|
if len(p) > 0 {
|
||||||
|
wf = p[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Registry == nil {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: wf}
|
||||||
|
}
|
||||||
|
m, ok := d.Registry.Module(do)
|
||||||
|
if !ok {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: wf}
|
||||||
|
}
|
||||||
|
fn, ok := m.Action(action)
|
||||||
|
if !ok {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: wf}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Resolution{
|
||||||
|
Kind: KindAction,
|
||||||
|
Workflow: wf,
|
||||||
|
Module: m.Name(),
|
||||||
|
Function: fn,
|
||||||
|
Params: tailFrom(p, 2), // the path remainder after the GUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserved classifies a reserved level-1 workflow. These are core-owned, carry
|
||||||
|
// no registry module, and hand the raw path remainder to the core workflow.
|
||||||
|
func reserved(p Params) Resolution {
|
||||||
|
return Resolution{
|
||||||
|
Kind: KindRoute,
|
||||||
|
Workflow: p[0],
|
||||||
|
Params: tailFrom(p, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guid classifies a non-reserved PARAM1 via the injected resolver.
|
||||||
|
func (d *Dispatcher) guid(p Params) Resolution {
|
||||||
|
guid := p[0]
|
||||||
|
|
||||||
|
if d.Resolver == nil {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: guid}
|
||||||
|
}
|
||||||
|
found, enrolled := d.Resolver.GUID(guid)
|
||||||
|
if !found {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: guid}
|
||||||
|
}
|
||||||
|
if !enrolled {
|
||||||
|
return Resolution{Kind: KindEnroll, Workflow: guid}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.module(guid, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// module resolves PARAM2 (the module) and PARAM3+ (the module's own cascade)
|
||||||
|
// for a resolved, enrolled GUID (SPEC-002 §3.5/§3.7).
|
||||||
|
func (d *Dispatcher) module(guid string, p Params) Resolution {
|
||||||
|
// PARAM2 — the module (absent/trailing empty -> the dashboard default).
|
||||||
|
name, absent := segment(p, 1)
|
||||||
|
if absent {
|
||||||
|
name = DefaultModule
|
||||||
|
}
|
||||||
|
if d.Registry == nil {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: guid}
|
||||||
|
}
|
||||||
|
m, ok := d.Registry.Module(name)
|
||||||
|
if !ok {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: guid}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PARAM3 — the function (absent/trailing empty -> the module default).
|
||||||
|
fn, absent := segment(p, 2)
|
||||||
|
if absent {
|
||||||
|
fn = ""
|
||||||
|
}
|
||||||
|
resolved, params, ok := m.Route(fn, tailFrom(p, 3))
|
||||||
|
if !ok {
|
||||||
|
return Resolution{Kind: KindNotFound, Workflow: guid}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Resolution{
|
||||||
|
Kind: KindRoute,
|
||||||
|
Workflow: guid,
|
||||||
|
Module: m.Name(),
|
||||||
|
Function: resolved,
|
||||||
|
Params: params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// segment applies the presence rules for p[i] (SPEC-002 §3.4). Interior empties
|
||||||
|
// were already rejected by hasInteriorEmpty, so a "" reaching here is always a
|
||||||
|
// trailing slash — which is "absent". Callers substitute their default.
|
||||||
|
func segment(p Params, i int) (value string, absent bool) {
|
||||||
|
if i >= len(p) || p[i] == "" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
return p[i], false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasInteriorEmpty reports whether any non-final segment is empty ("//").
|
||||||
|
func hasInteriorEmpty(p Params) bool {
|
||||||
|
for i := 0; i < len(p)-1; i++ {
|
||||||
|
if p[i] == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailFrom returns the remainder p[i:] for a resolved function (SPEC-002 §3.4).
|
||||||
|
// A trailing empty — a trailing slash — is "absent" and is dropped, so the
|
||||||
|
// function receives no phantom empty param. Interior empties never reach here:
|
||||||
|
// hasInteriorEmpty already rejected them.
|
||||||
|
func tailFrom(p Params, i int) Params {
|
||||||
|
if i >= len(p) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest := p[i:]
|
||||||
|
if n := len(rest); n > 0 && rest[n-1] == "" {
|
||||||
|
rest = rest[:n-1]
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
110
internal/core/routing/fakes_test.go
Normal file
110
internal/core/routing/fakes_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
// This file holds in-memory fakes for the two injected dependencies. It must
|
||||||
|
// import no HTTP and no DB package (SPEC-002 §5, the purity assertion).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeResolver is an in-memory Resolver: guid -> enrolled. A missing guid is
|
||||||
|
// "not found". It records the guids it was asked about.
|
||||||
|
type fakeResolver struct {
|
||||||
|
users map[string]bool // guid -> enrolled
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResolver) GUID(guid string) (bool, bool) {
|
||||||
|
f.calls = append(f.calls, guid)
|
||||||
|
enrolled, found := f.users[guid]
|
||||||
|
return found, enrolled
|
||||||
|
}
|
||||||
|
|
||||||
|
// forbiddenResolver fails the test if it is ever called. It proves the reserved
|
||||||
|
// switch never touches identity (SPEC-002 §3.5 / test row 2).
|
||||||
|
type forbiddenResolver struct {
|
||||||
|
t *testing.T
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r forbiddenResolver) GUID(guid string) (bool, bool) {
|
||||||
|
r.t.Helper()
|
||||||
|
r.t.Errorf("resolver must not be called (guid=%q)", guid)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeModule is an in-memory Module: a function whitelist, an optional numeric
|
||||||
|
// classifier, a default function, and an action whitelist.
|
||||||
|
type fakeModule struct {
|
||||||
|
name string
|
||||||
|
deflt string // function for an empty/absent PARAM3
|
||||||
|
routes map[string]string // PARAM3 -> function
|
||||||
|
numeric bool // classify numeric PARAM3 as "detail"
|
||||||
|
actions map[string]string // action -> function
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeModule) Name() string { return m.name }
|
||||||
|
|
||||||
|
func (m *fakeModule) Route(function string, tail Params) (string, Params, bool) {
|
||||||
|
if function == "" {
|
||||||
|
return m.deflt, tail, true
|
||||||
|
}
|
||||||
|
if fn, ok := m.routes[function]; ok {
|
||||||
|
return fn, tail, true
|
||||||
|
}
|
||||||
|
if m.numeric && IsNumeric(function) {
|
||||||
|
return "detail", Params{function}, true
|
||||||
|
}
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeModule) Action(action string) (string, bool) {
|
||||||
|
fn, ok := m.actions[action]
|
||||||
|
return fn, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRegistry is an in-memory Registry.
|
||||||
|
type fakeRegistry map[string]Module
|
||||||
|
|
||||||
|
func (r fakeRegistry) Module(name string) (Module, bool) {
|
||||||
|
m, ok := r[name]
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// The module set used by the test matrix (SPEC-002 §3.7 uses `requests`).
|
||||||
|
var (
|
||||||
|
homeModule = &fakeModule{name: "home", deflt: "dashboard"}
|
||||||
|
|
||||||
|
requestsModule = &fakeModule{
|
||||||
|
name: "requests",
|
||||||
|
deflt: "list",
|
||||||
|
routes: map[string]string{
|
||||||
|
"open": "open",
|
||||||
|
"closed": "closed",
|
||||||
|
"pto": "pto",
|
||||||
|
"equipment": "equipment",
|
||||||
|
},
|
||||||
|
numeric: true,
|
||||||
|
actions: map[string]string{"submit": "submit"},
|
||||||
|
}
|
||||||
|
|
||||||
|
hrModule = &fakeModule{
|
||||||
|
name: "hr",
|
||||||
|
routes: map[string]string{"save-data": "save-data"},
|
||||||
|
actions: map[string]string{"save-data": "save-data"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var testRegistry = fakeRegistry{
|
||||||
|
"home": homeModule,
|
||||||
|
"requests": requestsModule,
|
||||||
|
"hr": hrModule,
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFmt renders a Resolution compactly for failure messages.
|
||||||
|
func resolveFmt(r Resolution) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"{Kind:%s Workflow:%q Module:%q Function:%q Params:%v Redirect:%q}",
|
||||||
|
r.Kind, r.Workflow, r.Module, r.Function, []string(r.Params), r.Redirect,
|
||||||
|
)
|
||||||
|
}
|
||||||
59
internal/core/routing/purity_test.go
Normal file
59
internal/core/routing/purity_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forbiddenImportPrefixes are the packages the pure routing model must never
|
||||||
|
// pull into its build OR test graph (SPEC-002 §5, the purity assertion).
|
||||||
|
var forbiddenImportPrefixes = []string{
|
||||||
|
"net/http",
|
||||||
|
"database/sql",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurityNoHTTPOrDB makes the purity assertion real: it parses every .go
|
||||||
|
// file in this package — implementation and tests — and fails if any of them
|
||||||
|
// imports an HTTP or DB package.
|
||||||
|
func TestPurityNoHTTPOrDB(t *testing.T) {
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("runtime.Caller failed")
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(thisFile)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir(%s): %v", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
checked := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, parser.ImportsOnly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", e.Name(), err)
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
for _, imp := range file.Imports {
|
||||||
|
pkg := strings.Trim(imp.Path.Value, `"`)
|
||||||
|
for _, bad := range forbiddenImportPrefixes {
|
||||||
|
if pkg == bad || strings.HasPrefix(pkg, bad+"/") {
|
||||||
|
t.Errorf("%s imports forbidden package %q (the routing model must be pure)", e.Name(), pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if checked == 0 {
|
||||||
|
t.Fatal("purity check found no .go files to inspect")
|
||||||
|
}
|
||||||
|
}
|
||||||
159
internal/core/routing/routing.go
Normal file
159
internal/core/routing/routing.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// Package routing implements MyDay's pure routing model.
|
||||||
|
//
|
||||||
|
// It turns a request path plus the two routing fields (`do` / `action`) into a
|
||||||
|
// single Resolution for BOTH doors — ROUTER (path) and HANDLER (actions), per
|
||||||
|
// D-039 and SPEC-002.
|
||||||
|
//
|
||||||
|
// The model is deliberately PURE:
|
||||||
|
//
|
||||||
|
// - it imports no HTTP stack and no database driver;
|
||||||
|
// - it never touches storage — identity (GUID -> found/enrolled) and the
|
||||||
|
// module registry are INJECTED interfaces (§3.1);
|
||||||
|
// - so the whole cascade is exhaustively unit-testable in isolation (§5).
|
||||||
|
//
|
||||||
|
// Governing decisions: D-001 · D-004 · D-017 · D-018 · D-032 · D-039.
|
||||||
|
// Spec: docs/specs/spec-002-routing-model.md.
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params is the exploded path: the path split on "/" with EMPTY SEGMENTS
|
||||||
|
// PRESERVED (SPEC-002 §3.4). An empty segment is meaningful — a trailing empty
|
||||||
|
// is a trailing slash (→ default), while an interior empty ("//") is a mismatch
|
||||||
|
// (→ 404).
|
||||||
|
type Params []string
|
||||||
|
|
||||||
|
// Parse strips a single leading "/", splits on "/", and PRESERVES empty
|
||||||
|
// segments (SPEC-002 §3.4):
|
||||||
|
//
|
||||||
|
// "/" -> {}
|
||||||
|
// "/home" -> {"home"}
|
||||||
|
// "/home/" -> {"home", ""}
|
||||||
|
// "/{guid}/request/pto" -> {"{guid}", "request", "pto"}
|
||||||
|
// "/a//b" -> {"a", "", "b"}
|
||||||
|
func Parse(path string) Params {
|
||||||
|
trimmed := strings.TrimPrefix(path, "/")
|
||||||
|
if trimmed == "" {
|
||||||
|
return Params{}
|
||||||
|
}
|
||||||
|
return Params(strings.Split(trimmed, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is the classification of a Resolution (SPEC-002 §3.2).
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KindRoute is a path-mode resolution -> (module, function, params).
|
||||||
|
KindRoute Kind = iota
|
||||||
|
// KindAction is an action-mode resolution, reached via `do`/`action`.
|
||||||
|
KindAction
|
||||||
|
// KindRedirect is the ONE canonical redirect ("/" -> "/home").
|
||||||
|
KindRedirect
|
||||||
|
// KindNotFound is a 404.
|
||||||
|
KindNotFound
|
||||||
|
// KindEnroll is the enrollment screen (GUID found but not enrolled).
|
||||||
|
KindEnroll
|
||||||
|
// KindLogin is the login screen. The PURE model never returns this: it
|
||||||
|
// returns KindRoute and the door upgrades it when no session exists
|
||||||
|
// (SPEC-002 §6).
|
||||||
|
KindLogin
|
||||||
|
// KindSetup is the setup wizard (setup mode only).
|
||||||
|
KindSetup
|
||||||
|
)
|
||||||
|
|
||||||
|
// String renders a Kind for logs and test failures.
|
||||||
|
func (k Kind) String() string {
|
||||||
|
switch k {
|
||||||
|
case KindRoute:
|
||||||
|
return "Route"
|
||||||
|
case KindAction:
|
||||||
|
return "Action"
|
||||||
|
case KindRedirect:
|
||||||
|
return "Redirect"
|
||||||
|
case KindNotFound:
|
||||||
|
return "NotFound"
|
||||||
|
case KindEnroll:
|
||||||
|
return "Enroll"
|
||||||
|
case KindLogin:
|
||||||
|
return "Login"
|
||||||
|
case KindSetup:
|
||||||
|
return "Setup"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Kind(%d)", int(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolution is the single outcome of the model (SPEC-002 §3.2). One request
|
||||||
|
// resolves to one Resolution; the door then gates and dispatches it.
|
||||||
|
type Resolution struct {
|
||||||
|
// Kind classifies the outcome.
|
||||||
|
Kind Kind
|
||||||
|
// Workflow is the PARAM1 classification: a reserved workflow ("home",
|
||||||
|
// "access", "setup", "api", …) or the GUID itself; "" for the redirect.
|
||||||
|
Workflow string
|
||||||
|
// Module is the resolved registry module (PARAM2 / `do`). Empty for
|
||||||
|
// reserved workflows — those are core-owned and carry no module.
|
||||||
|
Module string
|
||||||
|
// Function is the resolved module function (PARAM3 / `action`).
|
||||||
|
Function string
|
||||||
|
// Params is the remainder handed to the function.
|
||||||
|
Params Params
|
||||||
|
// Redirect is the target path when Kind == KindRedirect.
|
||||||
|
Redirect string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReservedWorkflows is the ONE source for the reserved level-1 names (SPEC-002
|
||||||
|
// §4, D-004). A GUID may never equal one of these; validate on create AND
|
||||||
|
// update with ValidateWorkflowName.
|
||||||
|
//
|
||||||
|
// NOTE: kept as an exported slice because the spec declares it as a constant
|
||||||
|
// source of truth; treat it as read-only. Use IsReservedWorkflow for checks.
|
||||||
|
var ReservedWorkflows = []string{
|
||||||
|
"home",
|
||||||
|
"access",
|
||||||
|
"setup",
|
||||||
|
"api",
|
||||||
|
"static",
|
||||||
|
"media",
|
||||||
|
"ws",
|
||||||
|
"dev",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReservedWorkflow reports whether name is a reserved level-1 workflow
|
||||||
|
// (exact, case-sensitive match — SPEC-002 §4).
|
||||||
|
func IsReservedWorkflow(name string) bool {
|
||||||
|
for _, w := range ReservedWorkflows {
|
||||||
|
if w == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateWorkflowName rejects a name that collides with a reserved workflow.
|
||||||
|
// It is the single guard used when a GUID (or any workflow name) is created or
|
||||||
|
// updated (SPEC-002 §4, test row 22).
|
||||||
|
func ValidateWorkflowName(name string) error {
|
||||||
|
if IsReservedWorkflow(name) {
|
||||||
|
return fmt.Errorf("routing: %q is a reserved workflow name and cannot be a GUID", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNumeric reports whether s is a non-empty ASCII digit string. It is the
|
||||||
|
// built-in classifier modules use for unknown path values such as numeric ids
|
||||||
|
// (SPEC-002 §3.7); e.g. `/{GUID}/requests/42` -> function "detail", params {42}.
|
||||||
|
func IsNumeric(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
231
internal/core/routing/routing_test.go
Normal file
231
internal/core/routing/routing_test.go
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue