test: ≥95% coverage — daemon 96.9% (go), web 95.5-99% (vitest 142 tests); store.ts hooks-order crash fix
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
package main
|
||||
|
||||
// api_extra_test.go — spawn/status/container REST, gitlab handlers, webdist
|
||||
// serving, body/token validation edges.
|
||||
|
||||
import (
|
||||
"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(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(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/v4/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Private-Token") != "pat-good" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"username":"alice"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) {
|
||||
if broken {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `[{"path_with_namespace":"g/p","name":"P","namespace":{"path":"g"},
|
||||
"last_activity_at":"2024-01-01T00:00:00Z","web_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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user