- 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
201 lines
6.3 KiB
Go
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
|
|
}
|