Files
Maven/cmd/mavweb/handlers_test.go
T
claude d60a51c9e7 store, mavweb: the delivery outbox can be read (V-390)
The table was write-only. Rows were recorded and nothing could show them, so
the tests for #368 and #370 had to reach past the store into store.DB — if a
test can only see it that way, so can nobody else. A durable record nobody
reads answers no question, and why Maven went quiet is supposed to be a query.

ListDeliveryAttempts returns recent rows newest first, filtered by status.
Status is the filter worth having because the two real questions are "what got
dropped" and "what is still pending", and neither is answerable by reading the
whole list on a busy day. It reaches mavweb over IPC as DeliveryAttempts.

The section goes on /notifications, which already answers "what did she send",
rather than on a page of its own. Shared ui.css, the nav partial, the table in
div.scroll. A failed outbox read leaves a log line and still renders the nudge
list, because half the page beats none of it.
2026-08-04 03:22:34 +04:00

1288 lines
41 KiB
Go

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
// for handleChatAPI tests
chatText 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) (string, error) {
f.chatText = text
if f.chatErr != nil {
return "", f.chatErr
}
return "поняла", 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) 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: "<b>x", Scope: "homelab", Utterance: "restart the <i>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, "<b>x") {
t.Errorf("tool name rendered unescaped in output")
}
if !strings.Contains(body, "&lt;b&gt;x") {
t.Errorf("expected escaped tool name &lt;b&gt;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")
}
})
}
// --- 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 <div class=scroll> 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)
}
}
}