NextMyDay/internal/core/server/spec008_modules_test.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

235 lines
7.5 KiB
Go

package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
// Discovery (D-028): blank-importing the aggregator runs the module
// directories' init(), so module.Load sees the real, discovered set.
_ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules"
)
// doorAudit records audit action names in memory (AC-6).
type doorAudit struct {
mu sync.Mutex
actions []string
}
func (a *doorAudit) Record(_ context.Context, e audit.Event) {
a.mu.Lock()
defer a.mu.Unlock()
a.actions = append(a.actions, e.Action)
}
func (a *doorAudit) has(action string) bool {
a.mu.Lock()
defer a.mu.Unlock()
for _, x := range a.actions {
if x == action {
return true
}
}
return false
}
// testModule is a minimal OPTIONAL module (a stand-in for a future feature such
// as `expense`) used to exercise enable/disable through the door.
type testModule struct{ name string }
func (m *testModule) Name() string { return m.name }
func (m *testModule) Required() bool { return false }
func (m *testModule) Kind() string { return "domain" }
func (m *testModule) Migrations() []module.Migration {
return nil
}
func (m *testModule) Routes() []module.Route {
return []module.Route{{Function: "index", Handler: func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
w.WriteHeader(http.StatusOK)
return nil
}, Default: true}}
}
func (m *testModule) Actions() []module.Action { return nil }
func (m *testModule) Register(r *module.Registry) error { return r.Add(m) }
func (m *testModule) Start(context.Context) error { return nil }
// consoleGate is the admin-only role gate the entrypoint installs (SPEC-008 §7).
func consoleGate() *server.Tiers {
return server.NewTiers().
Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin).
Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin)
}
// buildConsole mounts a Modules page (with a writable temp config) onto reg.
func buildConsole(t *testing.T, mods *module.Registry, reg *server.MapRegistry, aud audit.Audit) *config.Config {
t.Helper()
cfg := &config.Config{
Server: config.Server{Listen: "127.0.0.1:8080"},
Database: config.Database{Path: "test.db"},
Modules: map[string]bool{},
}
page, err := module.NewPage(module.PageDeps{
Registry: mods,
Config: cfg,
ConfigPath: filepath.Join(t.TempDir(), config.FileName),
Audit: aud,
})
if err != nil {
t.Fatalf("module.NewPage: %v", err)
}
page.Register(reg)
return cfg
}
// serveForm issues a urlencoded POST against the door.
func serveForm(h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestSpec008_DoorDispatchesToDiscoveredHome is the definition-of-done proof:
// the discovered `home` module mounts into the door and the door dispatches
// /{GUID} to its dashboard.
func TestSpec008_DoorDispatchesToDiscoveredHome(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
reg := server.NewMapRegistry()
mods.Mount(reg)
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
rec := request(h.srv, "GET", "/"+h.guid, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("GET /{guid} = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Your day, at a glance.") {
t.Errorf("the door did not dispatch to the discovered home module:\n%s", rec.Body.String())
}
// /{GUID}/home reaches the same module default (SPEC-002 §4.4).
rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie())
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your day, at a glance.") {
t.Errorf("GET /{guid}/home did not reach the home module: %d %q", rec.Code, rec.Body.String())
}
}
// TestSpec008_ModulesPageIsAdminOnly: the core Modules page and its toggle are
// gated to the admin by the door's role gate (SPEC-008 §7, D-038/AC-4).
func TestSpec008_ModulesPageIsAdminOnly(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
reg := server.NewMapRegistry()
mods.Mount(reg)
pageCfg := buildConsole(t, mods, reg, nil)
h := newHarness(t, func(d *server.Deps) {
d.Registry = reg
d.RoleGate = consoleGate()
})
// The admin may open it.
rec := request(h.srv, "GET", "/"+h.guid+"/core/modules", h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("admin GET /{guid}/core/modules = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "home") {
t.Errorf("the Modules page does not list the discovered modules:\n%s", rec.Body.String())
}
// A team member may not (403).
teamID, err := h.users.Create(h.ctx, h.adminID, "worker@example.com", "Worker", users.RoleTeam)
if err != nil {
t.Fatalf("create team: %v", err)
}
idTeam, guidTeam, err := h.ids.Mint(h.ctx, teamID)
if err != nil {
t.Fatalf("Mint team: %v", err)
}
if err := h.ids.Activate(h.ctx, idTeam); err != nil {
t.Fatalf("Activate team: %v", err)
}
sess, err := h.sessions.Mint(h.ctx, idTeam, "127.0.0.1", "test")
if err != nil {
t.Fatalf("session Mint team: %v", err)
}
rec = request(h.srv, "GET", "/"+guidTeam+"/core/modules",
&http.Cookie{Name: httpx.SessionCookieName, Value: sess.ID})
if rec.Code != http.StatusForbidden {
t.Errorf("team GET /{guid}/core/modules = %d, want 403 (admin only)", rec.Code)
}
// A required module cannot be disabled, even by the admin (AC-4).
form := url.Values{
"module": {"home"},
"enabled": {"false"},
"csrf": {h.csrf.Token(h.session.ID)},
}
if rec := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie()); rec.Code != http.StatusForbidden {
t.Errorf("admin disabling a required module = %d, want 403 (AC-4)", rec.Code)
}
if pageCfg.Modules["home"] {
t.Error("a required module was written as disabled (AC-4)")
}
}
// TestSpec008_ModulesPageToggleAudited: an admin toggling an optional module
// writes the config and is audited (D-035, AC-6).
func TestSpec008_ModulesPageToggleAudited(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
if err := mods.Add(&testModule{name: "expense"}); err != nil {
t.Fatalf("add expense: %v", err)
}
reg := server.NewMapRegistry()
mods.Mount(reg)
rec := &doorAudit{}
pageCfg := buildConsole(t, mods, reg, rec)
h := newHarness(t, func(d *server.Deps) {
d.Registry = reg
d.RoleGate = consoleGate()
})
form := url.Values{
"module": {"expense"},
"enabled": {"false"},
"csrf": {h.csrf.Token(h.session.ID)},
}
res := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie())
if res.Code != http.StatusOK {
t.Fatalf("toggle = %d, want 200 (body %q)", res.Code, res.Body.String())
}
if pageCfg.Modules["expense"] {
t.Error("the config was not updated for the optional module")
}
if !rec.has("module.disable") {
t.Errorf("the toggle was not audited (AC-6): %v", rec.actions)
}
}