package main // pi_config_test.go — resync endpoint wiring: auth, conflict-while-running, // missing dotfiles mount, missing script. import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "testing" ) func TestAPIPiConfigResync(t *testing.T) { ts, _ := newTestServer(t) auth := testToken if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", "", ""); code != http.StatusUnauthorized { t.Fatalf("resync without token = %d, want 401", code) } // script missing in test env (path is /app/build/...): 500 before any work t.Setenv(envPiDotfilesMount, t.TempDir()) code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", auth, "") if code != http.StatusInternalServerError && code != http.StatusBadRequest { t.Fatalf("resync with no settings/script = %d %s", code, body) } } func TestPiSyncStateMutex(t *testing.T) { piSyncState.mu.Lock() if piSyncState.running { piSyncState.mu.Unlock() t.Fatal("running must start false") } piSyncState.running = true piSyncState.mu.Unlock() // emulate handler guard piSyncState.mu.Lock() again := piSyncState.running piSyncState.mu.Unlock() if !again { t.Fatal("guard must observe running") } piSyncState.mu.Lock() piSyncState.running = false piSyncState.mu.Unlock() } func TestCopyTreeSmoke(t *testing.T) { src := t.TempDir() dst := t.TempDir() + "/out" if err := os.WriteFile(src+"/f.txt", []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := copyTree(src, dst); err != nil { t.Fatalf("copyTree: %v", err) } if _, err := os.Stat(dst + "/f.txt"); err != nil { t.Fatalf("copied file missing: %v", err) } } func TestPiConfigResyncHappyPathStub(t *testing.T) { // full resync against a stub script + fake docker + writable dirs useFakeGit(t, fakeGitModeOK) f := newFakeDocker() ts := f.server(t) t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) root := t.TempDir() src := filepath.Join(root, "dotfiles") bake := filepath.Join(root, "bake") repo := filepath.Join(root, "repo") for _, d := range []string{src, bake, filepath.Join(repo, "docker")} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte(`{"packages":[]}`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } script := filepath.Join(root, "sync.sh") if err := os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\ncp \"$LVMH_PI_AGENT_DIR/settings.json\" \"$LVMH_PI_BAKE_DIR/\"\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv(envPiDotfilesMount, src) t.Setenv("LVMH_PI_BAKE_DIR", bake) daemonToken = testToken store := openTestStore(t) hub := NewHub(store) sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example") if err != nil { t.Fatal(err) } srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} api := httptest.NewServer(srv.Routes("")) t.Cleanup(api.Close) // point the handler at the stub script + repo layout t.Setenv("LVMH_TEST_SYNC_SCRIPT", script) t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "bake-ctx")) t.Setenv("LVMH_TEST_BUILD_REPO", repo) if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusOK { t.Fatalf("resync = %d %s", code, body) } if !f.hasCall(http.MethodPost, "/build") { t.Fatal("image builds not issued") } } func TestPiConfigResyncConflictWhileRunning(t *testing.T) { ts, _ := newTestServer(t) piSyncState.mu.Lock() piSyncState.running = true piSyncState.mu.Unlock() t.Cleanup(func() { piSyncState.mu.Lock() piSyncState.running = false piSyncState.mu.Unlock() }) if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusConflict { t.Fatalf("resync while running = %d %s, want 409", code, body) } } func TestCopyTreeError(t *testing.T) { if err := copyTree(t.TempDir()+"/nope", t.TempDir()+"/dst"); err == nil { t.Fatal("copyTree of missing src must fail") } } func TestPiConfigResyncFailurePaths(t *testing.T) { ts, _ := newTestServer(t) // 1: dotfiles mount without settings.json -> 400 empty := t.TempDir() t.Setenv(envPiDotfilesMount, empty) t.Setenv("LVMH_TEST_SYNC_SCRIPT", "/nonexistent-sync.sh") if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusBadRequest { t.Fatalf("no settings = %d %s, want 400", code, body) } // 2: settings present, script missing -> 500 if err := os.WriteFile(filepath.Join(empty, "settings.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("missing script = %d %s, want 500", code, body) } // 3: script fails -> 500 sync failed root := t.TempDir() src := filepath.Join(root, "src") if err := os.MkdirAll(src, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } failScript := filepath.Join(root, "fail.sh") if err := os.WriteFile(failScript, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv(envPiDotfilesMount, src) t.Setenv("LVMH_TEST_SYNC_SCRIPT", failScript) if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("failing script = %d %s, want 500", code, body) } } func TestPiConfigResyncBuildFailure(t *testing.T) { useFakeGit(t, fakeGitModeOK) f := newFakeDocker() f.failBuildHTTP = true ts := f.server(t) t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) root := t.TempDir() src := filepath.Join(root, "src") repo := filepath.Join(root, "repo") bake := filepath.Join(root, "bake") for _, d := range []string{src, bake, filepath.Join(repo, "docker")} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } script := filepath.Join(root, "ok.sh") if err := os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv(envPiDotfilesMount, src) t.Setenv("LVMH_TEST_SYNC_SCRIPT", script) t.Setenv("LVMH_PI_BAKE_DIR", bake) t.Setenv("LVMH_TEST_BUILD_REPO", repo) t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "ctx")) daemonToken = testToken store := openTestStore(t) hub := NewHub(store) sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example") if err != nil { t.Fatal(err) } srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} api := httptest.NewServer(srv.Routes("")) t.Cleanup(api.Close) if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("build failure = %d %s, want 500", code, body) } } func mustWrite(t *testing.T, path, content string, mode os.FileMode) string { t.Helper() if err := os.WriteFile(path, []byte(content), mode); err != nil { t.Fatal(err) } return path } func TestPiConfigResyncContextFailureArms(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "src") if err := os.MkdirAll(src, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } okScript := filepath.Join(root, "ok.sh") if err := os.WriteFile(okScript, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\n"), 0o755); err != nil { t.Fatal(err) } // repo missing control.Dockerfile only → copyTree ok, rename ok, worker ok (scratch), ops dockerfile missing → buildImage fails useFakeGit(t, fakeGitModeOK) f := newFakeDocker() ts := f.server(t) t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) daemonToken = testToken store := openTestStore(t) hub := NewHub(store) sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example") if err != nil { t.Fatal(err) } srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} api := httptest.NewServer(srv.Routes("")) t.Cleanup(api.Close) repo := filepath.Join(root, "repo") bake := filepath.Join(root, "bake") for _, d := range []string{bake, filepath.Join(repo, "docker")} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatal(err) } // rename arm: bake dir absent → os.Rename fails t.Setenv("LVMH_TEST_SYNC_SCRIPT", mustWrite(t, filepath.Join(root, "noop.sh"), "#!/bin/sh\nexit 0\n", 0o755)) t.Setenv(envPiDotfilesMount, src) t.Setenv("LVMH_PI_BAKE_DIR", filepath.Join(root, "no-such-bake")) t.Setenv("LVMH_TEST_BUILD_REPO", repo) t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "ctx")) if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("ops missing = %d %s, want 500", code, body) } } func TestPiConfigDefaultScriptPathBranch(t *testing.T) { ts, _ := newTestServer(t) root := t.TempDir() src := filepath.Join(root, "src") if err := os.MkdirAll(src, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } t.Setenv(envPiDotfilesMount, src) t.Setenv("LVMH_TEST_SYNC_SCRIPT", "") // default branch → missing real script path → 500 if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("default script missing = %d %s, want 500", code, body) } }