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 }