package main import ( "context" "net/http" "net/http/httptest" "net/url" "os" "reflect" "strings" "testing" "time" "github.com/kami/maven/internal/auth" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/webauthn" ) // fakeCore records the mutating calls handleTools makes and returns canned // tool lists / errors. Embedding ipc.UnimplementedCoreAPI satisfies the large // interface — only the methods the handlers touch are overridden; any other // call returns ipc.ErrNotImplemented instead of nil-panicking, so a test that // accidentally exercises an undeclared method fails loudly. type fakeCore struct { ipc.UnimplementedCoreAPI proposed, enabled []ipc.Tool listErr error enableErr error disableErr error // recorded args from the last Enable/Disable call gotEnableName string gotEnableCmd []string gotEnableDest bool gotDisable string // for handleSignal tests writeLog []ipc.WriteFactReq writeErr error signalErr error // for handleDash tests presence ipc.Presence facts []ipc.Fact nudges []ipc.Nudge notes []ipc.Note dashErr error // for handleRevert tests revertKey string revertNewID int64 revertErr error // for handleNotifications tests nudgesErr error attempts []ipc.DeliveryAttempt attemptStatus string // for handleHistory tests historyFacts []ipc.Fact historyErr error // for handleTrace tests tickTrace ipc.TickTrace traceErr error turns []ipc.TurnDecision // for handleChatAPI tests chatText string chatSource string chatErr error // for the MCP section of /tools mcpServers []ipc.MCPServerStatus mcpErr error } func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) { return f.mcpServers, f.mcpErr } func (f *fakeCore) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) { f.chatText = text if f.chatErr != nil { return ipc.ChatReply{}, f.chatErr } return ipc.ChatReply{Reply: "поняла", Source: f.chatSource}, nil } func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error { f.gotEnableName, f.gotEnableCmd, f.gotEnableDest = name, cmd, destructive return f.enableErr } func (f *fakeCore) DisableTool(_ context.Context, name string) error { f.gotDisable = name return f.disableErr } func (f *fakeCore) DeleteTool(_ context.Context, name string) error { return nil } func (f *fakeCore) ListTools(_ context.Context, status string) ([]ipc.Tool, error) { if f.listErr != nil { return nil, f.listErr } switch status { case "proposed": return f.proposed, nil default: return f.enabled, nil } } func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) { if f.writeErr != nil { return 0, f.writeErr } if f.writeLog == nil { f.writeLog = make([]ipc.WriteFactReq, 0) } f.writeLog = append(f.writeLog, req) return int64(len(f.writeLog)), nil } func (f *fakeCore) Presence(_ context.Context) (ipc.Presence, error) { if f.dashErr != nil { return ipc.Presence{}, f.dashErr } return f.presence, nil } func (f *fakeCore) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) { if f.historyErr != nil { return nil, f.historyErr } if f.historyFacts != nil { return f.historyFacts, nil } if f.dashErr != nil { return nil, f.dashErr } return f.facts, nil } func (f *fakeCore) CalendarEvents(_ context.Context, _, _ time.Time) ([]ipc.Fact, error) { return nil, nil } func (f *fakeCore) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) { if f.nudgesErr != nil { return nil, f.nudgesErr } if f.dashErr != nil { return nil, f.dashErr } return f.nudges, nil } func (f *fakeCore) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) { if f.dashErr != nil { return nil, f.dashErr } return f.notes, nil } func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) { f.revertKey = key if f.revertErr != nil { return 0, f.revertErr } return f.revertNewID, nil } func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) { return f.turns, nil } func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) { if f.traceErr != nil { return ipc.TickTrace{}, f.traceErr } return f.tickTrace, nil } // --- GET --- func TestHandleTools_GET_RendersAndEscapes(t *testing.T) { core := &fakeCore{ proposed: []ipc.Tool{{Name: "x", Scope: "homelab", Utterance: "restart the thing"}}, enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}}, } rr := httptest.NewRecorder() handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() // html/template must escape the untrusted (STT-sourced) tool name. if strings.Contains(body, "x") { t.Errorf("tool name rendered unescaped in output") } if !strings.Contains(body, "<b>x") { t.Errorf("expected escaped tool name <b>x in output") } if !strings.Contains(body, "svc") || !strings.Contains(body, "systemctl restart") { t.Errorf("enabled tool not rendered: %s", body) } } func TestHandleTools_NilCore_503(t *testing.T) { rr := httptest.NewRecorder() handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil, nil, false) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rr.Code) } } // --- POST enable --- func postForm(action string, vals url.Values) *http.Request { vals.Set("action", action) r := httptest.NewRequest(http.MethodPost, "/tools", strings.NewReader(vals.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") return r } // stepUpSession returns a PasskeySession that has already been asserted, // so POST /tools calls can pass the in-process auth gate. func stepUpSession() *webauthn.PasskeySession { s := webauthn.NewPasskeySession(5 * time.Minute) s.Assert(context.Background(), auth.Scope{}) return s } func TestHandleTools_POST_Enable_HappyPath(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart nginx"}, "destructive": {"on"}, }), core, stepUpSession(), false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.gotEnableName != "svc" { t.Errorf("name = %q, want svc", core.gotEnableName) } if want := []string{"systemctl", "restart", "nginx"}; !reflect.DeepEqual(core.gotEnableCmd, want) { t.Errorf("cmd = %v, want %v", core.gotEnableCmd, want) } if !core.gotEnableDest { t.Errorf("destructive = false, want true") } } func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{"cmd": {"systemctl restart"}}), core, stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } func TestHandleTools_POST_Enable_MissingCmd_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{"name": {"svc"}}), core, stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) { core := &fakeCore{enableErr: ipc.ErrForbidden} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, }), core, stepUpSession(), false) if rr.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502", rr.Code) } } func TestHandleTools_POST_UnknownAction_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("frobnicate", url.Values{"name": {"svc"}}), core, stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } // --- POST disable --- func TestHandleTools_POST_Disable_HappyPath(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession(), false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.gotDisable != "svc" { t.Errorf("disabled name = %q, want svc", core.gotDisable) } } func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("disable", url.Values{}), core, stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } func TestHandleTools_POST_Disable_CoreError_502(t *testing.T) { core := &fakeCore{disableErr: ipc.ErrToolNotFound} rr := httptest.NewRecorder() handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession(), false) if rr.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502", rr.Code) } } // TestEnableTool_NoInProcessAuthGate verifies that a POST /tools without a // prior WebAuthn assertion is rejected with 403 Forbidden — the in-process // auth gate requires step-up before any mutation (enable/disable). func TestEnableTool_NoInProcessAuthGate(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() // Session exists (WebAuthn wired) but was never asserted — gate rejects. handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, }), core, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want %d (expected auth gate to reject)", rr.Code, http.StatusForbidden) } if core.gotEnableName != "" { t.Errorf("core.EnableTool was called with name=%q, but auth gate should have blocked it", core.gotEnableName) } } // TestEnableTool_NoWebAuthnConfigured verifies that when WebAuthn is not // wired at all (nil session — no way to ever assert), the step-up gate is // not applied and /tools falls back to its transport-level auth. func TestEnableTool_NoWebAuthnConfigured(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, }), core, nil, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.gotEnableName != "svc" { t.Errorf("name = %q, want svc", core.gotEnableName) } } // TestEnableTool_RequireStepUp_FailsClosed verifies that with -require-stepup // set (requireStepUp=true) and WebAuthn unconfigured (nil session — step-up can // never be asserted), POST /tools is DENIED rather than falling open. func TestEnableTool_RequireStepUp_FailsClosed(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, }), core, nil, true) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } if core.gotEnableName != "" { t.Errorf("core.EnableTool called with name=%q, but -require-stepup should deny", core.gotEnableName) } } // TestHandleRevert_RequireStepUp_FailsClosed is the /api/revert half of the // same gate: nil session + -require-stepup ⇒ 403, no store mutation. func TestHandleRevert_RequireStepUp_FailsClosed(t *testing.T) { core := &fakeCore{revertNewID: 7} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleRevert(rr, req, core, nil, true) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } if core.revertKey != "" { t.Errorf("core.RevertFact called with key=%q, but -require-stepup should deny", core.revertKey) } } // TestStepUpOK covers the shared decision point directly. func TestStepUpOK(t *testing.T) { if !stepUpOK(nil, false) { t.Errorf("nil session, require=false: want allow (historical fail-open)") } if stepUpOK(nil, true) { t.Errorf("nil session, require=true: want deny") } if stepUpOK(webauthn.NewPasskeySession(5*time.Minute), false) { t.Errorf("unasserted session: want deny") } if !stepUpOK(stepUpSession(), true) { t.Errorf("asserted session, require=true: want allow") } } // TestEnableTool_WithAuthGate_RequiresStepUp verifies that a POST /tools with // an asserted passkey session proceeds past the auth gate to core.EnableTool. func TestEnableTool_WithAuthGate_RequiresStepUp(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() sess := stepUpSession() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, }), core, sess, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.gotEnableName != "svc" { t.Errorf("name = %q, want svc", core.gotEnableName) } } // --- webauthn handler wiring (contract level, not crypto) --- func newTestPasskey(t *testing.T) *PasskeyHandle { t.Helper() f, err := os.CreateTemp(t.TempDir(), "passkeys-*.json") if err != nil { t.Fatal(err) } f.Close() pk, err := newPasskeyHandle(webauthn.Config{ Origin: "https://maven.example", RPID: "maven.example", RPName: "maven", }, nil, f.Name(), nil) if err != nil { t.Fatal(err) } return pk } func TestWebAuthn_Finish_MethodGuards(t *testing.T) { pk := newTestPasskey(t) for _, tc := range []struct { name string h http.HandlerFunc }{ {"register", pk.RegisterFinish}, {"assert", pk.AssertFinish}, } { rr := httptest.NewRecorder() tc.h(rr, httptest.NewRequest(http.MethodGet, "/x", nil)) if rr.Code != http.StatusMethodNotAllowed { t.Errorf("%s finish GET = %d, want 405", tc.name, rr.Code) } } } func TestWebAuthn_Finish_MalformedJSON_400(t *testing.T) { pk := newTestPasskey(t) for _, tc := range []struct { name string h http.HandlerFunc }{ {"register", pk.RegisterFinish}, {"assert", pk.AssertFinish}, } { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader("{not json")) tc.h(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("%s finish malformed = %d, want 400", tc.name, rr.Code) } } } func TestWebAuthn_Begin_ReturnsChallenge(t *testing.T) { pk := newTestPasskey(t) for _, tc := range []struct { name string h http.HandlerFunc }{ {"register", pk.RegisterBegin}, {"assert", pk.AssertBegin}, } { rr := httptest.NewRecorder() tc.h(rr, httptest.NewRequest(http.MethodGet, "/x", nil)) if rr.Code != http.StatusOK { t.Errorf("%s begin = %d, want 200", tc.name, rr.Code) continue } if ct := rr.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("%s begin content-type = %q, want application/json", tc.name, ct) } if !strings.Contains(rr.Body.String(), `"challenge"`) { t.Errorf("%s begin body missing challenge: %s", tc.name, rr.Body.String()) } } } // --- middleware --- func TestNoCache(t *testing.T) { t.Parallel() rr := httptest.NewRecorder() innerCalled := false noCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { innerCalled = true w.Write([]byte("ok")) })).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) if ct := rr.Header().Get("Cache-Control"); ct != "no-cache, no-store, must-revalidate" { t.Errorf("Cache-Control = %q, want %q", ct, "no-cache, no-store, must-revalidate") } if !innerCalled { t.Error("inner handler was not called") } if rr.Body.String() != "ok" { t.Errorf("body = %q, want %q", rr.Body.String(), "ok") } } // --- handleSignal --- func TestHandleSignal(t *testing.T) { t.Parallel() t.Run("GET returns 405", func(t *testing.T) { rr := httptest.NewRecorder() handleSignal(rr, httptest.NewRequest(http.MethodGet, "/api/signal", nil), &fakeCore{}) if rr.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405", rr.Code) } }) t.Run("nil core returns 503", func(t *testing.T) { rr := httptest.NewRecorder() handleSignal(rr, httptest.NewRequest(http.MethodPost, "/api/signal", nil), nil) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } }) t.Run("unknown key returns 400", func(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/signal?key=nonexistent", nil) handleSignal(rr, req, core) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } }) t.Run("known key desk_active writes fact with correct params", func(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/signal?key=desk_active", nil) handleSignal(rr, req, core) if rr.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204", rr.Code) } if len(core.writeLog) != 1 { t.Fatalf("writeLog calls = %d, want 1", len(core.writeLog)) } reqF := core.writeLog[0] if reqF.Source != "infer:hyprland" { t.Errorf("source = %q, want %q", reqF.Source, "infer:hyprland") } if reqF.Value != `"active"` { t.Errorf("value = %q, want %q", reqF.Value, `"active"`) } if reqF.Kind != "env" { t.Errorf("kind = %q, want %q", reqF.Kind, "env") } if reqF.Confidence != 1.0 { t.Errorf("confidence = %f, want 1.0", reqF.Confidence) } }) t.Run("core WriteFact error returns 502", func(t *testing.T) { core := &fakeCore{writeErr: ipc.ErrForbidden} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/signal?key=desk_active", nil) handleSignal(rr, req, core) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) } // --- handleDash --- func TestHandleDash(t *testing.T) { t.Parallel() t.Run("nil core returns 503", func(t *testing.T) { rr := httptest.NewRecorder() handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), nil) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } }) t.Run("core API error returns 502", func(t *testing.T) { core := &fakeCore{dashErr: ipc.ErrNoFact} rr := httptest.NewRecorder() handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), core) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) t.Run("renders template with data", func(t *testing.T) { now := time.Now() core := &fakeCore{ presence: ipc.Presence{Bucket: ipc.Present, Score: 0.75, Updated: now}, facts: []ipc.Fact{ {Kind: "env", Key: "test-key", Value: `"val"`, Source: "test", Confidence: 1.0}, }, nudges: []ipc.Nudge{ {Rule: "test-rule", Channel: "test-chan", Message: "hello", Outcome: "pending"}, }, notes: []ipc.Note{ {Text: "a note", Source: "user"}, }, } rr := httptest.NewRecorder() handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), core) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "present") { t.Error("rendered output missing presence bucket") } if !strings.Contains(body, "0.75") { t.Error("rendered output missing presence score") } if !strings.Contains(body, "test-key") { t.Error("rendered output missing fact key") } if !strings.Contains(body, "test-rule") { t.Error("rendered output missing nudge rule") } if !strings.Contains(body, "a note") { t.Error("rendered output missing note text") } }) } // --- handleNotifications --- func TestHandleNotifications_NilCore_503(t *testing.T) { t.Parallel() rr := httptest.NewRecorder() handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), nil) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } } func TestHandleNotifications_CoreError_502(t *testing.T) { t.Parallel() core := &fakeCore{nudgesErr: ipc.ErrNudgeNotFound} rr := httptest.NewRecorder() handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), core) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } } func TestHandleNotifications_Renders(t *testing.T) { t.Parallel() core := &fakeCore{ nudges: []ipc.Nudge{ {Rule: "test-rule", Channel: "telegram", Message: "hello world", Outcome: "pending"}, {Rule: "other-rule", Channel: "voice", Message: "something happened", Outcome: "acted"}, }, } rr := httptest.NewRecorder() handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), core) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "test-rule") { t.Error("rendered output missing rule name") } if !strings.Contains(body, "telegram") { t.Error("rendered output missing channel") } if !strings.Contains(body, "pending") { t.Error("rendered output missing outcome") } if !strings.Contains(body, "hello world") { t.Error("rendered output missing message") } if !strings.Contains(body, "other-rule") { t.Error("rendered output missing second rule") } } // --- handleHistory --- func TestHandleHistory(t *testing.T) { t.Parallel() t.Run("nil core returns 503", func(t *testing.T) { rr := httptest.NewRecorder() handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), nil) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } }) t.Run("core RecentFacts error returns 502", func(t *testing.T) { core := &fakeCore{historyErr: ipc.ErrNoFact} rr := httptest.NewRecorder() handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), core) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) t.Run("renders template with facts", func(t *testing.T) { core := &fakeCore{ historyFacts: []ipc.Fact{ {Kind: "self", Key: "hist-key", Value: `"hist-val"`, Source: "test", Confidence: 0.5}, }, } rr := httptest.NewRecorder() handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), core) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "hist-key") { t.Error("rendered output missing fact key") } }) } // --- handleTrace --- func TestHandleTrace(t *testing.T) { t.Parallel() t.Run("nil core returns 503", func(t *testing.T) { rr := httptest.NewRecorder() handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), nil) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } }) t.Run("core TickTrace error returns 502", func(t *testing.T) { core := &fakeCore{traceErr: ipc.ErrNoFact} rr := httptest.NewRecorder() handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) t.Run("renders template with trace data", func(t *testing.T) { now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) snooze := time.Date(2025, 6, 1, 13, 0, 0, 0, time.UTC) core := &fakeCore{ tickTrace: ipc.TickTrace{ Now: now, Winner: "win-rule", Rules: []ipc.RuleTrace{ { RuleName: "win-rule", Severity: 5, PredicateResult: true, GateResult: true, GateDetail: ipc.GateDetail{Presence: "present"}, WasSelected: true, }, { RuleName: "lose-rule", Severity: 3, PredicateResult: true, GateResult: false, GateBlockedBy: "quiet_hours", GateDetail: ipc.GateDetail{ QuietHours: true, Presence: "away", SnoozeUntil: &snooze, }, WasSelected: false, LostTo: "win-rule", }, }, }, } rr := httptest.NewRecorder() handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "win-rule") { t.Error("rendered output missing winning rule name") } if !strings.Contains(body, "lose-rule") { t.Error("rendered output missing losing rule name") } if !strings.Contains(body, "quiet_hours") { t.Error("rendered output missing gate blocked by") } if !strings.Contains(body, "5") { t.Error("rendered output missing severity") } if !strings.Contains(body, "3") { t.Error("rendered output missing severity for second rule") } if strings.Contains(body, "nothing fired") { t.Error("rendered 'nothing fired' but a winner was set") } }) // The turn arbitration shares this page (V-564). A reader must see the // winner, a loser and the claimants that were never asked, because the last // of those is what the hardcoded ordering hides. t.Run("renders the turn decision record", func(t *testing.T) { core := &fakeCore{turns: []ipc.TurnDecision{{ Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), Utterance: "какая погода в риме", Winner: "query:weather", Claims: []ipc.TurnClaim{ {Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"}, {Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"}, {Stage: "query", Claimant: "kiwix", Outcome: "never_asked"}, }, }}} rr := httptest.NewRecorder() handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) body := rr.Body.String() for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} { if !strings.Contains(body, want) { t.Errorf("rendered page is missing %q", want) } } }) t.Run("no turns renders the empty note, not an error", func(t *testing.T) { rr := httptest.NewRecorder() handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{}) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "no turn has run") { t.Error("empty ring did not render its note") } }) } // --- handleRevert --- func TestHandleRevert(t *testing.T) { t.Parallel() t.Run("GET returns 405", func(t *testing.T) { rr := httptest.NewRecorder() handleRevert(rr, httptest.NewRequest(http.MethodGet, "/api/revert", nil), &fakeCore{}, nil, false) if rr.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405", rr.Code) } }) t.Run("nil core returns 503", func(t *testing.T) { rr := httptest.NewRecorder() handleRevert(rr, httptest.NewRequest(http.MethodPost, "/api/revert", nil), nil, nil, false) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } }) t.Run("empty key returns 400", func(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleRevert(rr, req, core, nil, false) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } }) t.Run("core returns ErrNoFact returns 404", func(t *testing.T) { core := &fakeCore{revertErr: ipc.ErrNoFact} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=missing")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleRevert(rr, req, core, nil, false) if rr.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", rr.Code) } }) t.Run("core returns error returns 502", func(t *testing.T) { core := &fakeCore{revertErr: ipc.ErrForbidden} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=somekey")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleRevert(rr, req, core, nil, false) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) t.Run("happy path returns JSON with reverted and new_id", func(t *testing.T) { core := &fakeCore{revertNewID: 42} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=test-key")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleRevert(rr, req, core, nil, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if ct := rr.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } body := rr.Body.String() if !strings.Contains(body, `"reverted":true`) { t.Errorf("body missing reverted:true: %s", body) } if !strings.Contains(body, `"new_id":42`) { t.Errorf("body missing new_id:42: %s", body) } }) t.Run("configured but un-asserted session returns 403", func(t *testing.T) { core := &fakeCore{revertNewID: 7} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // non-nil, never asserted ⇒ IsStepUp() false ⇒ gate closes. handleRevert(rr, req, core, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusForbidden { t.Errorf("status = %d, want 403", rr.Code) } if core.revertKey != "" { t.Error("RevertFact called despite closed step-up gate") } }) t.Run("asserted session passes the gate", func(t *testing.T) { core := &fakeCore{revertNewID: 9} rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleRevert(rr, req, core, stepUpSession(), false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } }) } // --- handleTools ListTools error --- func TestHandleTools_ListToolsError_502(t *testing.T) { t.Parallel() core := &fakeCore{listErr: ipc.ErrForbidden} rr := httptest.NewRecorder() handleTools(rr, postForm("enable", url.Values{ "name": {"svc"}, "cmd": {"systemctl restart"}, }), core, stepUpSession(), false) if rr.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String()) } } // --- handleRoutines --- // routineCore is a fakeCore that also answers the proposed-routine calls. type routineCore struct { fakeCore routines []ipc.ProposedRoutine dismissed int64 acceptedID int64 remCron string } func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { return c.routines, nil } func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error { c.dismissed = id return nil } func (c *routineCore) AcceptProposedRoutine(_ context.Context, id int64) error { c.acceptedID = id return nil } func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) { c.remCron = cron return 77, nil } func weeklyRoutineCore() *routineCore { return &routineCore{routines: []ipc.ProposedRoutine{{ ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7, Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(), }}} } func postRoutine(action, id string) *http.Request { return postForm(action, url.Values{"id": {id}}) } func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) { rr := httptest.NewRecorder() handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, "заправляешь") { t.Fatalf("want maven's phrasing in the page, got: %s", body) } if !strings.Contains(body, "class=scroll") { t.Fatal("table must be wrapped in
so it pans on a phone") } } // Accepting hands the loop a new reason to speak, so it needs step-up. func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) { core := weeklyRoutineCore() rr := httptest.NewRecorder() handleRoutines(rr, postRoutine("accept", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rr.Code) } if core.acceptedID != 0 { t.Fatal("accepted without step-up") } } // Accepting only flips the status. It used to also create a one-shot reminder, // which is why a non-weekly routine fired once and then went quiet forever // (Vikunja #366). The tick loop owns the schedule now, so a reminder here would // be a second, competing schedule. func TestHandleRoutines_Accept_FlipsStatusAndMakesNoReminder(t *testing.T) { core := weeklyRoutineCore() rr := httptest.NewRecorder() handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.acceptedID != 3 { t.Fatalf("accepted id = %d, want 3", core.acceptedID) } if core.remCron != "" { t.Fatalf("accepting must not create a reminder, got cron %q", core.remCron) } } // Dismiss only ever removes a reason to speak, so it is not step-up gated. func TestHandleRoutines_Dismiss_NoStepUpNeeded(t *testing.T) { core := weeklyRoutineCore() rr := httptest.NewRecorder() handleRoutines(rr, postRoutine("dismiss", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.dismissed != 3 { t.Fatalf("dismissed = %d, want 3", core.dismissed) } } func TestHandleRoutines_UnknownAction_400(t *testing.T) { rr := httptest.NewRecorder() handleRoutines(rr, postRoutine("frobnicate", "3"), weeklyRoutineCore(), stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } func TestHandleRoutines_BadID_400(t *testing.T) { rr := httptest.NewRecorder() handleRoutines(rr, postRoutine("dismiss", "nope"), weeklyRoutineCore(), stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } func TestHandleRoutines_NilCore_503(t *testing.T) { rr := httptest.NewRecorder() handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), nil, nil, false) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rr.Code) } } // --- handleChatAPI step-up gate (Vikunja #317) --- // // POST /api/chat reaches the router, the LLM and the act path, so it carries // the same gate as POST /tools and POST /api/revert. func postChat(text string) *http.Request { req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("text="+url.QueryEscape(text))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } func TestHandleChatAPI_RequireStepUp_FailsClosed(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleChatAPI(rr, postChat("выключи свет"), core, nil, true) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } if core.chatText != "" { t.Errorf("core.Chat called with %q, but -require-stepup should deny", core.chatText) } } func TestHandleChatAPI_UnassertedSession_Denied(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleChatAPI(rr, postChat("выключи свет"), core, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rr.Code) } if core.chatText != "" { t.Errorf("core.Chat called with %q despite an unasserted session", core.chatText) } } func TestHandleChatAPI_AssertedSession_PassesGate(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleChatAPI(rr, postChat("привет"), core, stepUpSession(), true) if rr.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303; body=%s", rr.Code, rr.Body.String()) } if core.chatText != "привет" { t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет") } } // Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ chat keeps // working, resting on the transport-level auth in front of mavweb. func TestHandleChatAPI_FailOpenByDefault(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleChatAPI(rr, postChat("привет"), core, nil, false) if rr.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rr.Code) } if core.chatText != "привет" { t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет") } } // The MCP section renders the configured servers, and a proposal that already // knows its cmd prefills the enable form so the argv is not retyped by hand. func TestHandleTools_GET_MCPSection(t *testing.T) { core := &fakeCore{ proposed: []ipc.Tool{{ Name: "vikunja_list_tasks", Scope: "mcp:vikunja", Cmd: []string{"mcp", "vikunja", "list_tasks"}, Destructive: true, Utterance: "mcp vikunja/list_tasks: List tasks in a project.", }}, mcpServers: []ipc.MCPServerStatus{ {Name: "vikunja", Transport: "http", Target: "http://192.168.1.104:9100/mcp", Connected: true, Server: "vikunja 0.1.0", Tools: 4}, {Name: "files", Transport: "stdio", Target: "mcp-server-fs /srv", Err: "start: no such file"}, }, } rr := httptest.NewRecorder() handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d", rr.Code) } body := rr.Body.String() for _, want := range []string{ "MCP servers", "vikunja", "192.168.1.104:9100/mcp", "vikunja 0.1.0", "files", "no such file", `value="mcp vikunja list_tasks"`, // the enable form is prefilled "checked", // and pre-marked destructive (no readOnlyHint) } { if !strings.Contains(body, want) { t.Errorf("missing %q in /tools output", want) } } } // MCP off (or an older core that does not know the method) renders the section // empty instead of breaking the page. func TestHandleTools_GET_MCPUnavailable(t *testing.T) { core := &fakeCore{mcpErr: ipc.ErrNotImplemented} rr := httptest.NewRecorder() handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "no MCP servers configured") { t.Error("expected the empty-state copy") } } // --- voice-path step-up gate (Vikunja #317) --- // // POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs // the same router, LLM and act path as POST /api/chat. They used to be // ungated on the grounds that the voice port is only reachable inside the // deploy, but mavweb is the thing proxying into it from outside. Speaking // "выключи свет" is not a smaller act than typing it. // unreachableVoice is a closed port: a request that clears the gate fails at // the dial with 503, which is how these tests tell "passed" from "denied". const unreachableVoice = "127.0.0.1:1" func pttReq() *http.Request { return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes")) } func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) { rr := httptest.NewRecorder() handlePTT(rr, pttReq(), unreachableVoice, nil, true) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } } func TestHandlePTT_UnassertedSession_Denied(t *testing.T) { rr := httptest.NewRecorder() handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } } func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) { rr := httptest.NewRecorder() handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true) if rr.Code == http.StatusForbidden { t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) } if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String()) } } // Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk // keeps working, resting on the transport-level auth in front of mavweb. func TestHandlePTT_FailOpenByDefault(t *testing.T) { rr := httptest.NewRecorder() handlePTT(rr, pttReq(), unreachableVoice, nil, false) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String()) } } func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) { rr := httptest.NewRecorder() handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } } func TestHandleWS_UnassertedSession_Denied(t *testing.T) { rr := httptest.NewRecorder() handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false) if rr.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) } } // Past the gate the handshake itself fails (httptest's recorder cannot be // hijacked), which is not a 403. That is all this asserts: the gate let it by. func TestHandleWS_AssertedSession_PassesGate(t *testing.T) { rr := httptest.NewRecorder() handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true) if rr.Code == http.StatusForbidden { t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) } } func (f *fakeCore) DeliveryAttempts(_ context.Context, status string, _ int) ([]ipc.DeliveryAttempt, error) { f.attemptStatus = status return f.attempts, nil } // TestHandleNotifications_ShowsTheOutbox — the outbox was written and never // read, so a dropped or failed send was invisible (Vikunja #390). func TestHandleNotifications_ShowsTheOutbox(t *testing.T) { done := time.Date(2026, 8, 4, 9, 0, 30, 0, time.UTC) core := &fakeCore{ attempts: []ipc.DeliveryAttempt{ {Kind: "nudge", Rule: "care-check", Channel: "telegram", Status: "dropped", Created: done.Add(-30 * time.Second), Completed: &done}, {Kind: "reminder", ReminderID: 7, Channel: "voice", Status: "pending", Created: done}, }, } rr := httptest.NewRecorder() handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications?status=dropped", nil), core) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } if core.attemptStatus != "dropped" { t.Errorf("status filter = %q, want it passed through", core.attemptStatus) } body := rr.Body.String() for _, want := range []string{"care-check", "dropped", "reminder #7", "Delivery outbox"} { if !strings.Contains(body, want) { t.Errorf("rendered outbox missing %q", want) } } } // --- the query source badge (V-539) --- // // Which query source claimed a turn was readable in the daemon log and nowhere // else, so a QA step could not tell a wrong answer from a wrongly ordered // chain. It now rides the redirect and renders beside the reply. func TestHandleChatAPI_CarriesTheClaimingSource(t *testing.T) { core := &fakeCore{chatSource: "kiwix"} rr := httptest.NewRecorder() handleChatAPI(rr, postChat("почему небо голубое"), core, nil, false) loc := rr.Header().Get("Location") if !strings.Contains(loc, "s=kiwix") { t.Errorf("redirect = %q; want the claiming source in it", loc) } } func TestHandleChatAPI_OmitsTheSourceWhenNothingClaimed(t *testing.T) { core := &fakeCore{} rr := httptest.NewRecorder() handleChatAPI(rr, postChat("запиши что я пил воду"), core, nil, false) if loc := rr.Header().Get("Location"); strings.Contains(loc, "s=") { t.Errorf("redirect = %q; a turn no source claimed carries no badge", loc) } } func TestChatPageRendersTheSourceBadge(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/chat?q=%D1%82%D0%B5%D1%81%D1%82&r=%D0%BE%D1%82%D0%B2%D0%B5%D1%82&s=search", nil) rr := httptest.NewRecorder() handleChatPage(rr, req, &fakeCore{}) if body := rr.Body.String(); !strings.Contains(body, ">search") { t.Errorf("chat page does not render the source badge; body=%s", body) } }