Files
Maven/cmd/mavweb/handlers_test.go
T
kami 44807b612c webauthn: persist credentials to JSON file instead of in-memory map
- New credentialStore type in credentials.go loads/saves
  map[id]localCred to a JSON file. Thread-safe with sync.RWMutex,
  writes to disk on every mutation.
- PasskeyHandle replaces sync.RWMutex+map with *credentialStore.
  Inline save/lookip/update closures delegate to store methods.
- newPasskeyHandle now takes a storePath parameter and returns an
  error; callers updated.
- New -passkey-file flag (default ./passkeys.json) configures the
  credential store path in main.go.
- Tests use os.CreateTemp in t.TempDir() so each test gets an
  isolated, auto-cleaned store file.
2026-07-05 02:09:56 +04:00

294 lines
8.6 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"strings"
"testing"
"time"
"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
}
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, _ 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) 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
}
}
// --- GET ---
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
core := &fakeCore{
proposed: []ipc.Tool{{Name: "<b>x", Utterance: "restart the <i>thing"}},
enabled: []ipc.Tool{{Name: "svc", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
}
rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
// html/template must escape the untrusted (STT-sourced) tool name.
if strings.Contains(body, "<b>x") {
t.Errorf("tool name rendered unescaped in output")
}
if !strings.Contains(body, "&lt;b&gt;x") {
t.Errorf("expected escaped tool name &lt;b&gt;x in output")
}
if !strings.Contains(body, "svc") || !strings.Contains(body, "systemctl restart") {
t.Errorf("enabled tool not rendered: %s", body)
}
}
func TestHandleTools_NilCore_503(t *testing.T) {
rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil)
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
}
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)
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)
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)
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)
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)
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)
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)
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)
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rr.Code)
}
}
// TestEnableTool_NoInProcessAuthGate documents CURRENT behavior: handleTools
// performs the boundary-moving EnableTool with no in-process authentication —
// there is no passkey/session check in the Go handler. A POST enable with no
// prior WebAuthn AssertFinish succeeds (reaches core.EnableTool). Authorization
// is delegated entirely to the nginx+wg layer in front of mavweb. Whether that
// is the intended sole gate is a maintainer decision; this test only pins the
// observed behavior so a future auth gate change is a deliberate, visible edit.
func TestEnableTool_NoInProcessAuthGate(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
// No passkey session, no cookie, no header — just the raw POST.
handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"},
}), core)
if rr.Code != http.StatusOK || core.gotEnableName != "svc" {
t.Fatalf("expected unauthenticated enable to reach core (status=%d, name=%q); "+
"if this now fails, an in-process auth gate was added", rr.Code, 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())
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())
}
}
}