Files
Maven/cmd/mavweb/handlers_test.go
T
claude eec3d9bed2 /trace grows a turn-decisions table under the rule trace (V-564)
Both tables answer the same question, who won and who lost, one about nudges
and the other about utterances, so they share a page rather than splitting the
nav. A turn is one collapsible row; never_asked is coloured like a block,
because it usually is one. A read failure is logged and the rule trace above it
still renders: a daemon too old to know the method is the ordinary case during
a rolling deploy.
2026-08-06 00:52:58 +04:00

1363 lines
44 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
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: "<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")
}
})
// 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 <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)
}
}
}
// --- 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</span>") {
t.Errorf("chat page does not render the source badge; body=%s", body)
}
}