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" } 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) } }