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) } }