Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree.
140 lines
5.0 KiB
Go
140 lines
5.0 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/lint"
|
|
)
|
|
|
|
// The registry's two checks (design D7; spec form-library "The registry
|
|
// holds every form and is checked"). The first holds every registered
|
|
// declaration to the library's invariants. The second walks the router's
|
|
// mutation routes and asserts each is either a registered form's path or a
|
|
// declared action trigger, which is the route-level
|
|
// template-versus-handler check no markup lint can do (findings FA-6,
|
|
// FA-7, FA-9). The screens manifest is the precedent for the shape: one
|
|
// list, read by a test, that a new route must join.
|
|
|
|
// pendingFormRoutes are the mutation routes still awaiting their
|
|
// declaration, each naming the lane of the forms-library change that
|
|
// removes it. The list is transient: it must be empty before the change
|
|
// archives (proposal, "A transient allowlist inside this change"). A route
|
|
// that is not here, not a registered form's path and not a declared
|
|
// trigger fails the test, so the list can only shrink.
|
|
var pendingFormRoutes = map[string]string{}
|
|
|
|
// routeScanDir is the tree whose mux registrations make up the router:
|
|
// internal/, which holds the console's own handlers and every integration
|
|
// that registers routes of its own.
|
|
const routeScanDir = ".."
|
|
|
|
// isConsoleRoute reports whether a registered route belongs to the
|
|
// console's router. Two kinds do not: the JSON API, which is not a surface
|
|
// a person fills in and so has no form to declare, and the routes a test
|
|
// double registers (the fake Discourse server in discoursetest, whose
|
|
// package name ends in "test"), which stand in for someone else's service.
|
|
func isConsoleRoute(r lint.Route) bool {
|
|
if strings.HasPrefix(r.Path, "/api/") {
|
|
return false
|
|
}
|
|
return !strings.HasSuffix(filepath.Base(filepath.Dir(r.File)), "test")
|
|
}
|
|
|
|
// TestRegisteredFormsSatisfyTheInvariants holds every declaration in the
|
|
// registry to the library's rules. It runs here rather than in
|
|
// internal/forms because a form registers only once its package is linked
|
|
// in, and this package is where the console's forms are declared.
|
|
func TestRegisteredFormsSatisfyTheInvariants(t *testing.T) {
|
|
all := forms.All()
|
|
if len(all) == 0 {
|
|
t.Fatal("no forms are registered; the declarations register as their package initialises")
|
|
}
|
|
for _, spec := range all {
|
|
for _, problem := range forms.CheckInvariants(spec) {
|
|
t.Errorf("%s: %s", spec.Name, problem)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryMutationRouteIsDeclared walks the router's mutation routes and
|
|
// accounts for each one.
|
|
func TestEveryMutationRouteIsDeclared(t *testing.T) {
|
|
formRoutes := forms.Routes()
|
|
triggers := map[string]forms.ActionTrigger{}
|
|
for _, tr := range forms.ActionTriggers() {
|
|
triggers[tr.Key()] = tr
|
|
}
|
|
|
|
routes, err := lint.Routes(routeScanDir)
|
|
if err != nil {
|
|
t.Fatalf("scan %s: %v", routeScanDir, err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, r := range routes {
|
|
if !r.IsMutation() || !isConsoleRoute(r) {
|
|
continue
|
|
}
|
|
key := r.Method + " " + r.Path
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
if _, ok := formRoutes[key]; ok {
|
|
continue
|
|
}
|
|
if _, ok := triggers[key]; ok {
|
|
continue
|
|
}
|
|
if lane, ok := pendingFormRoutes[key]; ok {
|
|
t.Logf("pending: %s (%s)", key, lane)
|
|
continue
|
|
}
|
|
t.Errorf("mutation route %s (%s:%d) is neither a registered form's path nor a declared action trigger; declare the form beside its handler, or add it to forms.RegisterTrigger", key, r.File, r.Line)
|
|
}
|
|
|
|
// The pending list only shrinks: an entry whose route no longer exists
|
|
// is stale and must go, the same discipline the anatomy allowlist has.
|
|
var stale []string
|
|
for key := range pendingFormRoutes {
|
|
if !seen[key] {
|
|
stale = append(stale, key)
|
|
}
|
|
}
|
|
sort.Strings(stale)
|
|
for _, key := range stale {
|
|
t.Errorf("pendingFormRoutes lists %q, which the router no longer registers; remove the entry", key)
|
|
}
|
|
|
|
// Same for the declared triggers: a trigger whose route is gone is a
|
|
// declaration of nothing.
|
|
for _, tr := range forms.ActionTriggers() {
|
|
if !seen[tr.Key()] {
|
|
t.Errorf("the action trigger %q declares %q, which the router does not register", tr.Label, tr.Key())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPendingRoutesAndFormsDoNotOverlap pins that a route leaves the
|
|
// pending list when its declaration lands, so the two lists cannot both
|
|
// claim one route and hide a lane's remaining work.
|
|
func TestPendingRoutesAndFormsDoNotOverlap(t *testing.T) {
|
|
formRoutes := forms.Routes()
|
|
for key, lane := range pendingFormRoutes {
|
|
if name, ok := formRoutes[key]; ok {
|
|
t.Errorf("%s is declared by the form %q and still listed as pending (%s); remove the pending entry", key, name, lane)
|
|
}
|
|
}
|
|
for _, tr := range forms.ActionTriggers() {
|
|
if lane, ok := pendingFormRoutes[tr.Key()]; ok {
|
|
t.Errorf("%s is a declared trigger and still listed as pending (%s); remove the pending entry", tr.Key(), lane)
|
|
}
|
|
}
|
|
}
|