Files
Maven/cmd/mavweb/handlers_test.go
T
claude 888c1c6768 the query source that claimed a turn is readable on /chat (V-539)
V-539 said SearXNG claims every world question, including invented terms,
so Kiwix is never reached. Measured today against the configured instance:
seven of eight invented Russian questions now return zero results, and
Response.Empty() already passes those to the ZIM. The premise moved with the
upstream engine set in three days.

The three quality signals the task named were recorded per query and none
separate the sets. Token overlap is zero for the one bad claim and also zero
for "столица Франции", whose answer is Париж. Empty snippets never fire,
because ParseResponse already drops a hit with no text. SearXNG returned no
corrections or suggestions even for the query it silently respelled. So no
threshold is built: it would cost a real answer to save one invented word.

What ships is the second half. The claiming query source crosses the IPC seam
on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It
rides the context rather than a return value, because handleText answers every
reach through one string and the mic, telegram and the web all share it.
Chat now returns ChatReply instead of a bare string.

Full -race suite green.
2026-08-05 15:28:26 +04:00

1323 lines
42 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
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) 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)
}
}
}
// --- 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)
}
}