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.ContainerID != "" { 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) } } }