eec3d9bed2
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.
1363 lines
44 KiB
Go
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, "<b>x") {
|
|
t.Errorf("expected escaped tool name <b>x in output")
|
|
}
|
|
if !strings.Contains(body, "svc") || !strings.Contains(body, "systemctl restart") {
|
|
t.Errorf("enabled tool not rendered: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_NilCore_503(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil, nil, false)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- POST enable ---
|
|
|
|
func postForm(action string, vals url.Values) *http.Request {
|
|
vals.Set("action", action)
|
|
r := httptest.NewRequest(http.MethodPost, "/tools", strings.NewReader(vals.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
return r
|
|
}
|
|
|
|
// stepUpSession returns a PasskeySession that has already been asserted,
|
|
// so POST /tools calls can pass the in-process auth gate.
|
|
func stepUpSession() *webauthn.PasskeySession {
|
|
s := webauthn.NewPasskeySession(5 * time.Minute)
|
|
s.Assert(context.Background(), auth.Scope{})
|
|
return s
|
|
}
|
|
|
|
func TestHandleTools_POST_Enable_HappyPath(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"},
|
|
"cmd": {"systemctl restart nginx"},
|
|
"destructive": {"on"},
|
|
}), core, stepUpSession(), false)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if core.gotEnableName != "svc" {
|
|
t.Errorf("name = %q, want svc", core.gotEnableName)
|
|
}
|
|
if want := []string{"systemctl", "restart", "nginx"}; !reflect.DeepEqual(core.gotEnableCmd, want) {
|
|
t.Errorf("cmd = %v, want %v", core.gotEnableCmd, want)
|
|
}
|
|
if !core.gotEnableDest {
|
|
t.Errorf("destructive = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{"cmd": {"systemctl restart"}}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_POST_Enable_MissingCmd_400(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{"name": {"svc"}}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) {
|
|
core := &fakeCore{enableErr: ipc.ErrForbidden}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"}, "cmd": {"systemctl restart"},
|
|
}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_POST_UnknownAction_400(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("frobnicate", url.Values{"name": {"svc"}}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- POST disable ---
|
|
|
|
func TestHandleTools_POST_Disable_HappyPath(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if core.gotDisable != "svc" {
|
|
t.Errorf("disabled name = %q, want svc", core.gotDisable)
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("disable", url.Values{}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleTools_POST_Disable_CoreError_502(t *testing.T) {
|
|
core := &fakeCore{disableErr: ipc.ErrToolNotFound}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestEnableTool_NoInProcessAuthGate verifies that a POST /tools without a
|
|
// prior WebAuthn assertion is rejected with 403 Forbidden — the in-process
|
|
// auth gate requires step-up before any mutation (enable/disable).
|
|
func TestEnableTool_NoInProcessAuthGate(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
// Session exists (WebAuthn wired) but was never asserted — gate rejects.
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"}, "cmd": {"systemctl restart"},
|
|
}), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d (expected auth gate to reject)", rr.Code, http.StatusForbidden)
|
|
}
|
|
if core.gotEnableName != "" {
|
|
t.Errorf("core.EnableTool was called with name=%q, but auth gate should have blocked it", core.gotEnableName)
|
|
}
|
|
}
|
|
|
|
// TestEnableTool_NoWebAuthnConfigured verifies that when WebAuthn is not
|
|
// wired at all (nil session — no way to ever assert), the step-up gate is
|
|
// not applied and /tools falls back to its transport-level auth.
|
|
func TestEnableTool_NoWebAuthnConfigured(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"}, "cmd": {"systemctl restart"},
|
|
}), core, nil, false)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if core.gotEnableName != "svc" {
|
|
t.Errorf("name = %q, want svc", core.gotEnableName)
|
|
}
|
|
}
|
|
|
|
// TestEnableTool_RequireStepUp_FailsClosed verifies that with -require-stepup
|
|
// set (requireStepUp=true) and WebAuthn unconfigured (nil session — step-up can
|
|
// never be asserted), POST /tools is DENIED rather than falling open.
|
|
func TestEnableTool_RequireStepUp_FailsClosed(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"}, "cmd": {"systemctl restart"},
|
|
}), core, nil, true)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if core.gotEnableName != "" {
|
|
t.Errorf("core.EnableTool called with name=%q, but -require-stepup should deny", core.gotEnableName)
|
|
}
|
|
}
|
|
|
|
// TestHandleRevert_RequireStepUp_FailsClosed is the /api/revert half of the
|
|
// same gate: nil session + -require-stepup ⇒ 403, no store mutation.
|
|
func TestHandleRevert_RequireStepUp_FailsClosed(t *testing.T) {
|
|
core := &fakeCore{revertNewID: 7}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
handleRevert(rr, req, core, nil, true)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if core.revertKey != "" {
|
|
t.Errorf("core.RevertFact called with key=%q, but -require-stepup should deny", core.revertKey)
|
|
}
|
|
}
|
|
|
|
// TestStepUpOK covers the shared decision point directly.
|
|
func TestStepUpOK(t *testing.T) {
|
|
if !stepUpOK(nil, false) {
|
|
t.Errorf("nil session, require=false: want allow (historical fail-open)")
|
|
}
|
|
if stepUpOK(nil, true) {
|
|
t.Errorf("nil session, require=true: want deny")
|
|
}
|
|
if stepUpOK(webauthn.NewPasskeySession(5*time.Minute), false) {
|
|
t.Errorf("unasserted session: want deny")
|
|
}
|
|
if !stepUpOK(stepUpSession(), true) {
|
|
t.Errorf("asserted session, require=true: want allow")
|
|
}
|
|
}
|
|
|
|
// TestEnableTool_WithAuthGate_RequiresStepUp verifies that a POST /tools with
|
|
// an asserted passkey session proceeds past the auth gate to core.EnableTool.
|
|
func TestEnableTool_WithAuthGate_RequiresStepUp(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
sess := stepUpSession()
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"}, "cmd": {"systemctl restart"},
|
|
}), core, sess, false)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if core.gotEnableName != "svc" {
|
|
t.Errorf("name = %q, want svc", core.gotEnableName)
|
|
}
|
|
}
|
|
|
|
// --- webauthn handler wiring (contract level, not crypto) ---
|
|
|
|
func newTestPasskey(t *testing.T) *PasskeyHandle {
|
|
t.Helper()
|
|
f, err := os.CreateTemp(t.TempDir(), "passkeys-*.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
pk, err := newPasskeyHandle(webauthn.Config{
|
|
Origin: "https://maven.example",
|
|
RPID: "maven.example",
|
|
RPName: "maven",
|
|
}, nil, f.Name(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pk
|
|
}
|
|
|
|
func TestWebAuthn_Finish_MethodGuards(t *testing.T) {
|
|
pk := newTestPasskey(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
h http.HandlerFunc
|
|
}{
|
|
{"register", pk.RegisterFinish},
|
|
{"assert", pk.AssertFinish},
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
tc.h(rr, httptest.NewRequest(http.MethodGet, "/x", nil))
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s finish GET = %d, want 405", tc.name, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebAuthn_Finish_MalformedJSON_400(t *testing.T) {
|
|
pk := newTestPasskey(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
h http.HandlerFunc
|
|
}{
|
|
{"register", pk.RegisterFinish},
|
|
{"assert", pk.AssertFinish},
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader("{not json"))
|
|
tc.h(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("%s finish malformed = %d, want 400", tc.name, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebAuthn_Begin_ReturnsChallenge(t *testing.T) {
|
|
pk := newTestPasskey(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
h http.HandlerFunc
|
|
}{
|
|
{"register", pk.RegisterBegin},
|
|
{"assert", pk.AssertBegin},
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
tc.h(rr, httptest.NewRequest(http.MethodGet, "/x", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("%s begin = %d, want 200", tc.name, rr.Code)
|
|
continue
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("%s begin content-type = %q, want application/json", tc.name, ct)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"challenge"`) {
|
|
t.Errorf("%s begin body missing challenge: %s", tc.name, rr.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- middleware ---
|
|
|
|
func TestNoCache(t *testing.T) {
|
|
t.Parallel()
|
|
rr := httptest.NewRecorder()
|
|
innerCalled := false
|
|
noCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
innerCalled = true
|
|
w.Write([]byte("ok"))
|
|
})).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if ct := rr.Header().Get("Cache-Control"); ct != "no-cache, no-store, must-revalidate" {
|
|
t.Errorf("Cache-Control = %q, want %q", ct, "no-cache, no-store, must-revalidate")
|
|
}
|
|
if !innerCalled {
|
|
t.Error("inner handler was not called")
|
|
}
|
|
if rr.Body.String() != "ok" {
|
|
t.Errorf("body = %q, want %q", rr.Body.String(), "ok")
|
|
}
|
|
}
|
|
|
|
// --- handleSignal ---
|
|
|
|
func TestHandleSignal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("GET returns 405", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleSignal(rr, httptest.NewRequest(http.MethodGet, "/api/signal", nil), &fakeCore{})
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want 405", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("nil core returns 503", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleSignal(rr, httptest.NewRequest(http.MethodPost, "/api/signal", nil), nil)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown key returns 400", func(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/signal?key=nonexistent", nil)
|
|
handleSignal(rr, req, core)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("known key desk_active writes fact with correct params", func(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/signal?key=desk_active", nil)
|
|
handleSignal(rr, req, core)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204", rr.Code)
|
|
}
|
|
if len(core.writeLog) != 1 {
|
|
t.Fatalf("writeLog calls = %d, want 1", len(core.writeLog))
|
|
}
|
|
reqF := core.writeLog[0]
|
|
if reqF.Source != "infer:hyprland" {
|
|
t.Errorf("source = %q, want %q", reqF.Source, "infer:hyprland")
|
|
}
|
|
if reqF.Value != `"active"` {
|
|
t.Errorf("value = %q, want %q", reqF.Value, `"active"`)
|
|
}
|
|
if reqF.Kind != "env" {
|
|
t.Errorf("kind = %q, want %q", reqF.Kind, "env")
|
|
}
|
|
if reqF.Confidence != 1.0 {
|
|
t.Errorf("confidence = %f, want 1.0", reqF.Confidence)
|
|
}
|
|
})
|
|
|
|
t.Run("core WriteFact error returns 502", func(t *testing.T) {
|
|
core := &fakeCore{writeErr: ipc.ErrForbidden}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/signal?key=desk_active", nil)
|
|
handleSignal(rr, req, core)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rr.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- handleDash ---
|
|
|
|
func TestHandleDash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil core returns 503", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), nil)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("core API error returns 502", func(t *testing.T) {
|
|
core := &fakeCore{dashErr: ipc.ErrNoFact}
|
|
rr := httptest.NewRecorder()
|
|
handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), core)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("renders template with data", func(t *testing.T) {
|
|
now := time.Now()
|
|
core := &fakeCore{
|
|
presence: ipc.Presence{Bucket: ipc.Present, Score: 0.75, Updated: now},
|
|
facts: []ipc.Fact{
|
|
{Kind: "env", Key: "test-key", Value: `"val"`, Source: "test", Confidence: 1.0},
|
|
},
|
|
nudges: []ipc.Nudge{
|
|
{Rule: "test-rule", Channel: "test-chan", Message: "hello", Outcome: "pending"},
|
|
},
|
|
notes: []ipc.Note{
|
|
{Text: "a note", Source: "user"},
|
|
},
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), core)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "present") {
|
|
t.Error("rendered output missing presence bucket")
|
|
}
|
|
if !strings.Contains(body, "0.75") {
|
|
t.Error("rendered output missing presence score")
|
|
}
|
|
if !strings.Contains(body, "test-key") {
|
|
t.Error("rendered output missing fact key")
|
|
}
|
|
if !strings.Contains(body, "test-rule") {
|
|
t.Error("rendered output missing nudge rule")
|
|
}
|
|
if !strings.Contains(body, "a note") {
|
|
t.Error("rendered output missing note text")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- handleNotifications ---
|
|
|
|
func TestHandleNotifications_NilCore_503(t *testing.T) {
|
|
t.Parallel()
|
|
rr := httptest.NewRecorder()
|
|
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), nil)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleNotifications_CoreError_502(t *testing.T) {
|
|
t.Parallel()
|
|
core := &fakeCore{nudgesErr: ipc.ErrNudgeNotFound}
|
|
rr := httptest.NewRecorder()
|
|
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), core)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleNotifications_Renders(t *testing.T) {
|
|
t.Parallel()
|
|
core := &fakeCore{
|
|
nudges: []ipc.Nudge{
|
|
{Rule: "test-rule", Channel: "telegram", Message: "hello world", Outcome: "pending"},
|
|
{Rule: "other-rule", Channel: "voice", Message: "something happened", Outcome: "acted"},
|
|
},
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), core)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "test-rule") {
|
|
t.Error("rendered output missing rule name")
|
|
}
|
|
if !strings.Contains(body, "telegram") {
|
|
t.Error("rendered output missing channel")
|
|
}
|
|
if !strings.Contains(body, "pending") {
|
|
t.Error("rendered output missing outcome")
|
|
}
|
|
if !strings.Contains(body, "hello world") {
|
|
t.Error("rendered output missing message")
|
|
}
|
|
if !strings.Contains(body, "other-rule") {
|
|
t.Error("rendered output missing second rule")
|
|
}
|
|
}
|
|
|
|
// --- handleHistory ---
|
|
|
|
func TestHandleHistory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil core returns 503", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), nil)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("core RecentFacts error returns 502", func(t *testing.T) {
|
|
core := &fakeCore{historyErr: ipc.ErrNoFact}
|
|
rr := httptest.NewRecorder()
|
|
handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), core)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("renders template with facts", func(t *testing.T) {
|
|
core := &fakeCore{
|
|
historyFacts: []ipc.Fact{
|
|
{Kind: "self", Key: "hist-key", Value: `"hist-val"`, Source: "test", Confidence: 0.5},
|
|
},
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), core)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "hist-key") {
|
|
t.Error("rendered output missing fact key")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- handleTrace ---
|
|
|
|
func TestHandleTrace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil core returns 503", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), nil)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("core TickTrace error returns 502", func(t *testing.T) {
|
|
core := &fakeCore{traceErr: ipc.ErrNoFact}
|
|
rr := httptest.NewRecorder()
|
|
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("renders template with trace data", func(t *testing.T) {
|
|
now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
|
|
snooze := time.Date(2025, 6, 1, 13, 0, 0, 0, time.UTC)
|
|
core := &fakeCore{
|
|
tickTrace: ipc.TickTrace{
|
|
Now: now,
|
|
Winner: "win-rule",
|
|
Rules: []ipc.RuleTrace{
|
|
{
|
|
RuleName: "win-rule",
|
|
Severity: 5,
|
|
PredicateResult: true,
|
|
GateResult: true,
|
|
GateDetail: ipc.GateDetail{Presence: "present"},
|
|
WasSelected: true,
|
|
},
|
|
{
|
|
RuleName: "lose-rule",
|
|
Severity: 3,
|
|
PredicateResult: true,
|
|
GateResult: false,
|
|
GateBlockedBy: "quiet_hours",
|
|
GateDetail: ipc.GateDetail{
|
|
QuietHours: true,
|
|
Presence: "away",
|
|
SnoozeUntil: &snooze,
|
|
},
|
|
WasSelected: false,
|
|
LostTo: "win-rule",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "win-rule") {
|
|
t.Error("rendered output missing winning rule name")
|
|
}
|
|
if !strings.Contains(body, "lose-rule") {
|
|
t.Error("rendered output missing losing rule name")
|
|
}
|
|
if !strings.Contains(body, "quiet_hours") {
|
|
t.Error("rendered output missing gate blocked by")
|
|
}
|
|
if !strings.Contains(body, "5") {
|
|
t.Error("rendered output missing severity")
|
|
}
|
|
if !strings.Contains(body, "3") {
|
|
t.Error("rendered output missing severity for second rule")
|
|
}
|
|
if strings.Contains(body, "nothing fired") {
|
|
t.Error("rendered 'nothing fired' but a winner was set")
|
|
}
|
|
})
|
|
|
|
// The turn arbitration shares this page (V-564). A reader must see the
|
|
// winner, a loser and the claimants that were never asked, because the last
|
|
// of those is what the hardcoded ordering hides.
|
|
t.Run("renders the turn decision record", func(t *testing.T) {
|
|
core := &fakeCore{turns: []ipc.TurnDecision{{
|
|
Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
|
|
Utterance: "какая погода в риме",
|
|
Winner: "query:weather",
|
|
Claims: []ipc.TurnClaim{
|
|
{Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"},
|
|
{Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"},
|
|
{Stage: "query", Claimant: "kiwix", Outcome: "never_asked"},
|
|
},
|
|
}}}
|
|
rr := httptest.NewRecorder()
|
|
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
|
|
body := rr.Body.String()
|
|
for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("rendered page is missing %q", want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("no turns renders the empty note, not an error", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "no turn has run") {
|
|
t.Error("empty ring did not render its note")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- handleRevert ---
|
|
|
|
func TestHandleRevert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("GET returns 405", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleRevert(rr, httptest.NewRequest(http.MethodGet, "/api/revert", nil), &fakeCore{}, nil, false)
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want 405", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("nil core returns 503", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleRevert(rr, httptest.NewRequest(http.MethodPost, "/api/revert", nil), nil, nil, false)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("empty key returns 400", func(t *testing.T) {
|
|
core := &fakeCore{}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key="))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
handleRevert(rr, req, core, nil, false)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("core returns ErrNoFact returns 404", func(t *testing.T) {
|
|
core := &fakeCore{revertErr: ipc.ErrNoFact}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=missing"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
handleRevert(rr, req, core, nil, false)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("core returns error returns 502", func(t *testing.T) {
|
|
core := &fakeCore{revertErr: ipc.ErrForbidden}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=somekey"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
handleRevert(rr, req, core, nil, false)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want 502", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("happy path returns JSON with reverted and new_id", func(t *testing.T) {
|
|
core := &fakeCore{revertNewID: 42}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=test-key"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
handleRevert(rr, req, core, nil, false)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, `"reverted":true`) {
|
|
t.Errorf("body missing reverted:true: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"new_id":42`) {
|
|
t.Errorf("body missing new_id:42: %s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("configured but un-asserted session returns 403", func(t *testing.T) {
|
|
core := &fakeCore{revertNewID: 7}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
// non-nil, never asserted ⇒ IsStepUp() false ⇒ gate closes.
|
|
handleRevert(rr, req, core, webauthn.NewPasskeySession(5*time.Minute), false)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", rr.Code)
|
|
}
|
|
if core.revertKey != "" {
|
|
t.Error("RevertFact called despite closed step-up gate")
|
|
}
|
|
})
|
|
|
|
t.Run("asserted session passes the gate", func(t *testing.T) {
|
|
core := &fakeCore{revertNewID: 9}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
handleRevert(rr, req, core, stepUpSession(), false)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- handleTools ListTools error ---
|
|
|
|
func TestHandleTools_ListToolsError_502(t *testing.T) {
|
|
t.Parallel()
|
|
core := &fakeCore{listErr: ipc.ErrForbidden}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, postForm("enable", url.Values{
|
|
"name": {"svc"}, "cmd": {"systemctl restart"},
|
|
}), core, stepUpSession(), false)
|
|
if rr.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- handleRoutines ---
|
|
|
|
// routineCore is a fakeCore that also answers the proposed-routine calls.
|
|
type routineCore struct {
|
|
fakeCore
|
|
|
|
routines []ipc.ProposedRoutine
|
|
dismissed int64
|
|
acceptedID int64
|
|
remCron string
|
|
}
|
|
|
|
func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
|
return c.routines, nil
|
|
}
|
|
|
|
func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error {
|
|
c.dismissed = id
|
|
return nil
|
|
}
|
|
|
|
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id int64) error {
|
|
c.acceptedID = id
|
|
return nil
|
|
}
|
|
|
|
func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) {
|
|
c.remCron = cron
|
|
return 77, nil
|
|
}
|
|
|
|
func weeklyRoutineCore() *routineCore {
|
|
return &routineCore{routines: []ipc.ProposedRoutine{{
|
|
ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7,
|
|
Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
|
}}}
|
|
}
|
|
|
|
func postRoutine(action, id string) *http.Request {
|
|
return postForm(action, url.Values{"id": {id}})
|
|
}
|
|
|
|
func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "заправляешь") {
|
|
t.Fatalf("want maven's phrasing in the page, got: %s", body)
|
|
}
|
|
if !strings.Contains(body, "class=scroll") {
|
|
t.Fatal("table must be wrapped in <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)
|
|
}
|
|
}
|