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