Files
lvmh/daemon/api_extra_test.go

702 lines
26 KiB
Go

package main
// api_extra_test.go — spawn/status/container REST, gitlab handlers, webdist
// serving, body/token validation edges.
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// newSpawnAPIServer builds a full Server (spawner → fake docker, gitlab →
// fake upstream) for REST tests that need the spawn pipeline.
func newSpawnAPIServer(t *testing.T) (*httptest.Server, *fakeDocker) {
t.Helper()
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
ts := f.server(t)
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
buildCtx := t.TempDir()
dockerfile := filepath.Join(buildCtx, "docker", "worker.Dockerfile")
if err := os.MkdirAll(filepath.Dir(dockerfile), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
t.Setenv(envWorkerDockerfile, dockerfile)
t.Setenv(envWorkerContext, buildCtx)
t.Setenv(envRepoDir, t.TempDir())
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
gl := NewGitLab(store, "https://gitlab.example")
srv := NewServer(store, hub, sp, gl)
api := httptest.NewServer(srv.Routes(""))
t.Cleanup(api.Close)
return api, f
}
func apiReq(t *testing.T, method, url, token, body string) (int, string) {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, err := http.NewRequest(method, url, rdr)
if err != nil {
t.Fatalf("new req: %v", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func TestAPISpawnStatusAndContainerLifecycle(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
auth := testToken
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project","branch":"main"}`)
if code != http.StatusCreated {
t.Fatalf("spawn = %d %s", code, body)
}
var res SpawnResult
if err := json.Unmarshal([]byte(body), &res); err != nil {
t.Fatalf("decode spawn result: %v", err)
}
if res.SessionID == "" || res.ImageUsed != imageRefWorker {
t.Fatalf("spawn result = %+v", res)
}
// async job reaches running; status endpoint lists it
deadlineHit := false
for i := 0; i < 200; i++ {
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "")
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
var jobs []SpawnJob
if err := json.Unmarshal([]byte(body), &jobs); err != nil {
t.Fatalf("decode jobs: %v", err)
}
done := false
for _, j := range jobs {
if j.SessionID == res.SessionID && j.State == stateRunning {
done = true
}
}
if done {
deadlineHit = true
break
}
}
if !deadlineHit {
t.Fatal("job never reached running")
}
// delete container: happy path then 404 after row deleted
code, _ = apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+res.SessionID+"/container", auth, "")
if code != http.StatusOK {
t.Fatalf("delete container = %d", code)
}
code, body = apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+res.SessionID+"/container", auth, "")
if code != http.StatusNotFound {
t.Fatalf("second delete = %d %s, want 404", code, body)
}
// docker stop failure → 500 covered in TestAPIDeleteContainerStopFailure
}
func TestAPISpawnFailures(t *testing.T) {
// docker unreachable from the start → POST /api/spawn surfaces 500
dead := httptest.NewServer(http.NotFoundHandler())
dead.Close()
useFakeGit(t, fakeGitModeOK)
t.Setenv("DOCKER_HOST", "tcp://"+dead.Listener.Addr().String())
buildCtx := t.TempDir()
t.Setenv(envWorkerDockerfile, filepath.Join(buildCtx, "docker", "worker.Dockerfile"))
t.Setenv(envWorkerContext, buildCtx)
t.Setenv(envRepoDir, t.TempDir())
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
api := httptest.NewServer(NewServer(store, hub, sp, NewGitLab(store, "https://gitlab.example")).Routes(""))
t.Cleanup(api.Close)
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/spawn", testToken, `not json`); code != http.StatusBadRequest {
t.Fatalf("bad body = %d %s", code, body)
}
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/spawn", testToken, `{"repo":"group/project"}`); code != http.StatusInternalServerError {
t.Fatalf("dead docker spawn = %d %s, want 500", code, body)
}
}
func TestAPIDeleteContainerStopFailure(t *testing.T) {
ts, f := newSpawnAPIServer(t)
f.failStop = true
auth := testToken
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project"}`); code != http.StatusCreated {
t.Fatalf("spawn = %d %s", code, body)
}
// wait for a running job, then expect the DELETE to 500 on stop failure
var sid string
for i := 0; i < 200; i++ {
_, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "")
var jobs []SpawnJob
_ = json.Unmarshal([]byte(body), &jobs)
for _, j := range jobs {
if j.State == stateRunning {
sid = j.SessionID
}
}
if sid != "" {
break
}
}
if sid == "" {
t.Fatal("no running job")
}
if code, body := apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+sid+"/container", auth, ""); code != http.StatusInternalServerError {
t.Fatalf("delete with stop failure = %d %s, want 500", code, body)
}
}
// newGitLabAPIServer wires a Server whose GitLab points at a fake upstream.
func newGitLabAPIServer(t *testing.T, broken bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token pat-good" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = io.WriteString(w, `{"login":"alice"}`)
})
mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) {
if broken {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = io.WriteString(w, `[{"full_name":"g/p","name":"P","owner":{"login":"g"},
"updated_at":"2024-01-01T00:00:00Z","html_url":"https://gl/g/p","default_branch":"main"}]`)
})
up := httptest.NewServer(mux)
t.Cleanup(up.Close)
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
srv := NewServer(store, hub, nil, NewGitLab(store, up.URL))
api := httptest.NewServer(srv.Routes(""))
t.Cleanup(api.Close)
return api
}
func TestAPIGitLabHandlers(t *testing.T) {
ts := newGitLabAPIServer(t, false)
auth := testToken
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/status", auth, "")
if code != http.StatusOK || !strings.Contains(body, `"connected":false`) {
t.Fatalf("status = %d %s", code, body)
}
// auth still required
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/status", "", ""); code != http.StatusUnauthorized {
t.Fatalf("unauth status = %d", code)
}
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `not json`); code != http.StatusBadRequest {
t.Fatalf("connect bad body = %d %s", code, body)
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":" "}`); code != http.StatusBadRequest {
t.Fatal("connect empty token must 400")
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":"pat-bad"}`); code != http.StatusInternalServerError {
t.Fatal("connect invalid PAT must 500")
}
code, body = apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":"pat-good"}`)
if code != http.StatusOK || !strings.Contains(body, `"username":"alice"`) {
t.Fatalf("connect = %d %s", code, body)
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/repos", auth, "")
if code != http.StatusOK || !strings.Contains(body, `"path":"g/p"`) {
t.Fatalf("repos = %d %s", code, body)
}
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/gitlab/connect", auth, ""); code != http.StatusOK {
t.Fatalf("disconnect = %d", code)
}
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/repos", auth, ""); code != http.StatusConflict {
t.Fatalf("repos after disconnect = %d, want 409", code)
}
// upstream 500 → 502 bad gateway
broken := newGitLabAPIServer(t, true)
apiReq(t, http.MethodPost, broken.URL+"/api/gitlab/connect", auth, `{"token":"pat-good"}`)
if code, _ := apiReq(t, http.MethodGet, broken.URL+"/api/gitlab/repos", auth, ""); code != http.StatusBadGateway {
t.Fatalf("repos with broken upstream = %d, want 502", code)
}
}
func TestAPIPromptBodyValidation(t *testing.T) {
ts, _ := newTestServer(t)
auth := testToken
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, `not json`); code != http.StatusBadRequest {
t.Fatal("bad json must 400")
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, `{"message":" "}`); code != http.StatusBadRequest {
t.Fatal("blank message must 400")
}
// bodies over 1 MiB are rejected by MaxBytesReader
big := `{"message":"` + strings.Repeat("x", 1<<20) + `"}`
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, big); code != http.StatusBadRequest {
t.Fatal("oversized body must 400")
}
}
func TestAPIEventsLatestAndBeforeParams(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
for seq := int64(1); seq <= 5; seq++ {
if err := store.AppendEvent(Event{SessionID: "s1", Seq: seq, TS: seq, Type: evAgentSettled, Payload: []byte(`{}`)}); err != nil {
t.Fatalf("append %d: %v", seq, err)
}
}
getSeqs := func(query string) []int64 {
t.Helper()
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events"+query, auth, "")
if code != http.StatusOK {
t.Fatalf("%s → %d %s, want 200", query, code, body)
}
var frames []map[string]any
if err := json.Unmarshal([]byte(body), &frames); err != nil {
t.Fatalf("decode %s: %v", query, err)
}
seqs := make([]int64, 0, len(frames))
for _, f := range frames {
seqs = append(seqs, int64(f["seq"].(float64)))
}
return seqs
}
if got := getSeqs("?latest=1&limit=2"); len(got) != 2 || got[0] != 4 || got[1] != 5 {
t.Fatalf("latest=1&limit=2 = %v, want [4 5] ascending", got)
}
if got := getSeqs("?before=4&limit=2"); len(got) != 2 || got[0] != 2 || got[1] != 3 {
t.Fatalf("before=4&limit=2 = %v, want [2 3] ascending", got)
}
// default behavior unchanged
if got := getSeqs("?after=0&limit=10"); len(got) != 5 || got[0] != 1 {
t.Fatalf("after=0 = %v, want full ascending replay", got)
}
for _, q := range []string{"?before=abc", "?before=-1", "?latest=2"} {
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events"+q, auth, ""); code != http.StatusBadRequest {
t.Fatalf("%s → %d, want 400", q, code)
}
}
}
func TestAPIEventsEdgeCases(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events?after=-1", auth, ""); code != http.StatusBadRequest {
t.Fatal("after=-1 must 400")
}
// limit clamped above the max: still 200 with all events
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events?limit=99999999", auth, ""); code != http.StatusOK {
t.Fatal("huge limit must be clamped, not 400")
}
// non-object payload degrades to an envelope-only frame
if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 5, Type: evMessageEnd, Payload: []byte(`[1,2]`)}); err != nil {
t.Fatalf("append: %v", err)
}
_, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events", auth, "")
var frames []map[string]any
if err := json.Unmarshal([]byte(body), &frames); err != nil {
t.Fatalf("decode: %v", err)
}
if len(frames) != 1 || frames[0]["message"] != nil || frames[0]["type"] != evMessageEnd {
t.Fatalf("frame with non-object payload = %v", frames[0])
}
}
func TestAPITokenEmptyRejected(t *testing.T) {
ts, _ := newTestServer(t)
daemonToken = ""
t.Cleanup(func() { daemonToken = testToken })
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions", "anything", ""); code != http.StatusUnauthorized {
t.Fatal("empty configured token must reject everything")
}
}
func TestAPIAbortOffline409(t *testing.T) {
ts, _ := newTestServer(t)
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/abort", testToken, ""); code != http.StatusConflict {
t.Fatal("abort offline must 409")
}
}
func TestAPIHandlersWithClosedStore(t *testing.T) {
// events endpoint surfaces store errors as 500; gitlab disconnect too
ts, store := newTestServer(t)
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events", testToken, ""); code != http.StatusInternalServerError {
t.Fatal("events with broken store must 500")
}
gl := NewGitLab(store, "https://gitlab.example")
daemonToken = testToken
hub2 := NewHub(store)
ts2 := httptest.NewServer(NewServer(store, hub2, nil, gl).Routes(""))
t.Cleanup(ts2.Close)
if code, _ := apiReq(t, http.MethodDelete, ts2.URL+"/api/gitlab/connect", testToken, ""); code != http.StatusInternalServerError {
t.Fatal("gitlab disconnect with broken store must 500")
}
}
func TestAPIWebHandlerDirOverride(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("INDEX"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("APP"), 0o644); err != nil {
t.Fatalf("write app: %v", err)
}
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
srv := NewServer(store, hub, nil, NewGitLab(store, "https://gitlab.example"))
ts := httptest.NewServer(srv.Routes(dir))
t.Cleanup(ts.Close)
code, body := apiReq(t, http.MethodGet, ts.URL+"/app.js", "", "")
if code != http.StatusOK || body != "APP" {
t.Fatalf("app.js = %d %q", code, body)
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/missing/route", "", "")
if code != http.StatusOK || body != "INDEX" {
t.Fatalf("SPA fallback = %d %q", code, body)
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/", "", "")
if code != http.StatusOK || body != "INDEX" {
t.Fatalf("root = %d %q", code, body)
}
}
func TestAPIWebHandlerEmbedded(t *testing.T) {
ts, _ := newTestServer(t) // Routes("") → embedded webdist
for path, wantStatus := range map[string]int{"/index.html": 200, "/no-such-page": 200, "/": 200} {
code, body := apiReq(t, http.MethodGet, ts.URL+path, "", "")
if code != wantStatus {
t.Fatalf("%s = %d", path, code)
}
if !strings.Contains(body, "<") {
t.Fatalf("%s body = %q", path, body)
}
}
}
func TestAPIRepoImagesCRUD(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
auth := testToken
// auth required on the new namespace
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/repos", "", ""); code != http.StatusUnauthorized {
t.Fatal("GET /api/repos must require auth")
}
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", "", `{"image":"lvmh-worker-x"}`); code != http.StatusUnauthorized {
t.Fatal("PUT image must require auth")
}
// empty listing
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || body != "[]\n" {
t.Fatalf("empty repos = %d %q, want 200 []", code, body)
}
// valid registration
code, body = apiReq(t, http.MethodPut, ts.URL+"/api/repos/group/project/image", auth, `{"image":"lvmh-worker-group--project-ab12cd"}`)
if code != http.StatusOK || !strings.Contains(body, `"ok":true`) {
t.Fatalf("put image = %d %s", code, body)
}
// a second repo, then listing is sorted by repo; built reflects the fake
// docker store (legacy mode: only the default worker image exists)
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/alpha/repo/image", auth, `{"image":"lvmh-worker-alpha:1.2.3"}`); code != http.StatusOK {
t.Fatalf("put tagged image = %d", code)
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || body != `[{"repo":"alpha/repo","image":"lvmh-worker-alpha:1.2.3","built":false},{"repo":"group/project","image":"lvmh-worker-group--project-ab12cd","built":false}]`+"\n" {
t.Fatalf("repos = %d %s", code, body)
}
// upsert via PUT, verified through the listing
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/group/project/image", auth, `{"image":"lvmh-worker-other"}`); code != http.StatusOK {
t.Fatalf("upsert = %d", code)
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || !strings.Contains(body, `{"repo":"group/project","image":"lvmh-worker-other","built":false}`) {
t.Fatalf("after upsert = %d %s", code, body)
}
// delete → gone; idempotent delete
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/repos/group/project/image", auth, ""); code != http.StatusOK {
t.Fatal("delete image failed")
}
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/repos/group/project/image", auth, ""); code != http.StatusOK {
t.Fatal("delete absent image must stay 200")
}
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/repos", auth, "")
if code != http.StatusOK || strings.Contains(body, "group/project") {
t.Fatalf("after delete = %d %s", code, body)
}
}
func TestAPIRepoImageValidation(t *testing.T) {
ts, _ := newTestServer(t)
auth := testToken
valid := []string{
"lvmh-worker-x",
"lvmh-worker-group--project-ab12cd",
"lvmh-worker-x:latest",
"lvmh-worker-x:1.2.3",
"lvmh-worker-x-y.z:tag-1_2",
}
for _, img := range valid {
if code, body := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, `{"image":"`+img+`"}`); code != http.StatusOK {
t.Fatalf("image %q = %d %s, want 200", img, code, body)
}
}
invalid := []string{
"", // empty
"lvmh-worker-", // no name after the prefix
"lvmh-worker-:tag", // tag without a name
"lvmh-worker-X", // uppercase name part
"evil", // foreign namespace
"lvmh-worker-x;rm -rf /", // shell metachars
"lvmh-worker-x/y", // path separator
"lvmh-worker-x:tag with space", // space in tag
}
for _, img := range invalid {
code, body := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, `{"image":"`+img+`"}`)
if code != http.StatusBadRequest || !strings.Contains(body, "invalid image name") {
t.Fatalf("image %q = %d %s, want 400 invalid image name", img, code, body)
}
}
// bad repo shapes (single-slash-safe: ServeMux collapses "//" via
// redirect before our handler sees the path)
for _, repo := range []string{
"noslash",
"group/proj!bad",
} {
code, body := apiReq(t, http.MethodPut, ts.URL+"/api/repos/"+repo+"/image", auth, `{"image":"lvmh-worker-x"}`)
if code != http.StatusBadRequest {
t.Fatalf("repo %q = %d %s, want 400", repo, code, body)
}
code, _ = apiReq(t, http.MethodDelete, ts.URL+"/api/repos/"+repo+"/image", auth, "")
if code != http.StatusBadRequest {
t.Fatalf("delete repo %q = %d, want 400", repo, code)
}
}
// not an image path at all
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/notimage", auth, `{"image":"lvmh-worker-x"}`); code != http.StatusBadRequest {
t.Fatal("non-image path under /api/repos must 400")
}
// malformed body
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, `not json`); code != http.StatusBadRequest {
t.Fatal("bad body must 400")
}
// oversized body
big := `{"image":"` + strings.Repeat("x", 1<<20) + `"}`
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", auth, big); code != http.StatusBadRequest {
t.Fatal("oversized body must 400")
}
}
func TestAPIRepoImagesStoreFailure(t *testing.T) {
ts, store := newTestServer(t)
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/repos", testToken, ""); code != http.StatusInternalServerError {
t.Fatal("GET /api/repos with broken store must 500")
}
if code, _ := apiReq(t, http.MethodPut, ts.URL+"/api/repos/g/p/image", testToken, `{"image":"lvmh-worker-x"}`); code != http.StatusInternalServerError {
t.Fatal("PUT with broken store must 500")
}
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/repos/g/p/image", testToken, ""); code != http.StatusInternalServerError {
t.Fatal("DELETE with broken store must 500")
}
}
func TestParseEnabledModels(t *testing.T) {
items := parseEnabledModels([]byte(`{"enabledModels":["anthropic/claude-opus-5","zai/glm","bad","/x","p/"]}`))
if len(items) != 2 {
t.Fatalf("items = %+v, want 2 valid", items)
}
if items[0].Provider != "anthropic" || items[0].ID != "claude-opus-5" {
t.Fatalf("first = %+v", items[0])
}
if parseEnabledModels([]byte("not json")) != nil {
t.Fatal("invalid json must yield nil")
}
}
func TestAPISpawnModelValidation(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
auth := testToken
// invalid model spec -> 400 before any spawn work
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth,
`{"repo":"g/p","model":"no-slash"}`); code != http.StatusBadRequest {
t.Fatalf("bad model = %d %s, want 400", code, body)
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth,
`{"repo":"g/p","model":"zai-renaud/glm-5.3"}`); code != http.StatusCreated {
t.Fatal("valid provider/model-id must pass")
}
}
func TestAPIHandlersStoreFailuresCovered(t *testing.T) {
ts, store, _ := newTestServerHub(t)
auth := testToken
_ = store.Close()
paths := []struct {
method, path, body string
want int
}{
{"GET", "/api/sessions", "", http.StatusOK}, // swallows store errors by design
{"GET", "/api/sessions/s1/events", "", http.StatusInternalServerError},
{"GET", "/api/sessions/s1/stats", "", http.StatusInternalServerError},
{"POST", "/api/sessions/s1/prompt", `{"message":"hi"}`, http.StatusConflict},
{"POST", "/api/sessions/s1/abort", "", http.StatusConflict},
{"POST", "/api/sessions/s1/model", `{"provider":"p","modelId":"m"}`, http.StatusConflict},
{"PATCH", "/api/sessions/s1", `{"name":"n"}`, http.StatusInternalServerError},
{"GET", "/api/stats", "", http.StatusInternalServerError},
}
for _, tc := range paths {
code, body := apiReq(t, tc.method, ts.URL+tc.path, auth, tc.body)
if code != tc.want {
t.Errorf("%s %s = %d %s, want %d", tc.method, tc.path, code, body, tc.want)
}
}
}
func TestModelCatalogFallbackPaths(t *testing.T) {
ts, _ := newTestServer(t)
dir := t.TempDir()
minimal := filepath.Join(dir, "minimal-models.json")
if err := os.WriteFile(minimal, []byte(`{"providers":{"p":{"models":[{"id":"m1","name":"M1"}]}}}`), 0o644); err != nil {
t.Fatal(err)
}
settings := filepath.Join(dir, "settings.json")
if err := os.WriteFile(settings, []byte(`{"enabledModels":["anthropic/claude-x","p/m1","junk"]}`), 0o644); err != nil {
t.Fatal(err)
}
t.Run("explicit-file-when-baked-missing", func(t *testing.T) {
t.Setenv(envModelsFile, minimal)
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusOK || !strings.Contains(body, `"id":"m1"`) {
t.Fatalf("explicit = %d %s", code, body)
}
})
t.Run("baked-preferred-with-settings-merge", func(t *testing.T) {
t.Setenv(envModelsFile, "")
// pretend the baked dotfiles models.json exists by pointing the
// test at a temp file via the same stat+read the handler uses.
t.Setenv("LVMH_TEST_BAKED_MODELS", minimal)
t.Setenv(envSettingsFile, "LVMH_SETTINGS_FILE")
t.Setenv("LVMH_SETTINGS_FILE", settings)
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusOK {
t.Fatalf("merge = %d %s", code, body)
}
if !strings.Contains(body, "claude-x") {
t.Fatalf("enabledModels not merged: %s", body)
}
if strings.Count(body, `"id":"m1"`) != 1 {
t.Fatalf("dedupe broken: %s", body)
}
})
t.Run("unreadable-file-500", func(t *testing.T) {
t.Setenv(envModelsFile, filepath.Join(dir, "nope.json"))
code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusInternalServerError {
t.Fatalf("missing file = %d, want 500", code)
}
})
t.Run("garbage-json-500", func(t *testing.T) {
bad := filepath.Join(dir, "bad.json")
if err := os.WriteFile(bad, []byte("{{"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(envModelsFile, bad)
code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusInternalServerError {
t.Fatalf("garbage = %d, want 500", code)
}
})
}
func TestRenameAndSetModelBodyValidation(t *testing.T) {
ts, _ := newTestServer(t)
if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken, "not json"); code != http.StatusBadRequest {
t.Fatalf("bad rename body = %d, want 400", code)
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, "nope"); code != http.StatusBadRequest {
t.Fatalf("bad model body = %d, want 400", code)
}
}
func TestAPISpawnBlankContainerNoRepo(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
// no repo and not empty → 400
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"repo":""}`); code != http.StatusBadRequest {
t.Fatalf("no-repo non-empty spawn = %d %s, want 400", code, body)
}
// blank container: no repo required
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"empty":true}`)
if code != http.StatusCreated {
t.Fatalf("blank spawn = %d %s, want 201", code, body)
}
if !strings.Contains(body, `"sessionId"`) {
t.Fatalf("blank spawn body = %s, want sessionId", body)
}
}