691c3660d2
The /tools enable action takes name+cmd from form fields and calls
core.EnableTool, so it defines arbitrary argv that internal/tool then executes.
Its step-up gate read `session != nil && !session.IsStepUp()`, and
stepUpSession is nil unless both -webauthn-origin and -webauthn-rpid are set —
so with neither flag the gate was skipped entirely. compose passed neither and
published 9201 on every host interface, while /ptt proxies to the voice server
unauthenticated, so a caller could enable a tool, trigger it, and answer its
own confirm turn. internal/tool's boundary reasoning ("a compromised router
can't grant itself a capability") held; the outer boundary it depends on was an
unwritten deployment assumption.
The fail-open itself stays: gating on a session that can never be asserted
would 403 permanently, and that reasoning is sound. What was missing is the
compensating control.
- compose publishes 127.0.0.1:9201 so reaching the UI requires the wg tunnel by
construction rather than by convention. Verified no other service reaches
mavweb by host-published port; mavpoll is host-networked but only dials
netdata and kuma.
- stepUpOK() replaces the two inline gates in handleTools and handleRevert, so
one decision point covers both surfaces.
- -require-stepup (default false, behaviour byte-for-byte unchanged) fails those
actions closed when step-up cannot be asserted.
- A startup warning names both unguarded surfaces when stepUpSession is nil,
in fail-open and fail-closed variants.
Also repoints one doc comment at DESIGN.md, since it shared a hunk with the
warning block.
The committed kuma key is deliberately left for a separate change: the old
value is in git history forever, so rotation means a genuinely new key, not a
re-commit under a variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
921 lines
28 KiB
Go
921 lines
28 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.CoreAPI (nil) satisfies the large
|
|
// interface — only the methods the handlers touch are overridden; any other
|
|
// call would nil-panic, which is fine since the handlers never make them.
|
|
type fakeCore struct {
|
|
ipc.CoreAPI
|
|
|
|
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
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|