Files
Maven/cmd/mavweb/models_test.go
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

228 lines
8.4 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
// fakeModelCore is a core that supports the two model methods. It records what
// the page asked for, so the tests can assert the gate rather than the HTML.
type fakeModelCore struct {
ipc.UnimplementedCoreAPI
status ipc.ModelStatusResp
statusErr error
swapResp ipc.SwapModelResp
swapErr error
swapped []ipc.SwapModelReq
}
func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
return f.status, f.statusErr
}
func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
f.swapped = append(f.swapped, req)
return f.swapResp, f.swapErr
}
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
return w
}
func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, nil, session, requireStepUp)
return w
}
func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
core := &fakeModelCore{status: ipc.ModelStatusResp{
Model: "Qwen3-1.7B-UD-Q4_K_XL",
ModelPath: "/opt/maven/models/llm/qwen3.gguf",
BaseURL: "http://127.0.0.1:18099",
NCtx: 4096,
Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
}}
w := modelsGET(t, core)
if w.Code != http.StatusOK {
t.Fatalf("GET /models = %d; want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
if !strings.Contains(body, want) {
t.Errorf("page does not mention %q", want)
}
}
if len(core.swapped) != 0 {
t.Errorf("a GET swapped the model: %v", core.swapped)
}
}
func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
// like POST /tools. Nothing reaches core.
core := &fakeModelCore{}
w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
if w.Code != http.StatusForbidden {
t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
}
if len(core.swapped) != 0 {
t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
}
}
func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
status: ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models = %d; want 200", w.Code)
}
if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
}
if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
}
}
func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
swapErr: errBrokenModel{},
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "rolled back to qwen3") {
t.Errorf("page does not say it rolled back:\n%s", body)
}
}
func TestModels_RefusedPathIs403(t *testing.T) {
core := &fakeModelCore{swapErr: ipc.ErrForbidden}
w := modelsPOST(t, core, nil, false, "/etc/passwd")
if w.Code != http.StatusForbidden {
t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
}
}
func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
w := modelsGET(t, core)
if w.Code != http.StatusOK {
t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "swap not configured") {
t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
}
}
func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
// An in-process CoreAPI (no swap methods) must not 500 the page.
w := modelsGET(t, ipc.UnimplementedCoreAPI{})
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
}
}
type errBrokenModel struct{}
func (errBrokenModel) Error() string { return "llm: server did not start" }
type errPrivateModel struct{}
func (errPrivateModel) Error() string { return "exec /private/llama-server: token rejected" }
func TestModels_SwapFailureIsSanitizedAndTraceable(t *testing.T) {
core := &fakeModelCore{
swapErr: errPrivateModel{},
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
body := w.Body.String()
for _, want := range []string{"swap failed", string(problemModelsUnavailable), "request "} {
if !strings.Contains(body, want) {
t.Errorf("sanitized model error missing %q:\n%s", want, body)
}
}
if strings.Contains(body, "/private/llama-server") || strings.Contains(body, "token rejected") {
t.Errorf("model page disclosed the backend error:\n%s", body)
}
}
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
// The load failed and so did the rollback: nothing is loaded. The page used
// to branch on RolledBack first and render "rolled back to — she is still
// answering, with the old model" over an empty model name.
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{NoBackend: true},
swapErr: errBrokenModel{},
status: ipc.ModelStatusResp{Model: "unknown"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "still answering") {
t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
}
if !strings.Contains(body, "no model is loaded") {
t.Errorf("the page does not name the state the operator is in:\n%s", body)
}
}
func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
// n_ctx was read off a form that renders no such input, so only a
// hand-crafted POST could set it. The resident model is a Thinking variant
// whose window is sized for reasoning tokens; shrinking it from the wire is
// not a capability this page offers.
core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, nil, nil, false)
if len(core.swapped) != 1 {
t.Fatalf("SwapModel calls = %v; want one", core.swapped)
}
if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
}
}
func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
// A swap is a multi-minute IPC call and ipc.Client serialises everything on
// one mutex, so it must not run on the connection every other page shares.
shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
if len(shared.swapped) != 0 {
t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
}
if len(swapConn.swapped) != 1 {
t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
}
}