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:
Raphael Westphal
2026-08-18 14:56:08 +02:00
parent ecb91e941c
commit 0ecef2a9bd
30 changed files with 6305 additions and 54 deletions
+383
View File
@@ -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)
}
}
}
+351
View File
@@ -0,0 +1,351 @@
package main
// docker_test.go — spawner pipeline against a fake docker REST API and a
// fake git binary (PATH shim recording invocations).
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// --- fake docker daemon (just enough REST for the spawner pipeline) ---
type dockerCall struct {
Method string
Path string
Query string
Body string
}
func (c dockerCall) String() string { return c.Method + " " + c.Path + "?" + c.Query }
// recordedCreate is the parsed POST /containers/create request body.
type recordedCreate struct {
Name string
Image string `json:"Image"`
Env []string `json:"Env"`
Labels map[string]string `json:"Labels"`
Cmd []string `json:"Cmd"`
HostConfig struct {
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
} `json:"HostConfig"`
}
type fakeDocker struct {
mu sync.Mutex
calls []dockerCall
images int // entries served by GET /images/json
volume map[string]bool // existing volumes
nextID int
create []recordedCreate
failBuild bool
failBuildHTTP bool
failCreate bool
failStart bool
failStop bool
failWait bool
failVolumeCreate bool
waitHang bool
unknown []string
}
func newFakeDocker() *fakeDocker {
return &fakeDocker{images: 1, volume: map[string]bool{}}
}
func (f *fakeDocker) server(t *testing.T) *httptest.Server {
t.Helper()
ts := httptest.NewServer(f)
t.Cleanup(ts.Close)
return ts
}
// record appends a call; the /vX.Y version prefix negotiated by the SDK is
// stripped so assertions are version-agnostic.
func (f *fakeDocker) record(r *http.Request, body string) dockerCall {
path := r.URL.Path
if strings.HasPrefix(path, "/v") {
if i := strings.Index(path[1:], "/"); i >= 0 {
path = path[i+1:]
}
}
c := dockerCall{Method: r.Method, Path: path, Query: r.URL.RawQuery, Body: body}
f.mu.Lock()
f.calls = append(f.calls, c)
f.mu.Unlock()
return c
}
func (f *fakeDocker) hasCall(method, pathPrefix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) {
return true
}
}
return false
}
func (f *fakeDocker) hasCallSuffix(method, suffix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c.Method == method && strings.HasSuffix(c.Path, suffix) {
return true
}
}
return false
}
func (f *fakeDocker) countCalls(method, pathPrefix string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, c := range f.calls {
if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) {
n++
}
}
return n
}
func (f *fakeDocker) createsByName(prefix string) []recordedCreate {
f.mu.Lock()
defer f.mu.Unlock()
var out []recordedCreate
for _, c := range f.create {
if strings.HasPrefix(c.Name, prefix) {
out = append(out, c)
}
}
return out
}
func (f *fakeDocker) volumeExists(name string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.volume[name]
}
func (f *fakeDocker) assertNoUnknown(t *testing.T) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.unknown) > 0 {
t.Fatalf("fake docker got unhandled requests: %v", f.unknown)
}
}
func writeJSONNow(w http.ResponseWriter, status int, v string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, v)
}
func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
call := f.record(r, string(body))
switch {
case call.Path == "/_ping":
w.Header().Set("Api-Version", "1.44")
w.WriteHeader(http.StatusOK)
case call.Method == http.MethodGet && call.Path == "/images/json":
list := "[]"
if f.images > 0 {
list = `[{"Id":"sha256:abc","RepoTags":["lvmh-worker:latest"]}]`
}
writeJSONNow(w, http.StatusOK, list)
case call.Method == http.MethodPost && call.Path == "/build":
if f.failBuildHTTP {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"build endpoint broken"}`)
return
}
if f.failBuild {
writeJSONNow(w, http.StatusOK, `{"errorDetail":{"message":"build exploded"},"error":"build exploded"}`+"\n")
return
}
writeJSONNow(w, http.StatusOK, `{"stream":"Successfully tagged lvmh-worker:latest"}`+"\n")
case call.Method == http.MethodPost && call.Path == "/volumes/create":
var req struct {
Name string `json:"Name"`
}
_ = json.Unmarshal(body, &req)
f.mu.Lock()
f.volume[req.Name] = true
f.mu.Unlock()
if f.failVolumeCreate {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume create failed"}`)
return
}
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, req.Name))
case call.Method == http.MethodGet && strings.HasPrefix(call.Path, "/volumes/"):
name := strings.TrimPrefix(call.Path, "/volumes/")
f.mu.Lock()
exists := f.volume[name]
f.mu.Unlock()
if exists {
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name))
return
}
writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`)
case call.Method == http.MethodPost && call.Path == "/containers/create":
var rc recordedCreate
if err := json.Unmarshal(body, &rc); err != nil {
writeJSONNow(w, http.StatusBadRequest, `{"message":"bad create body"}`)
return
}
rc.Name = r.URL.Query().Get("name")
if f.failCreate {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"create failed"}`)
return
}
f.mu.Lock()
f.nextID++
id := fmt.Sprintf("cid-%d", f.nextID)
f.create = append(f.create, rc)
f.mu.Unlock()
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Id":%q,"Warnings":null}`, id))
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/start"):
if f.failStart {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"start failed"}`)
return
}
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/stop"):
if f.failStop {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"stop failed"}`)
return
}
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/wait"):
if f.failWait {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"wait failed"}`)
return
}
if f.waitHang {
<-r.Context().Done() // hold the connection until the client gives up
return
}
writeJSONNow(w, http.StatusOK, `{"StatusCode":0,"Error":null}`)
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/containers/"):
w.WriteHeader(http.StatusNoContent)
default:
f.mu.Lock()
f.unknown = append(f.unknown, call.String())
f.mu.Unlock()
writeJSONNow(w, http.StatusNotFound, `{"message":"not implemented: `+call.String()+`"}`)
}
}
// --- fake git (PATH shim): logs invocations, optionally fails ---
const (
fakeGitModeOK string = "ok"
fakeGitModeFail string = "fail"
fakeGitModeNoisy string = "noisy"
fakeGitModeSilent string = "silent"
)
// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests).
func useFakeGit(t *testing.T, mode string) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "git-calls.log")
script := "#!/bin/sh\n" +
"printf '%s\\n' \"$*\" >> \"" + logPath + "\"\n" +
"case \"$FAKE_GIT_MODE\" in\n" +
" fail) echo 'fatal: repository not found'; exit 1;;\n" +
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
" silent) exit 1;;\n" +
"esac\n" +
"exit 0\n"
if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("PATH", dir)
t.Setenv("FAKE_GIT_MODE", mode)
return logPath
}
func readGitLog(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("read git log: %v", err)
}
var out []string
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
if line != "" {
out = append(out, line)
}
}
return out
}
// --- spawner construction wired at the fake daemon ---
func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
t.Helper()
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 dockerfile dir: %v", err)
}
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil {
t.Fatalf("write dockerfile: %v", err)
}
if err := os.WriteFile(filepath.Join(buildCtx, "app.txt"), []byte("app"), 0o644); err != nil {
t.Fatalf("write app.txt: %v", err)
}
t.Setenv(envWorkerDockerfile, dockerfile)
t.Setenv(envWorkerContext, buildCtx)
t.Setenv(envRepoDir, t.TempDir())
t.Setenv(envProviderAPIKey, "key-123")
t.Setenv(envWorkerModels, "") // fall back to default path (absent) → no models bind
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
return sp, store
}
// waitJobState polls JobsSnapshot until the job for sessionID reaches want.
func waitJobState(t *testing.T, sp *Spawner, sessionID, want string) SpawnJob {
t.Helper()
var job SpawnJob
waitFor(t, 5*time.Second, func() bool {
for _, j := range sp.JobsSnapshot() {
if j.SessionID == sessionID {
job = j
return j.State == want
}
}
return false
})
return job
}
+133
View File
@@ -0,0 +1,133 @@
package main
// gitlab_extra_test.go — error taxonomy, insecure client seam, decode failures.
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGitLabErrorType(t *testing.T) {
err := &GitLabError{msg: "upstream sad"}
if err.Error() != "upstream sad" {
t.Fatalf("GitLabError.Error = %q", err.Error())
}
}
func TestNewGitLabWithClientInsecure(t *testing.T) {
store := openTestStore(t)
gl := NewGitLabWithClient(store, "https://gl.example/", true)
if !gl.insecure {
t.Fatal("insecure flag not set")
}
hc := gl.httpClient()
if hc.Transport == nil {
t.Fatal("insecure client must install a custom transport")
}
plain := NewGitLab(store, "https://gl.example/")
if plain.httpClient().Transport != nil {
t.Fatal("default client must not override the transport")
}
if !strings.HasSuffix(gl.baseURL, "example") || strings.HasSuffix(gl.baseURL, "/") {
t.Fatalf("baseURL trailing slash not trimmed: %q", gl.baseURL)
}
}
func TestGitLabConnectDecodeAndEmptyUser(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"decode-failure", "not json", "decode"},
{"empty-username", `{}`, "no username"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(up.Close)
gl := NewGitLab(openTestStore(t), up.URL)
_, err := gl.Connect(context.Background(), "pat")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Connect = %v, want containing %q", err, tc.want)
}
})
}
}
func TestGitLabRequestFailures(t *testing.T) {
store := openTestStore(t)
// unparseable base URL → request construction error
gl := NewGitLab(store, "https://gl.example/\n")
if _, err := gl.Connect(context.Background(), "pat"); err == nil {
t.Fatal("connect against unparseable URL must fail")
}
// transport failure (cancelled context) wraps into GitLabError
gl2 := NewGitLab(store, "https://gl.example")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := gl2.Connect(ctx, "pat")
if err == nil || !strings.Contains(err.Error(), "gitlab request failed") {
t.Fatalf("connect with dead context = %v, want GitLabError", err)
}
// store failures surface from token() and Disconnect
dead := openTestStore(t)
_ = dead.Close()
gl3 := NewGitLab(dead, "https://gl.example")
if _, err := gl3.Repos(context.Background()); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("repos with closed store = %v", err)
}
if err := gl3.Disconnect(); err == nil {
t.Fatal("disconnect with closed store must fail")
}
}
func TestGitLabStatusStoreFailure(t *testing.T) {
dead := openTestStore(t)
_ = dead.Close()
gl := NewGitLab(dead, "https://gl.example")
status := gl.Status()
if status["connected"] != false {
t.Fatalf("status with broken store = %v, want connected=false", status)
}
}
func TestGitLabConnectStoreFailures(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"username":"alice"}`))
}))
t.Cleanup(up.Close)
dead := openTestStore(t)
_ = dead.Close()
gl := NewGitLab(dead, up.URL)
if _, err := gl.Connect(context.Background(), "pat"); err == nil {
t.Fatal("Connect with closed store must fail at SetSetting")
}
}
func TestGitLabReposSkipsEmptyPaths(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"path_with_namespace":"","name":"X"}]`))
}))
t.Cleanup(up.Close)
store := openTestStore(t)
gl := NewGitLab(store, up.URL)
if err := store.SetSetting(settingGitLabToken, "pat"); err != nil {
t.Fatalf("set token: %v", err)
}
repos, err := gl.Repos(context.Background())
if err != nil {
t.Fatalf("repos: %v", err)
}
if len(repos) != 0 {
t.Fatalf("repos = %+v, want empty (blank path skipped)", repos)
}
}
+493
View File
@@ -0,0 +1,493 @@
package main
// hub_extra_test.go — session_info, spawn_status push, send/overflow
// branches, upgrade failures, closed-store resilience.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestHubSessionInfoUpdatesSession(t *testing.T) {
ts, store := newTestServer(t)
ws := dialAgent(t, ts)
_ = ws.WriteJSON(helloFrame("s1"))
_ = readFrame(t, ws)
// malformed session_info (no session payload) is ignored, conn stays up
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evSessionInfo, "sessionId": "s1", "seq": 1, "ts": 2})
renamed := "renamed"
// envelope sessionId missing → falls back to session.id
info := map[string]any{"id": "s1", "name": renamed, "cwd": "/w", "model": "glm-4", "provider": "p", "startedAt": 99}
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evSessionInfo, "seq": 2, "ts": 3, "session": info})
waitFor(t, 2*time.Second, func() bool {
rows, err := store.Sessions()
if err != nil || len(rows) != 1 {
return false
}
return rows[0].Info.Model == "glm-4"
})
rows, _ := store.Sessions()
if rows[0].Info.ID != "s1" || rows[0].Info.Name == nil || *rows[0].Info.Name != renamed {
t.Fatalf("session after session_info = %+v", rows[0].Info)
}
}
func TestHubHelloFallbacksAndBadPayload(t *testing.T) {
ts, _ := newTestServer(t)
ws := dialAgent(t, ts)
// hello without session payload → logged, ignored, conn lives on
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evHello, "sessionId": "s1", "seq": 0, "ts": 1})
// hello with session.id but no envelope sessionId → registers under session.id
frame := helloFrame("ignored")
frame["sessionId"] = nil
_ = ws.WriteJSON(frame)
welcome := readFrame(t, ws)
if welcome["type"] != evWelcome || welcome["sessionId"] != "ignored" {
t.Fatalf("welcome = %v, want fallback to session.id", welcome)
}
}
func TestHubIsOnline(t *testing.T) {
hub := NewHub(openTestStore(t))
if hub.IsOnline("nope") {
t.Fatal("unknown session cannot be online")
}
ac := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
hub.agents["s1"] = ac
if !hub.IsOnline("s1") {
t.Fatal("registered conn must report online")
}
}
func TestHubBroadcastSpawnStatus(t *testing.T) {
ts, _, hub := newTestServerHub(t)
hub.SpawnStatus = func() []SpawnJob {
return []SpawnJob{{Repo: "group/proj", State: stateRunning, ContainerID: "cid-1"}}
}
web := dialWeb(t, ts)
if first := readFrame(t, web); first["type"] != frameSessionList {
t.Fatalf("first frame = %v", first)
}
hub.BroadcastSpawnStatus()
_ = web.SetReadDeadline(time.Now().Add(2 * time.Second))
var got map[string]any
for got == nil {
if m := readFrame(t, web); m["type"] == frameSpawnStatus {
got = m
}
}
jobs := got["jobs"].([]any)
if len(jobs) != 1 || jobs[0].(map[string]any)["repo"] != "group/proj" {
t.Fatalf("spawn_status frame = %v", got)
}
}
// newTestServerHub is newTestServer but also hands back the hub so tests can
// wire SpawnStatus themselves (NewServer normally does).
func newTestServerHub(t *testing.T) (*httptest.Server, *Store, *Hub) {
t.Helper()
store := openTestStore(t)
daemonToken = testToken
hub := NewHub(store)
srv := &Server{store: store, hub: hub, gitlab: NewGitLab(store, "https://gitlab.example")}
ts := httptest.NewServer(srv.Routes(""))
t.Cleanup(ts.Close)
return ts, store, hub
}
// throwawayConn is a client-side websocket whose only job is being closeable
// (agentConn.drop touches conn).
func throwawayConn(t *testing.T) *websocket.Conn {
t.Helper()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
_ = c.Close()
}))
t.Cleanup(up.Close)
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
if err != nil {
t.Fatalf("dial throwaway: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
func dialWeb(t *testing.T, ts *httptest.Server) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + testToken
web, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial web ws: %v", err)
}
t.Cleanup(func() { _ = web.Close() })
return web
}
func TestHubUpgradeFailures(t *testing.T) {
ts, _ := newTestServer(t)
get := func(path string) int {
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+testToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
resp.Body.Close()
return resp.StatusCode
}
// plain HTTP GETs (no upgrade headers) must not 500
if code := get("/agent/ws"); code != http.StatusBadRequest {
t.Fatalf("agent ws non-upgrade = %d, want 400", code)
}
if code := get("/ws?token=" + testToken); code != http.StatusBadRequest {
t.Fatalf("web ws non-upgrade = %d, want 400", code)
}
}
func TestSendToAgentBranches(t *testing.T) {
hub := NewHub(openTestStore(t))
// dropped conn: done closed → ErrOffline via done branch (send kept full
// so the send case cannot win the select race)
dead := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
dead.send <- []byte("fill")
dead.drop()
hub.agents["gone"] = dead
if err := hub.sendToAgent("gone", map[string]any{"type": evAbort}); err != ErrOffline {
t.Fatalf("sendToAgent dropped conn = %v, want ErrOffline", err)
}
// live conn but nobody drains send: timeout branch (3s)
stuck := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
stuck.send <- []byte("fill")
hub.agents["stuck"] = stuck
start := time.Now()
if err := hub.sendToAgent("stuck", map[string]any{"type": evAbort}); err != ErrOffline {
t.Fatalf("sendToAgent stuck conn = %v, want ErrOffline", err)
}
if elapsed := time.Since(start); elapsed < promptSendTimeout-100*time.Millisecond {
t.Fatalf("sendToAgent returned after %v, want full timeout", elapsed)
}
}
func TestWebClientOverflowDrops(t *testing.T) {
// real ws conn so drop() has something to close
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
_ = c.Close()
}))
t.Cleanup(up.Close)
wsURL := "ws" + strings.TrimPrefix(up.URL, "http")
clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer clientConn.Close()
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: clientConn, done: make(chan struct{})}
for i := 0; i < webMaxPending; i++ {
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: []byte("{}")})
}
c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")}) // overflow → dropped
select {
case <-c.done:
case <-time.After(2 * time.Second):
t.Fatal("overflowed web client was not dropped")
}
if !c.dropped {
t.Fatal("client must be marked dropped")
}
// deliveries to a dropped client are no-ops
c.deliverControl([]byte("{}"))
if len(c.control) != 0 {
t.Fatal("dropped client must not queue control frames")
}
// control-frame overflow path on a fresh client
c2 := &webClient{hub: hub, conn: clientConn, done: make(chan struct{})}
for i := 0; i < webMaxPending; i++ {
c2.deliverControl([]byte("{}"))
}
c2.deliverControl([]byte("{}"))
select {
case <-c2.done:
case <-time.After(2 * time.Second):
t.Fatal("control-overflowed web client was not dropped")
}
}
func TestAgentWritePumpDropsOnWriteError(t *testing.T) {
serverConn := make(chan *websocket.Conn, 1)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
serverConn <- c
for { // keep server side alive until client vanishes
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}))
t.Cleanup(up.Close)
clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = clientConn.Close() })
var server *websocket.Conn
select {
case server = <-serverConn:
case <-time.After(2 * time.Second):
t.Fatal("server conn never upgraded")
}
hub := NewHub(openTestStore(t))
ac := &agentConn{hub: hub, conn: server, send: make(chan []byte, 4), done: make(chan struct{})}
go ac.writePump()
// healthy write first
ac.send <- []byte(`{"type":"welcome"}`)
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, _, err := clientConn.ReadMessage(); err != nil {
t.Fatalf("first write lost: %v", err)
}
// kill the TCP conn underneath gorilla, then keep writing
if err := clientConn.UnderlyingConn().Close(); err != nil {
t.Fatalf("close tcp: %v", err)
}
deadline := time.After(3 * time.Second)
for {
select {
case ac.send <- []byte(`{"type":"abort"}`):
case <-ac.done:
return // writePump hit the write error and dropped
case <-deadline:
t.Fatal("writePump did not drop after write failure")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestHubClosedStoreKeepsServing(t *testing.T) {
ts, store := newTestServer(t)
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
ws := dialAgent(t, ts)
_ = ws.WriteJSON(helloFrame("s1"))
// welcome still arrives with lastSeq 0 despite store failures
if welcome := readFrame(t, ws); welcome["type"] != evWelcome || welcome["lastSeq"].(float64) != 0 {
t.Fatalf("welcome with broken store = %v", welcome)
}
// events are not persisted but fan-out still happens; conn must survive
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evAgentSettled, "sessionId": "s1", "seq": 1, "ts": 5})
hub := NewHub(store) // store already closed
if views := hub.SessionsView(); len(views) != 0 {
t.Fatalf("SessionsView with broken store = %v, want empty", views)
}
}
func TestHubBroadcastSpawnStatusNilProvider(t *testing.T) {
hub := NewHub(openTestStore(t))
hub.BroadcastSpawnStatus() // SpawnStatus nil → no-op, must not panic
hub.SpawnStatus = func() []SpawnJob { return nil }
hub.BroadcastSpawnStatus() // provider returning nil jobs → empty frame
}
func TestHubByeClosesConnAndEventWithoutSessionIgnored(t *testing.T) {
ts, _ := newTestServer(t)
ws := dialAgent(t, ts)
_ = ws.WriteJSON(helloFrame("s1"))
_ = readFrame(t, ws)
// event frames without sessionId are dropped silently
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evMessageEnd, "seq": 9, "ts": 9})
// bye closes the conn from the server side
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evBye, "sessionId": "s1", "seq": 10, "ts": 10})
_ = ws.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, _, err := ws.ReadMessage(); err == nil {
t.Fatal("conn must close after bye")
}
}
func TestHubHelloWelcomeSendDropWhenQueueFull(t *testing.T) {
hub := NewHub(openTestStore(t))
ac := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
ac.send <- []byte("full") // nobody drains → welcome select takes default → drop
hello := helloFrame("s1")
raw, _ := json.Marshal(hello)
hub.handleHello(ac, frame{typ: evHello, sessionID: "s1", raw: raw})
select {
case <-ac.done:
case <-time.After(2 * time.Second):
t.Fatal("agent with full send queue must be dropped at hello")
}
}
func TestHubWebReadWritePumpEdges(t *testing.T) {
ts, _, hub := newTestServerHub(t)
web := dialWeb(t, ts)
if first := readFrame(t, web); first["type"] != frameSessionList {
t.Fatalf("first frame = %v", first)
}
// garbage frames must not kill the read pump
if err := web.WriteMessage(websocket.TextMessage, []byte("not json")); err != nil {
t.Fatalf("write garbage: %v", err)
}
if err := web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"}); err != nil {
t.Fatalf("subscribe: %v", err)
}
waitFor(t, 2*time.Second, func() bool {
hub.mu.Lock()
defer hub.mu.Unlock()
for c := range hub.webs {
return c.subscription() == "s1"
}
return false
})
if err := web.WriteJSON(map[string]any{"type": frameUnsubscribe}); err != nil {
t.Fatalf("unsubscribe: %v", err)
}
waitFor(t, 2*time.Second, func() bool {
hub.mu.Lock()
defer hub.mu.Unlock()
for c := range hub.webs {
return c.subscription() == ""
}
return false
})
// client disconnects → readPump exits, client unregistered
_ = web.Close()
waitFor(t, 2*time.Second, func() bool {
hub.mu.Lock()
defer hub.mu.Unlock()
return len(hub.webs) == 0
})
}
func TestHubDropWebIdempotent(t *testing.T) {
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})}
hub.mu.Lock()
hub.webs[c] = struct{}{}
hub.mu.Unlock()
hub.dropWeb(c)
hub.dropWeb(c) // second drop hits the already-removed early return
select {
case <-c.done:
case <-time.After(2 * time.Second):
t.Fatal("web client was not dropped")
}
hub.mu.Lock()
left := len(hub.webs)
hub.mu.Unlock()
if left != 0 {
t.Fatalf("webs after drop = %d, want 0", left)
}
}
func TestWebWritePumpDropsOnWriteError(t *testing.T) {
for _, tc := range []struct {
name string
ctrl bool // deliver control frames (vs events only)
}{
{"control-write", true},
{"events-write", false},
} {
t.Run(tc.name, func(t *testing.T) {
serverConn := make(chan *websocket.Conn, 1)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
serverConn <- c
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}))
t.Cleanup(up.Close)
clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = clientConn.Close() })
var server *websocket.Conn
select {
case server = <-serverConn:
case <-time.After(2 * time.Second):
t.Fatal("server conn never upgraded")
}
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: server, done: make(chan struct{})}
hub.mu.Lock()
hub.webs[c] = struct{}{} // writePump errors route through dropWeb
hub.mu.Unlock()
go c.writePump()
if tc.ctrl {
c.deliverControl([]byte(`{"type":"session_list"}`))
} else {
c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")})
}
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, _, err := clientConn.ReadMessage(); err != nil {
t.Fatalf("first frame lost: %v", err)
}
if err := clientConn.UnderlyingConn().Close(); err != nil {
t.Fatalf("close tcp: %v", err)
}
// flush cycle hits the dead conn → writePump drops the client
deadline := time.After(3 * time.Second)
for {
if tc.ctrl {
c.deliverControl([]byte(`{"type":"session_list"}`))
} else {
c.deliverEvent(pendingEvent{sessionID: "s", seq: 2, raw: []byte("{}")})
}
select {
case <-c.done:
return
case <-deadline:
t.Fatal("writePump did not drop after write failure")
default:
}
time.Sleep(2 * time.Millisecond)
}
})
}
}
func TestWebClientDeliverToDropped(t *testing.T) {
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})}
for i := 0; i < webMaxPending+1; i++ { // overflow sets the dropped flag
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: []byte("{}")})
}
c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")})
if len(c.events) != webMaxPending {
t.Fatalf("dropped client queued %d events, want %d", len(c.events), webMaxPending)
}
}
+31 -10
View File
@@ -5,6 +5,7 @@ package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
@@ -26,12 +27,23 @@ const (
)
func main() {
if err := run(os.Args[1:]); err != nil {
log.Fatal(err)
}
}
// run is the testable main: parses args, wires the server, blocks on SIGINT/
// SIGTERM, then shuts down. Split out of main for coverage of the wiring.
func run(args []string) error {
fs := flag.NewFlagSet("lvmh-daemon", flag.ContinueOnError)
var (
addr = flag.String("addr", defaultListenAddr, "listen address")
dbPath = flag.String("db", envOr(envDB, defaultDBPath), "sqlite database path")
webdist = flag.String("webdist", webDistDefault, "serve web UI from this directory instead of the embedded build")
addr = fs.String("addr", defaultListenAddr, "listen address")
dbPath = fs.String("db", envOr(envDB, defaultDBPath), "sqlite database path")
webdist = fs.String("webdist", webDistDefault, "serve web UI from this directory instead of the embedded build")
)
flag.Parse()
if err := fs.Parse(args); err != nil {
return err
}
// Fall back to the embedded UI when the override directory is absent.
if st, err := os.Stat(*webdist); err != nil || !st.IsDir() {
*webdist = ""
@@ -39,17 +51,17 @@ func main() {
daemonToken = os.Getenv(envToken)
if daemonToken == "" {
log.Fatalf("LVMH_TOKEN must be set")
return fmt.Errorf("LVMH_TOKEN must be set")
}
if dir := filepath.Dir(*dbPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatalf("create db dir: %v", err)
return fmt.Errorf("create db dir: %w", err)
}
}
store, err := OpenStore(*dbPath)
if err != nil {
log.Fatalf("open store: %v", err)
return fmt.Errorf("open store: %w", err)
}
defer store.Close()
@@ -57,7 +69,7 @@ func main() {
gitlab := NewGitLab(store, envOr(envGitLabBaseURL, defaultGitLabBase))
spawner, err := NewSpawner(store, hub, envOr(envGitLabBaseURL, defaultGitLabBase))
if err != nil {
log.Fatalf("docker client: %v", err)
return fmt.Errorf("docker client: %w", err)
}
srv := NewServer(store, hub, spawner, gitlab)
@@ -67,20 +79,29 @@ func main() {
ReadHeaderTimeout: 10 * time.Second,
}
serveErr := make(chan error, 1)
go func() {
log.Printf("lvmh daemon listening on %s (db %s)", *addr, *dbPath)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
serveErr <- err
return
}
serveErr <- nil
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
defer signal.Stop(stop)
select {
case err := <-serveErr:
return fmt.Errorf("listen: %w", err)
case <-stop:
}
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown: %v", err)
}
return nil
}
+121
View File
@@ -0,0 +1,121 @@
package main
// main_test.go — run() wiring: flag errors, missing token, db dir creation,
// listen, graceful SIGTERM shutdown.
import (
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
func freeTestAddr(t *testing.T) string {
t.Helper()
ls, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("probe listener: %v", err)
}
defer ls.Close()
return fmt.Sprintf("127.0.0.1:%d", ls.Addr().(*net.TCPAddr).Port)
}
func TestRunFlagError(t *testing.T) {
if err := run([]string{"--nope"}); err == nil {
t.Fatal("unknown flag must error")
}
}
func TestRunRequiresToken(t *testing.T) {
t.Setenv(envToken, "")
if err := run(nil); err == nil || err.Error() != "LVMH_TOKEN must be set" {
t.Fatalf("run without token = %v", err)
}
}
func TestRunBadDBPath(t *testing.T) {
t.Setenv(envToken, "s3cret")
occupied := filepath.Join(t.TempDir(), "file.db")
if err := os.WriteFile(occupied, nil, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := run([]string{"--db", filepath.Join(occupied, "sub", "lvmh.db")}); err == nil {
t.Fatal("run with db under a file must fail")
}
}
func TestRunListenAndServeShutdown(t *testing.T) {
addr := freeTestAddr(t)
dir := t.TempDir()
db := filepath.Join(dir, "nested", "lvmh.db")
t.Setenv(envToken, "s3cret")
t.Setenv(envDB, db)
errCh := make(chan error, 1)
go func() { errCh <- run([]string{"--addr", addr, "--webdist", filepath.Join(dir, "absent")}) }()
// server comes up: /api answers 401 without a token
deadline := time.Now().Add(5 * time.Second)
responded := false
for time.Now().Before(deadline) {
if resp, err := http.Get("http://" + addr + "/api/sessions"); err == nil {
resp.Body.Close()
responded = resp.StatusCode == 401
break
}
time.Sleep(20 * time.Millisecond)
}
if !responded {
t.Fatal("daemon never answered /api/sessions with 401")
}
if _, err := os.Stat(db); err != nil {
t.Fatalf("db not created at %s: %v", db, err)
}
// SIGTERM → graceful shutdown, run returns nil
if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
t.Fatalf("kill: %v", err)
}
select {
case err := <-errCh:
if err != nil {
t.Fatalf("run returned %v, want nil", err)
}
case <-time.After(10 * time.Second):
t.Fatal("run did not return after SIGTERM")
}
}
func TestRunListenFailure(t *testing.T) {
t.Setenv(envToken, "s3cret")
ls, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("occupy listener: %v", err)
}
defer ls.Close()
port := ls.Addr().(*net.TCPAddr).Port
db := filepath.Join(t.TempDir(), "lvmh.db")
done := make(chan error, 1)
go func() { done <- run([]string{"--addr", fmt.Sprintf("127.0.0.1:%d", port), "--db", db}) }()
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "listen") {
t.Fatalf("run on occupied port = %v, want listen error", err)
}
case <-time.After(10 * time.Second):
t.Fatal("run never returned on listen failure")
}
}
func TestRunOpenStoreFailure(t *testing.T) {
t.Setenv(envToken, "s3cret")
// --db pointing at an existing directory: schema exec fails
if err := run([]string{"--db", t.TempDir(), "--addr", "127.0.0.1:0"}); err == nil || !strings.Contains(err.Error(), "open store") {
t.Fatalf("run with directory db = %v, want open store error", err)
}
}
+787
View File
@@ -0,0 +1,787 @@
package main
// spawner_test.go — clone→build→create/start pipeline, git failures, env wiring.
import (
"archive/tar"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/docker/docker/client"
)
func TestSpawnerStartHappyPath(t *testing.T) {
gitLog := useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "main")
if err != nil {
t.Fatalf("Start: %v", err)
}
if res.SessionID == "" || res.ContainerID != "" {
t.Fatalf("Start result = %+v, want sessionId set and empty containerId", res)
}
job := waitJobState(t, sp, res.SessionID, stateRunning)
if job.Repo != "group/project" || job.ContainerID == "" {
t.Fatalf("running job = %+v, want containerId set", job)
}
// clone URL carries the PAT only when none is stored (it isn't here)
calls := readGitLog(t, gitLog)
if len(calls) != 1 || !strings.HasPrefix(calls[0], "clone --branch main -- https://gitlab.example/group/project.git ") {
t.Fatalf("git calls = %v", calls)
}
// container created with the right image, env, labels, mounts, network
creates := f.createsByName("lvmh-agent-")
if len(creates) != 1 {
t.Fatalf("agent containers created = %+v", creates)
}
c := creates[0]
if c.Image != imageRefWorker {
t.Fatalf("image = %q", c.Image)
}
if c.Labels[labelSession] != res.SessionID {
t.Fatalf("labels = %v", c.Labels)
}
wantEnv := map[string]bool{
envProviderAPIKey + "=key-123": true,
envToken + "=" + testToken: true,
"LVMH_URL=" + defaultContainerURL: true,
envLVMHSessionID + "=" + res.SessionID: true,
envLVMHAgent + "=1": true,
envLVMHRepo + "=group/project": true,
}
for _, e := range c.Env {
if !wantEnv[e] {
t.Fatalf("unexpected env %q in %v", e, c.Env)
}
delete(wantEnv, e)
}
if len(wantEnv) != 0 {
t.Fatalf("missing env %v", wantEnv)
}
wantBinds := []string{
"lvmh-repo-group-project:" + workspaceMount,
volumeSessions + ":" + sessionsMount,
}
if len(c.HostConfig.Binds) != 2 {
t.Fatalf("binds = %v, want %v (no models file)", c.HostConfig.Binds, wantBinds)
}
for i, b := range wantBinds {
if c.HostConfig.Binds[i] != b {
t.Fatalf("binds = %v, want %v", c.HostConfig.Binds, wantBinds)
}
}
if c.HostConfig.NetworkMode != defaultNetwork {
t.Fatalf("network = %q", c.HostConfig.NetworkMode)
}
// fresh repo volume seeded from the clone via a one-shot container
seed := f.createsByName("lvmh-seed-")
if len(seed) != 1 {
t.Fatalf("seed containers = %+v", seed)
}
if seed[0].Image != imageRefWorker || len(seed[0].Cmd) == 0 || !strings.Contains(seed[0].Cmd[len(seed[0].Cmd)-1], workspaceMount) {
t.Fatalf("seed create = %+v", seed[0])
}
if !strings.HasPrefix(seed[0].HostConfig.Binds[1], sp.reposDir) {
t.Fatalf("seed binds = %v, want clone dir mounted at /src", seed[0].HostConfig.Binds)
}
// no build expected (fake daemon already has the image)
if f.hasCall(http.MethodPost, "/build") {
t.Fatal("image present but build was called")
}
// session volume created too
if !f.volumeExists(volumeSessions) {
t.Fatalf("volume %q missing", volumeSessions)
}
// container row persisted
row, ok, err := store.GetContainer(res.SessionID)
if err != nil || !ok || row.Repo != "group/project" {
t.Fatalf("container row = %+v ok=%v err=%v", row, ok, err)
}
f.assertNoUnknown(t)
}
func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.images = 0 // image absent → ensureImage must build
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
builds := f.countCalls(http.MethodPost, "/build")
if builds != 1 {
t.Fatalf("build calls = %d, want 1", builds)
}
f.assertNoUnknown(t)
}
func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
f := newFakeDocker()
f.images = 0
sp, _ := newTestSpawner(t, f) // dockerfile exists in the fake context
// docker reachable but image absent and no Dockerfile anywhere → clear error
t.Setenv(envWorkerDockerfile, filepath.Join(t.TempDir(), "missing.Dockerfile"))
sp2, err := NewSpawner(sp.store, sp.hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
_, err = sp2.Start(context.Background(), "group/project", "")
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
t.Fatalf("Start without dockerfile err = %v", err)
}
if len(sp2.JobsSnapshot()) != 0 {
t.Fatalf("failed Start must not leave a job: %+v", sp2.JobsSnapshot())
}
}
func TestSpawnerRunJobErrorStates(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, f *fakeDocker)
gitMode string
wantMsg string
}{
{
name: "clone-fails",
gitMode: fakeGitModeFail,
wantMsg: "fatal: repository not found",
},
{
name: "noisy-clone-failure-truncated-to-tail",
gitMode: fakeGitModeNoisy,
wantMsg: "xxx",
},
{
name: "build-fails",
setup: func(t *testing.T, f *fakeDocker) {
f.images = 0
f.failBuild = true
},
wantMsg: "build exploded",
},
{
name: "create-fails",
setup: func(t *testing.T, f *fakeDocker) {
f.failCreate = true
},
wantMsg: "create failed",
},
{
name: "start-fails-and-cleans-up",
setup: func(t *testing.T, f *fakeDocker) {
f.failStart = true
},
wantMsg: "start failed",
},
{
name: "volume-create-fails",
setup: func(t *testing.T, f *fakeDocker) {
f.failVolumeCreate = true
},
wantMsg: "volume create failed",
},
{
name: "seed-wait-fails",
setup: func(t *testing.T, f *fakeDocker) {
f.failWait = true
},
wantMsg: "wait failed",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gitMode := tc.gitMode
if gitMode == "" {
gitMode = fakeGitModeOK
}
useFakeGit(t, gitMode)
f := newFakeDocker()
if tc.setup != nil {
tc.setup(t, f)
}
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
job := waitJobState(t, sp, res.SessionID, stateError)
if !strings.Contains(job.Message, tc.wantMsg) {
t.Fatalf("job message = %q, want containing %q", job.Message, tc.wantMsg)
}
if job.ContainerID != "" {
t.Fatalf("error job has containerId %q", job.ContainerID)
}
})
}
}
func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
gitLog := useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
// existing clone → pull --ff-only in that dir, no clone
dir := filepath.Join(sp.reposDir, "group-project")
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err != nil {
t.Fatalf("cloneOrUpdate: %v", err)
}
calls := readGitLog(t, gitLog)
if len(calls) != 1 || calls[0] != "pull --ff-only" {
t.Fatalf("git calls = %v, want pull --ff-only", calls)
}
}
func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if u, err := sp.cloneURL("group/project"); err != nil || u != "https://gitlab.example/group/project.git" {
t.Fatalf("cloneURL without PAT = %q err=%v", u, err)
}
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
u, err := sp.cloneURL("group/project")
if err != nil {
t.Fatalf("cloneURL: %v", err)
}
if u != "https://oauth2:pat-1@gitlab.example/group/project.git" {
t.Fatalf("cloneURL with PAT = %q", u)
}
}
func TestSpawnerModelsBindWhenPresent(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
models := filepath.Join(t.TempDir(), "models.json")
if err := os.WriteFile(models, []byte("{}"), 0o644); err != nil {
t.Fatalf("write models: %v", err)
}
sp.modelsPath = models
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
creates := f.createsByName("lvmh-agent-")
if len(creates) != 1 {
t.Fatalf("creates = %d", len(creates))
}
roFound := false
for _, b := range creates[0].HostConfig.Binds {
if b == models+":"+modelsMountTarget+":ro" {
roFound = true
}
}
if !roFound {
t.Fatalf("models read-only bind missing: %v", creates[0].HostConfig.Binds)
}
}
func TestSpawnerRemoveSession(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if err := sp.RemoveSession(context.Background(), "nope"); err != errNoContainer {
t.Fatalf("remove unknown = %v, want errNoContainer", err)
}
if err := store.UpsertContainer("s1", "cid-9", "group/project"); err != nil {
t.Fatalf("upsert container: %v", err)
}
sp.setJob("s1", "group/project", stateRunning, "cid-9", "")
if err := sp.RemoveSession(context.Background(), "s1"); err != nil {
t.Fatalf("RemoveSession: %v", err)
}
if !f.hasCall(http.MethodPost, "/containers/cid-9/stop") || !f.hasCall(http.MethodDelete, "/containers/cid-9") {
t.Fatal("container not stopped+removed")
}
if _, ok, _ := store.GetContainer("s1"); ok {
t.Fatal("container row must be deleted")
}
for _, j := range sp.JobsSnapshot() {
if j.SessionID == "s1" && j.State != stateError {
t.Fatalf("job after removal = %+v, want error state", j)
}
}
// stop failure surfaces, row kept
if err := store.UpsertContainer("s2", "cid-10", "group/project"); err != nil {
t.Fatalf("upsert: %v", err)
}
f.failStop = true
if err := sp.RemoveSession(context.Background(), "s2"); err == nil || !strings.Contains(err.Error(), "docker stop") {
t.Fatalf("remove with stop failure = %v", err)
}
f.assertNoUnknown(t)
}
func TestSpawnerSeedVolumeWaitContextCancel(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.waitHang = true // server never answers wait
sp, _ := newTestSpawner(t, f)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project") }()
waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPost, "/wait") })
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("seedVolume after cancel = %v, want context.Canceled", err)
}
case <-time.After(5 * time.Second):
t.Fatal("seedVolume did not return after context cancel")
}
}
func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
l1 := sp.slugLock("a")
l2 := sp.slugLock("a")
if l1 != l2 {
t.Fatal("slugLock must return the same mutex per slug")
}
if sp.slugLock("b") == l1 {
t.Fatal("slugLock must return distinct mutexes per slug")
}
res1, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start 1: %v", err)
}
res2, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start 2: %v", err)
}
waitJobState(t, sp, res1.SessionID, stateRunning)
waitJobState(t, sp, res2.SessionID, stateRunning)
if len(f.createsByName("lvmh-agent-")) != 2 {
t.Fatalf("agent containers = %d, want 2", len(f.createsByName("lvmh-agent-")))
}
}
func TestRepoSlugAndUUID(t *testing.T) {
if got := repoSlug("a/b/c"); got != "a-b-c" {
t.Fatalf("repoSlug = %q", got)
}
id := newUUID()
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
t.Fatalf("newUUID shape = %q", id)
}
if id2 := newUUID(); id2 == id {
t.Fatal("newUUID must not repeat")
}
}
func TestEnvOr(t *testing.T) {
t.Setenv("LVMH_TEST_ENV_OR", " value ")
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "value" {
t.Fatalf("envOr trimmed = %q", got)
}
t.Setenv("LVMH_TEST_ENV_OR", " ")
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "def" {
t.Fatalf("envOr whitespace-only = %q", got)
}
if got := envOr("LVMH_TEST_ENV_OR_UNSET", "def"); got != "def" {
t.Fatalf("envOr unset = %q", got)
}
}
func TestExtractBuildError(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"empty", "", ""},
{"no-error", `{"stream":"Step 1/3"}` + "\n" + `{"stream":"done"}`, ""},
{"error-field", `{"error":"nope"}`, "nope"},
{"error-detail-wins", `{"errorDetail":{"message":"boom"},"error":"generic"}`, "boom"},
{"mixed-stream", "{\"stream\":\"...\"}\n{\"error\":\"late failure\"}", "late failure"},
{"non-json-line", "garbage line\n{\"error\":\"after garbage\"}", "after garbage"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractBuildError([]byte(tc.body)); got != tc.want {
t.Fatalf("extractBuildError(%q) = %q, want %q", tc.body, got, tc.want)
}
})
}
}
func TestTarDir(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "one.txt"), []byte("one"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "two.txt"), []byte("two"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
var buf bytes.Buffer
if err := tarDir(&buf, dir); err != nil {
t.Fatalf("tarDir: %v", err)
}
tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
names := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
body, _ := io.ReadAll(tr)
names[hdr.Name] = string(body)
if hdr.Uname != "root" || hdr.Gname != "root" {
t.Fatalf("header %q owner = %s/%s, want root/root", hdr.Name, hdr.Uname, hdr.Gname)
}
}
for name, want := range map[string]string{"one.txt": "one", "sub/two.txt": "two"} {
if names[name] != want {
t.Fatalf("archive missing %q (have %v)", name, names)
}
}
if _, ok := names["sub/"]; !ok {
t.Fatalf("directories must be archived with trailing slash: %v", names)
}
if err := tarDir(&buf, filepath.Join(dir, "does-not-exist")); err == nil {
t.Fatal("tarDir of missing dir must fail")
}
}
func TestGitRunSilentFailure(t *testing.T) {
useFakeGit(t, fakeGitModeSilent)
err := gitRun("", "clone", "x")
if err == nil || strings.Contains(err.Error(), "clone x") {
// silent failure surfaces the bare exec error, not a padded message
t.Fatalf("gitRun silent failure = %v", err)
}
}
func TestSpawnerNewBadDockerHost(t *testing.T) {
t.Setenv("DOCKER_HOST", "http://")
store := openTestStore(t)
if _, err := NewSpawner(store, NewHub(store), "https://gitlab.example"); err == nil {
t.Fatal("NewSpawner must fail on an unparseable DOCKER_HOST")
}
}
func TestSpawnerStartDockerUnavailable(t *testing.T) {
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
// point the spawner at a dead endpoint: last Start request kills nothing
// because the client is already built; close the fake server instead.
sp.cli.Close()
closeDocker(t, sp)
if _, err := sp.Start(context.Background(), "group/project", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("Start with dead docker = %v, want docker unavailable", err)
}
}
// closeDocker re-aims the spawner at a closed server (dial refused).
func closeDocker(t *testing.T, sp *Spawner) {
t.Helper()
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"dead"}`)
}))
dead.Close() // listener gone: connections refused
if err := sp.cli.Close(); err != nil {
t.Fatalf("close docker client: %v", err)
}
cli, err := client.NewClientWithOpts(client.WithHost("tcp://" + dead.Listener.Addr().String()))
if err != nil {
t.Fatalf("rebuild docker client: %v", err)
}
sp.cli = cli
}
func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
// unparseable repo path (control char) → cloneURL parse error
if err := sp.cloneOrUpdate("bad\nrepo", "", repoSlug("bad\nrepo")); err == nil {
t.Fatal("cloneOrUpdate with control-char repo must fail")
}
// reposDir path occupied by a file → MkdirAll fails
file := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(file, nil, 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
sp.reposDir = file
if err := sp.cloneOrUpdate("group/project", "", "group-project"); err == nil {
t.Fatal("cloneOrUpdate with file reposDir must fail")
}
}
func TestSpawnerEnsureImageErrors(t *testing.T) {
f := newFakeDocker()
f.images = 0
sp, _ := newTestSpawner(t, f)
ctx := context.Background()
// dockerfile missing on disk
sp.dockerfile = filepath.Join(t.TempDir(), "gone.Dockerfile")
if err := sp.ensureImage(ctx); err == nil || !strings.Contains(err.Error(), "worker Dockerfile missing") {
t.Fatalf("ensureImage without dockerfile = %v", err)
}
// dockerfile exists but cannot be made relative to the build context
sp.dockerfile = filepath.Join(t.TempDir(), "worker.Dockerfile")
if err := os.WriteFile(sp.dockerfile, []byte("FROM scratch"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
sp.buildContext = "relative-context"
if err := sp.ensureImage(ctx); err == nil {
t.Fatal("ensureImage with relative-context vs absolute dockerfile must fail")
}
// tar of a missing build context fails
sp.buildContext = filepath.Join(t.TempDir(), "no-such-context")
if err := sp.ensureImage(ctx); err == nil || !strings.Contains(err.Error(), "build context") {
t.Fatalf("ensureImage with missing context = %v", err)
}
}
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group-project"] = true // repo volume exists → skip seed
f.failVolumeCreate = true
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
job := waitJobState(t, sp, res.SessionID, stateError)
if !strings.Contains(job.Message, volumeSessions) {
t.Fatalf("job message = %q, want sessions volume failure", job.Message)
}
}
func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
ctx := context.Background()
f.failCreate = true
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
t.Fatal("seedVolume with failing create must fail")
}
f.failCreate = false
f.failStart = true
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
t.Fatal("seedVolume with failing start must fail")
}
}
func TestSpawnerStoreFailures(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
// closed store: GetContainer errors
deadStore := openTestStore(t)
_ = deadStore.Close()
sp.store = deadStore
if err := sp.RemoveSession(context.Background(), "s1"); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("RemoveSession with closed store = %v", err)
}
// runJob completes container start but cannot persist the row
sp2, _ := newTestSpawner(t, newFakeDocker())
dead2 := openTestStore(t)
_ = dead2.Close()
sp2.store = dead2
res, err := sp2.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
job := waitJobState(t, sp2, res.SessionID, stateError)
if !strings.Contains(job.Message, "not persisted") {
t.Fatalf("job message = %q, want persistence failure", job.Message)
}
}
func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
useFakeGit(t, fakeGitModeFail)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
dir := filepath.Join(sp.reposDir, "group-project")
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project"))
if err == nil || !strings.Contains(err.Error(), "git pull") {
t.Fatalf("cloneOrUpdate pull failure = %v, want git pull error", err)
}
}
func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
// repo volume pre-exists → seeding skipped → failures surface at the
// worker container create/start instead of at the seed container.
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group-project"] = true
sp, _ := newTestSpawner(t, f)
f.failCreate = true
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
if job := waitJobState(t, sp, res.SessionID, stateError); !strings.Contains(job.Message, "docker create") {
t.Fatalf("job = %+v, want docker create failure", job)
}
f.failCreate = false
f.failStart = true
res2, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start 2: %v", err)
}
if job := waitJobState(t, sp, res2.SessionID, stateError); !strings.Contains(job.Message, "docker start") {
t.Fatalf("job = %+v, want docker start failure", job)
}
// failed worker start must remove the created container
if f.countCalls(http.MethodDelete, "/containers/") == 0 {
t.Fatal("failed start must clean up the created container")
}
}
func TestSpawnerBuildHTTPErrors(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.images = 0
f.failBuildHTTP = true // /build endpoint itself 500s
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
if job := waitJobState(t, sp, res.SessionID, stateError); !strings.Contains(job.Message, "docker build") {
t.Fatalf("job = %+v, want docker build failure", job)
}
// dead docker endpoint: ensureImage cannot even list images
sp2, _ := newTestSpawner(t, newFakeDocker())
sp2.images0AndDead(t)
if err := sp2.ensureImage(context.Background()); err == nil {
t.Fatal("ensureImage with dead docker must fail")
}
}
func (sp *Spawner) images0AndDead(t *testing.T) {
t.Helper()
dead := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(dead.Close)
dead.Close()
if err := sp.cli.Close(); err != nil {
t.Fatalf("close client: %v", err)
}
cli, err := client.NewClientWithOpts(client.WithHost("tcp://" + dead.Listener.Addr().String()))
if err != nil {
t.Fatalf("client: %v", err)
}
sp.cli = cli
}
// failingWriter errors on the Nth write onward.
type failingWriter struct {
n, failAt int
}
func (w *failingWriter) Write(p []byte) (int, error) {
w.n++
if w.n >= w.failAt {
return 0, fmt.Errorf("write boom %d", w.n)
}
return len(p), nil
}
func TestTarDirWriteErrors(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("content"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := tarDir(&failingWriter{failAt: 1}, dir); err == nil {
t.Fatal("tarDir into failing writer must fail")
}
if err := tarDir(&failingWriter{failAt: 4}, dir); err == nil {
t.Fatal("tarDir copy into failing writer must fail")
}
}
func TestTarDirUnreadableFile(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores file permissions")
}
dir := t.TempDir()
secret := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chmod(secret, 0o000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(secret, 0o644) })
var buf bytes.Buffer
if err := tarDir(&buf, dir); err == nil {
t.Fatal("tarDir with unreadable file must fail")
}
}
func TestGitRunUnderivableExit(t *testing.T) {
// gitRun is the exec seam: with real git present, invoking a nonexistent
// subcommand must surface stderr, and trimming applies to long output.
if err := gitRun("", "version"); err != nil {
t.Fatalf("git version: %v", err)
}
err := gitRun("", "this-subcommand-does-not-exist")
if err == nil || !strings.Contains(err.Error(), "this-subcommand-does-not-exist") {
t.Fatalf("gitRun unknown subcommand = %v", err)
}
}
+102
View File
@@ -0,0 +1,102 @@
package main
// store_extra_test.go — open failure, closed-handle error branches, corrupt rows.
import (
"path/filepath"
"strings"
"testing"
)
func TestOpenStoreBadPath(t *testing.T) {
if _, err := OpenStore(filepath.Join(t.TempDir(), "occupied.db", "x.db")); err == nil {
t.Fatal("OpenStore under a file path must fail")
}
}
func TestStoreClosedHandleErrors(t *testing.T) {
store := openTestStore(t)
if err := store.Close(); err != nil {
t.Fatalf("close: %v", err)
}
errStr := func(err error) string {
if err == nil {
return ""
}
return err.Error()
}
if err := store.AppendEvent(Event{SessionID: "s", Seq: 1, Type: evAgentSettled}); errStr(err) == "" {
t.Fatal("AppendEvent on closed store must error")
}
if _, err := store.LastSeq("s"); errStr(err) == "" {
t.Fatal("LastSeq on closed store must error")
}
if _, err := store.EventsAfter("s", 0, 10); errStr(err) == "" {
t.Fatal("EventsAfter on closed store must error")
}
if err := store.UpsertSession(SessionInfo{ID: "s"}); errStr(err) == "" {
t.Fatal("UpsertSession on closed store must error")
}
if err := store.TouchSession("s", 1, 1); errStr(err) == "" {
t.Fatal("TouchSession on closed store must error")
}
if err := store.SetOnline("s", true); errStr(err) == "" {
t.Fatal("SetOnline on closed store must error")
}
if _, err := store.Sessions(); errStr(err) == "" {
t.Fatal("Sessions on closed store must error")
}
if _, _, err := store.GetSetting("k"); errStr(err) == "" {
t.Fatal("GetSetting on closed store must error")
}
if err := store.SetSetting("k", "v"); errStr(err) == "" {
t.Fatal("SetSetting on closed store must error")
}
if err := store.DeleteSetting("k"); errStr(err) == "" {
t.Fatal("DeleteSetting on closed store must error")
}
if err := store.UpsertContainer("s", "c", "r"); errStr(err) == "" {
t.Fatal("UpsertContainer on closed store must error")
}
if _, _, err := store.GetContainer("s"); errStr(err) == "" {
t.Fatal("GetContainer on closed store must error")
}
if err := store.DeleteContainer("s"); errStr(err) == "" {
t.Fatal("DeleteContainer on closed store must error")
}
}
func TestStoreCorruptRows(t *testing.T) {
store := openTestStore(t)
// undecodable info blob → Sessions errors
if _, err := store.db.Exec(`INSERT INTO sessions(id, info) VALUES('bad', 'not json')`); err != nil {
t.Fatalf("insert corrupt session: %v", err)
}
if _, err := store.Sessions(); err == nil || !strings.Contains(err.Error(), "decode session info") {
t.Fatalf("Sessions with corrupt info = %v", err)
}
// non-integer seq → EventsAfter scan error
if _, err := store.db.Exec(`INSERT INTO events(sessionId, seq, ts, type, payload)
VALUES('s1', 'not-a-number', 1, 'x', '{}')`); err != nil {
t.Fatalf("insert corrupt event: %v", err)
}
if _, err := store.EventsAfter("s1", 0, 10); err == nil {
t.Fatal("EventsAfter with text seq must scan-error")
}
}
func TestStoreAppendEventDefaultPayload(t *testing.T) {
store := openTestStore(t)
if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 1, Type: evBye}); err != nil {
t.Fatalf("append empty payload: %v", err)
}
events, err := store.EventsAfter("s1", 0, 10)
if err != nil || len(events) != 1 {
t.Fatalf("events = %v %v", events, err)
}
if string(events[0].Payload) != "{}" {
t.Fatalf("payload = %q, want {}", events[0].Payload)
}
}