NextMyDay/internal/core/module/page_test.go
Claus Lohmar d7d2c7d2b6 feat(module): add the module registry, discovery and the home module (SPEC-008)
- Build-time discovery (D-028, the Caddy pattern): a module self-registers from
  init(); internal/modules/imports_gen.go (go:generate) blank-imports every
  module directory, so THE DIRECTORY IS THE SOURCE OF TRUTH
- The registry: deterministic registration order, config-backed enable/disable
  (D-038), required modules, name-prefixed migrations, Mount() into the door
- The name-collision guard now also fails a module named for a reserved
  level-1 workflow (SPEC-009 §13 Q-1) — access · static · api · media · ws ·
  dev — derived from routing.ReservedWorkflows, `home` excepted (D-2)
- home: the reference dashboard module, and the core Modules page
2026-10-14 10:53:57 +00:00

201 lines
6.3 KiB
Go

package module
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"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/server"
)
// recordingAudit captures events in memory (AC-6).
type recordingAudit struct {
mu sync.Mutex
events []audit.Event
}
func (a *recordingAudit) Record(_ context.Context, e audit.Event) {
a.mu.Lock()
defer a.mu.Unlock()
a.events = append(a.events, e)
}
func (a *recordingAudit) actions() []string {
a.mu.Lock()
defer a.mu.Unlock()
out := make([]string, 0, len(a.events))
for _, e := range a.events {
out = append(out, e.Action)
}
return out
}
func pageFixture(t *testing.T, write func(string, []byte) error) (*Page, *config.Config, *recordingAudit, string) {
t.Helper()
reg, err := LoadModules([]Module{
&fakeModule{name: "home", required: true, kind: "core", routes: []Route{defaultRoute()}},
&fakeModule{name: "expense", kind: "domain", routes: []Route{defaultRoute()}},
}, map[string]bool{"expense": true})
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
cfg := &config.Config{Modules: map[string]bool{"expense": true}}
path := filepath.Join(t.TempDir(), config.FileName)
aud := &recordingAudit{}
p, err := NewPage(PageDeps{
Registry: reg,
Config: cfg,
ConfigPath: path,
WriteFile: write,
Audit: aud,
})
if err != nil {
t.Fatalf("NewPage: %v", err)
}
return p, cfg, aud, path
}
func toggleRequest(name, enabled string) *http.Request {
form := url.Values{"module": {name}, "enabled": {enabled}}
r := httptest.NewRequest("POST", "/guid?do=core&action=modules.toggle", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r.WithContext(httpx.WithToken(r.Context(), "token"))
}
// ── AC-4: the UI refuses a required module ───────────────────────────────────
func TestPageToggleRequiredIsRefused(t *testing.T) {
p, cfg, aud, path := pageFixture(t, nil)
rec := httptest.NewRecorder()
if err := p.Toggle(rec, toggleRequest("home", "false"), &server.RequestContext{}); err != nil {
t.Fatalf("Toggle: %v", err)
}
if rec.Code != http.StatusForbidden {
t.Errorf("disabling a required module = %d, want 403 (AC-4)", rec.Code)
}
if cfg.Modules["home"] {
t.Error("the config recorded a change to a required module (AC-4)")
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Error("config.yaml was written for a refused toggle (AC-4)")
}
if got := aud.actions(); len(got) == 0 || got[len(got)-1] != "module.toggle" {
t.Errorf("refused toggle audit = %v, want a module.toggle row", got)
}
}
// ── AC-6: a toggle is audited and persisted ──────────────────────────────────
func TestPageToggleSavesAndAudits(t *testing.T) {
p, cfg, aud, path := pageFixture(t, nil)
rec := httptest.NewRecorder()
if err := p.Toggle(rec, toggleRequest("expense", "false"), &server.RequestContext{}); err != nil {
t.Fatalf("Toggle: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("toggle = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if cfg.Modules["expense"] {
t.Error("the in-memory config was not updated")
}
// Persisted (D-035): the file carries the new state.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(data), "expense: false") {
t.Errorf("config.yaml does not record expense: false:\n%s", data)
}
// Audited (A11/AC-6).
if got := aud.actions(); !contains(got, "module.disable") {
t.Errorf("audit = %v, want module.disable (AC-6)", got)
}
// Re-enabling audits module.enable.
rec = httptest.NewRecorder()
if err := p.Toggle(rec, toggleRequest("expense", "true"), &server.RequestContext{}); err != nil {
t.Fatalf("Toggle(on): %v", err)
}
if got := aud.actions(); !contains(got, "module.enable") {
t.Errorf("audit = %v, want module.enable (AC-6)", got)
}
}
// TestPageUnwritableConfig: D-035's caveat — unwritable ⇒ edit and restart.
func TestPageUnwritableConfig(t *testing.T) {
p, _, aud, _ := pageFixture(t, func(string, []byte) error {
return errors.New("read-only filesystem")
})
rec := httptest.NewRecorder()
if err := p.Toggle(rec, toggleRequest("expense", "false"), &server.RequestContext{}); err != nil {
t.Fatalf("Toggle: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("toggle on an unwritable config = %d, want 200 (render the caveat)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Edit it by hand and restart") {
t.Errorf("unwritable-config page did not show the edit-and-restart caveat:\n%s", rec.Body.String())
}
if got := aud.actions(); !contains(got, "module.disable") {
t.Errorf("logical toggle was not audited: %v", got)
}
}
// TestPageList: every module is listed with kind · required · enabled.
func TestPageList(t *testing.T) {
p, _, _, _ := pageFixture(t, nil)
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/guid/core/modules", nil)
r = r.WithContext(httpx.WithToken(r.Context(), "token"))
if err := p.List(rec, r, &server.RequestContext{}); err != nil {
t.Fatalf("List: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("List = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"home", "expense", "core", "domain", "locked"} {
if !strings.Contains(body, want) {
t.Errorf("Modules page is missing %q:\n%s", want, body)
}
}
}
// TestPageUnknownModule: an unknown name is a 404, audited as such.
func TestPageUnknownModule(t *testing.T) {
p, _, aud, _ := pageFixture(t, nil)
rec := httptest.NewRecorder()
if err := p.Toggle(rec, toggleRequest("ghost", "false"), &server.RequestContext{}); err != nil {
t.Fatalf("Toggle: %v", err)
}
if rec.Code != http.StatusNotFound {
t.Errorf("unknown module toggle = %d, want 404", rec.Code)
}
if got := aud.actions(); !contains(got, "module.toggle") {
t.Errorf("unknown toggle was not audited: %v", got)
}
}
func contains(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}