- 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)
235 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|