49dfeb879e
POST /api/ptt and /ws were listed as ungated on the grounds that mavend's voice port is only reachable inside the deploy. mavweb is the thing proxying into it from outside, so that argument does not hold. Audio posted to /api/ptt runs the same router, the same LLM and the same applyAction that POST /api/chat was gated on, which means speaking a light-switch act reached the act path while typing it did not. Both now take stepUpOK, so they fail open by default and deny under -require-stepup exactly like the other four. Registration moved down next to /api/chat because the gate needs stepUpSession. The route table records the reason and names the session-scoped assertion the hands-free case wants as a separate task. The SECURITY startup lines are one surface per line now. Found in review of #51.
1254 lines
40 KiB
Go
1254 lines
40 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
|
|
|
|
// for handleHistory tests
|
|
historyFacts []ipc.Fact
|
|
historyErr error
|
|
|
|
// for handleTrace tests
|
|
tickTrace ipc.TickTrace
|
|
traceErr error
|
|
|
|
// for handleChatAPI tests
|
|
chatText string
|
|
chatErr error
|
|
|
|
// for the MCP section of /tools
|
|
mcpServers []ipc.MCPServerStatus
|
|
mcpErr error
|
|
}
|
|
|
|
func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
|
return f.mcpServers, f.mcpErr
|
|
}
|
|
|
|
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
|
f.chatText = text
|
|
if f.chatErr != nil {
|
|
return "", f.chatErr
|
|
}
|
|
return "поняла", nil
|
|
}
|
|
|
|
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
|
f.gotEnableName, f.gotEnableCmd, f.gotEnableDest = name, cmd, destructive
|
|
return f.enableErr
|
|
}
|
|
|
|
func (f *fakeCore) DisableTool(_ context.Context, name string) error {
|
|
f.gotDisable = name
|
|
return f.disableErr
|
|
}
|
|
|
|
func (f *fakeCore) DeleteTool(_ context.Context, name string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCore) ListTools(_ context.Context, status string) ([]ipc.Tool, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
switch status {
|
|
case "proposed":
|
|
return f.proposed, nil
|
|
default:
|
|
return f.enabled, nil
|
|
}
|
|
}
|
|
|
|
func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
|
if f.writeErr != nil {
|
|
return 0, f.writeErr
|
|
}
|
|
if f.writeLog == nil {
|
|
f.writeLog = make([]ipc.WriteFactReq, 0)
|
|
}
|
|
f.writeLog = append(f.writeLog, req)
|
|
return int64(len(f.writeLog)), nil
|
|
}
|
|
|
|
func (f *fakeCore) Presence(_ context.Context) (ipc.Presence, error) {
|
|
if f.dashErr != nil {
|
|
return ipc.Presence{}, f.dashErr
|
|
}
|
|
return f.presence, nil
|
|
}
|
|
|
|
func (f *fakeCore) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
|
if f.historyErr != nil {
|
|
return nil, f.historyErr
|
|
}
|
|
if f.historyFacts != nil {
|
|
return f.historyFacts, nil
|
|
}
|
|
if f.dashErr != nil {
|
|
return nil, f.dashErr
|
|
}
|
|
return f.facts, nil
|
|
}
|
|
func (f *fakeCore) CalendarEvents(_ context.Context, _, _ time.Time) ([]ipc.Fact, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeCore) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
|
|
if f.nudgesErr != nil {
|
|
return nil, f.nudgesErr
|
|
}
|
|
if f.dashErr != nil {
|
|
return nil, f.dashErr
|
|
}
|
|
return f.nudges, nil
|
|
}
|
|
|
|
func (f *fakeCore) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
|
|
if f.dashErr != nil {
|
|
return nil, f.dashErr
|
|
}
|
|
return f.notes, nil
|
|
}
|
|
|
|
func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) {
|
|
f.revertKey = key
|
|
if f.revertErr != nil {
|
|
return 0, f.revertErr
|
|
}
|
|
return f.revertNewID, nil
|
|
}
|
|
|
|
func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
|
if f.traceErr != nil {
|
|
return ipc.TickTrace{}, f.traceErr
|
|
}
|
|
return f.tickTrace, nil
|
|
}
|
|
|
|
// --- GET ---
|
|
|
|
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
|
|
core := &fakeCore{
|
|
proposed: []ipc.Tool{{Name: "<b>x", Scope: "homelab", Utterance: "restart the <i>thing"}},
|
|
enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
// html/template must escape the untrusted (STT-sourced) tool name.
|
|
if strings.Contains(body, "<b>x") {
|
|
t.Errorf("tool name rendered unescaped in output")
|
|
}
|
|
if !strings.Contains(body, "<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")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- 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())
|
|
}
|
|
}
|