diff --git a/internal/workload/plugin/source/static/build_test.go b/internal/workload/plugin/source/static/build_test.go
new file mode 100644
index 0000000..718346d
--- /dev/null
+++ b/internal/workload/plugin/source/static/build_test.go
@@ -0,0 +1,174 @@
+package static
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestCopyDir_CopiesRegularFiles(t *testing.T) {
+ src := t.TempDir()
+ dst := filepath.Join(t.TempDir(), "out")
+
+ if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil {
+ t.Fatalf("mkdir sub: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("
hi
"), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "sub", "nested.txt"), []byte("nested"), 0o644); err != nil {
+ t.Fatalf("write nested: %v", err)
+ }
+
+ if err := copyDir(src, dst); err != nil {
+ t.Fatalf("copyDir: %v", err)
+ }
+
+ gotIndex, err := os.ReadFile(filepath.Join(dst, "index.html"))
+ if err != nil {
+ t.Fatalf("read index: %v", err)
+ }
+ if string(gotIndex) != "hi
" {
+ t.Errorf("index content = %q", string(gotIndex))
+ }
+ gotNested, err := os.ReadFile(filepath.Join(dst, "sub", "nested.txt"))
+ if err != nil {
+ t.Fatalf("read nested: %v", err)
+ }
+ if string(gotNested) != "nested" {
+ t.Errorf("nested content = %q", string(gotNested))
+ }
+}
+
+func TestCopyDir_PreservesFileMode(t *testing.T) {
+ // File modes are only meaningful outside Windows — Windows reports
+ // 0666 for any writable file regardless of the source mode.
+ if runtime.GOOS == "windows" {
+ t.Skip("file modes are not preserved meaningfully on Windows")
+ }
+ src := t.TempDir()
+ dst := filepath.Join(t.TempDir(), "out")
+
+ if err := os.WriteFile(filepath.Join(src, "script.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ if err := copyDir(src, dst); err != nil {
+ t.Fatalf("copyDir: %v", err)
+ }
+
+ info, err := os.Stat(filepath.Join(dst, "script.sh"))
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if info.Mode().Perm() != 0o755 {
+ t.Errorf("mode = %v, want 0755", info.Mode().Perm())
+ }
+}
+
+func TestCopyDir_RejectsSymlinks(t *testing.T) {
+ src := t.TempDir()
+ dst := filepath.Join(t.TempDir(), "out")
+
+ target := filepath.Join(src, "real.txt")
+ if err := os.WriteFile(target, []byte("real"), 0o644); err != nil {
+ t.Fatalf("write target: %v", err)
+ }
+ if err := os.Symlink(target, filepath.Join(src, "link.txt")); err != nil {
+ // Windows non-admin users cannot create symlinks. The defense
+ // is still valuable on Linux, so just skip when unsupported.
+ t.Skipf("symlink not supported in this environment: %v", err)
+ }
+
+ err := copyDir(src, dst)
+ if err == nil {
+ t.Fatal("copyDir accepted a symlink; expected refusal")
+ }
+ if !strings.Contains(err.Error(), "non-regular") {
+ t.Errorf("error = %v, want substring \"non-regular\"", err)
+ }
+}
+
+func TestVerifyDownloadInsideRoot_AcceptsCleanTree(t *testing.T) {
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "sub", "file.txt"), []byte("ok"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ if err := verifyDownloadInsideRoot(root); err != nil {
+ t.Fatalf("verifyDownloadInsideRoot rejected clean tree: %v", err)
+ }
+}
+
+func TestVerifyDownloadInsideRoot_RejectsSymlink(t *testing.T) {
+ root := t.TempDir()
+ target := filepath.Join(t.TempDir(), "outside.txt")
+ if err := os.WriteFile(target, []byte("outside"), 0o644); err != nil {
+ t.Fatalf("write target: %v", err)
+ }
+ if err := os.Symlink(target, filepath.Join(root, "escape.txt")); err != nil {
+ t.Skipf("symlink not supported in this environment: %v", err)
+ }
+
+ err := verifyDownloadInsideRoot(root)
+ if err == nil {
+ t.Fatal("verifyDownloadInsideRoot accepted symlink; expected refusal")
+ }
+ if !strings.Contains(err.Error(), "non-regular") {
+ t.Errorf("error = %v, want substring \"non-regular\"", err)
+ }
+}
+
+func TestVerifyDownloadInsideRoot_MissingRootSurfacesError(t *testing.T) {
+ err := verifyDownloadInsideRoot(filepath.Join(t.TempDir(), "does-not-exist"))
+ if err == nil {
+ t.Fatal("expected error for missing root, got nil")
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("error = %v, want os.ErrNotExist in chain", err)
+ }
+}
+
+func TestPrepareStaticBuild_WritesDockerfileAndCopiesFiles(t *testing.T) {
+ src := t.TempDir()
+ ctxDir := filepath.Join(t.TempDir(), "ctx")
+
+ if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("hello"), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+
+ if err := prepareStaticBuild(src, ctxDir); err != nil {
+ t.Fatalf("prepareStaticBuild: %v", err)
+ }
+
+ if _, err := os.Stat(filepath.Join(ctxDir, "Dockerfile")); err != nil {
+ t.Fatalf("Dockerfile missing: %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join(ctxDir, "index.html"))
+ if err != nil {
+ t.Fatalf("read copied index: %v", err)
+ }
+ if string(got) != "hello" {
+ t.Errorf("copied index content = %q", string(got))
+ }
+}
+
+func TestPrepareStaticBuild_EmptySrcStillWritesDockerfile(t *testing.T) {
+ // An empty repo folder shouldn't crash the build — nginx will just
+ // serve an empty image. The Dockerfile must still land.
+ src := t.TempDir()
+ ctxDir := filepath.Join(t.TempDir(), "ctx")
+
+ if err := prepareStaticBuild(src, ctxDir); err != nil {
+ t.Fatalf("prepareStaticBuild: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(ctxDir, "Dockerfile")); err != nil {
+ t.Fatalf("Dockerfile missing: %v", err)
+ }
+}
diff --git a/internal/workload/plugin/source/static/helpers_test.go b/internal/workload/plugin/source/static/helpers_test.go
new file mode 100644
index 0000000..1ac28a3
--- /dev/null
+++ b/internal/workload/plugin/source/static/helpers_test.go
@@ -0,0 +1,310 @@
+package static
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/alexei/tinyforge/internal/workload/plugin"
+)
+
+func TestIdShort_TruncatesLongID(t *testing.T) {
+ got := idShort(plugin.Workload{ID: "abcd1234-5678-1234-abcd-deadbeef0000"})
+ if got != "abcd1234" {
+ t.Fatalf("idShort = %q, want %q", got, "abcd1234")
+ }
+}
+
+func TestIdShort_ShortIDPassesThrough(t *testing.T) {
+ // IDs shorter than 8 chars must not panic on slicing.
+ got := idShort(plugin.Workload{ID: "abc"})
+ if got != "abc" {
+ t.Fatalf("idShort = %q, want %q", got, "abc")
+ }
+}
+
+func TestIdShort_ExactlyEightChars(t *testing.T) {
+ got := idShort(plugin.Workload{ID: "12345678"})
+ if got != "12345678" {
+ t.Fatalf("idShort = %q, want %q", got, "12345678")
+ }
+}
+
+func TestContainerNameFor_Shape(t *testing.T) {
+ w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"}
+ got := containerNameFor(w)
+ if got != "dw-site-mysite-abcd1234" {
+ t.Fatalf("containerNameFor = %q, want %q", got, "dw-site-mysite-abcd1234")
+ }
+}
+
+func TestImageTagFor_Shape(t *testing.T) {
+ w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"}
+ got := imageTagFor(w)
+ if got != "dw-site-mysite-abcd1234:latest" {
+ t.Fatalf("imageTagFor = %q, want %q", got, "dw-site-mysite-abcd1234:latest")
+ }
+}
+
+func TestSiteVolumeKey_Shape(t *testing.T) {
+ w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"}
+ got := siteVolumeKey(w)
+ if got != "mysite-abcd1234" {
+ t.Fatalf("siteVolumeKey = %q, want %q", got, "mysite-abcd1234")
+ }
+}
+
+func TestNaming_TwoWorkloadsSameNameGetDifferentResources(t *testing.T) {
+ // Workload names are not UNIQUE in the schema; the ID short suffix
+ // is the only thing keeping two same-named workloads from clobbering
+ // each other's container / image / volume.
+ a := plugin.Workload{ID: "aaaaaaaa-rest", Name: "site"}
+ b := plugin.Workload{ID: "bbbbbbbb-rest", Name: "site"}
+
+ if containerNameFor(a) == containerNameFor(b) {
+ t.Errorf("container names collide for same-named workloads: %q", containerNameFor(a))
+ }
+ if imageTagFor(a) == imageTagFor(b) {
+ t.Errorf("image tags collide for same-named workloads: %q", imageTagFor(a))
+ }
+ if siteVolumeKey(a) == siteVolumeKey(b) {
+ t.Errorf("volume keys collide for same-named workloads: %q", siteVolumeKey(a))
+ }
+}
+
+func TestSanitizeError_Empty(t *testing.T) {
+ if got := sanitizeError("", "tok"); got != "" {
+ t.Fatalf("sanitizeError(\"\") = %q, want \"\"", got)
+ }
+}
+
+func TestSanitizeError_CollapsesWhitespace(t *testing.T) {
+ got := sanitizeError("line1\nline2\rline3\tline4", "")
+ if strings.ContainsAny(got, "\n\r\t") {
+ t.Fatalf("sanitizeError did not collapse whitespace: %q", got)
+ }
+ if got != "line1 line2 line3 line4" {
+ t.Fatalf("sanitizeError = %q, want %q", got, "line1 line2 line3 line4")
+ }
+}
+
+func TestSanitizeError_RedactsAccessToken(t *testing.T) {
+ tok := "ghp_supersecrettoken"
+ msg := "401 from gitea using token=" + tok + " ok"
+ got := sanitizeError(msg, tok)
+ if strings.Contains(got, tok) {
+ t.Fatalf("sanitizeError leaked token: %q", got)
+ }
+ if !strings.Contains(got, "[REDACTED]") {
+ t.Fatalf("sanitizeError missing [REDACTED] marker: %q", got)
+ }
+}
+
+func TestSanitizeError_EmptyTokenIsNoOp(t *testing.T) {
+ // An empty token must not redact arbitrary substrings (a naive
+ // ReplaceAll with "" splits the string by every byte boundary).
+ msg := "auth failed"
+ got := sanitizeError(msg, "")
+ if got != msg {
+ t.Fatalf("sanitizeError(msg, \"\") = %q, want %q", got, msg)
+ }
+}
+
+func TestSanitizeError_TruncatesLongInput(t *testing.T) {
+ // 240-byte cap from the implementation.
+ long := strings.Repeat("a", 1000)
+ got := sanitizeError(long, "")
+ if !strings.HasSuffix(got, "…") {
+ t.Fatalf("sanitizeError did not append ellipsis: ...%s", got[max(0, len(got)-20):])
+ }
+ // 240 bytes of "a" plus the three-byte ellipsis rune.
+ if len(got) != 240+len("…") {
+ t.Fatalf("sanitizeError length = %d, want %d", len(got), 240+len("…"))
+ }
+}
+
+func TestSanitizeError_MultibyteRuneAtCutoff(t *testing.T) {
+ // The truncation slices at byte 240 — if a multi-byte rune straddles
+ // that boundary the output ends in a broken rune sequence. The
+ // implementation is byte-sliced today so this is more a "guard the
+ // expected behavior" test: the function must still produce valid
+ // UTF-8 on input that wasn't already broken, OR ship a known
+ // fix-needed test if the implementation changes. "é" is 2 bytes
+ // (C3 A9); 1000 of them = 2000 bytes well past the cap.
+ long := strings.Repeat("é", 1000)
+ got := sanitizeError(long, "")
+ if !strings.HasSuffix(got, "…") {
+ t.Fatalf("sanitizeError did not append ellipsis on multi-byte input: %q", got)
+ }
+ // Output must remain valid UTF-8 — a torn rune at the cap would
+ // fail this check. utf8.ValidString is the canonical guard.
+ if !utf8.ValidString(got) {
+ t.Errorf("sanitizeError produced invalid UTF-8 at byte cap: %q", got)
+ }
+}
+
+func TestSanitizeError_ShortInputUnchanged(t *testing.T) {
+ got := sanitizeError("short message", "")
+ if got != "short message" {
+ t.Fatalf("sanitizeError mangled short input: %q", got)
+ }
+}
+
+func TestContainerRowID_Deterministic(t *testing.T) {
+ w := plugin.Workload{ID: "abcd1234-rest"}
+ a := containerRowID(w)
+ b := containerRowID(w)
+ if a != b {
+ t.Fatalf("containerRowID not deterministic: %q vs %q", a, b)
+ }
+ if a != "abcd1234-rest:site" {
+ t.Fatalf("containerRowID = %q, want %q", a, "abcd1234-rest:site")
+ }
+}
+
+func TestLockFor_ReturnsSameLockForSameWorkload(t *testing.T) {
+ // Suffix by t.Name() so the package-global saveLocks map cannot
+ // bleed key state between tests (or between -count=N runs).
+ key := t.Name() + "-wid"
+ a := lockFor(key)
+ b := lockFor(key)
+ if a != b {
+ t.Fatalf("lockFor returned distinct locks for same workload: %p vs %p", a, b)
+ }
+}
+
+func TestLockFor_ReturnsDistinctLocksForDifferentWorkloads(t *testing.T) {
+ a := lockFor(t.Name() + "-a")
+ b := lockFor(t.Name() + "-b")
+ if a == b {
+ t.Fatalf("lockFor returned same lock for different workloads: %p", a)
+ }
+}
+
+func TestLockFor_SerializesConcurrentAcquisitions(t *testing.T) {
+ // Two goroutines holding the same lock must run sequentially. The
+ // counter would race past 2 if locking were broken; with the lock,
+ // the increment is observed monotonically.
+ lk := lockFor(t.Name() + "-wid")
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ counter int
+ peak int
+ )
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ lk.Lock()
+ defer lk.Unlock()
+
+ mu.Lock()
+ counter++
+ if counter > peak {
+ peak = counter
+ }
+ mu.Unlock()
+
+ mu.Lock()
+ counter--
+ mu.Unlock()
+ }()
+ }
+ wg.Wait()
+ if peak != 1 {
+ t.Fatalf("lockFor failed to serialize: peak in-flight = %d, want 1", peak)
+ }
+}
+
+func TestLockFor_ConcurrentMapAccessIsSafe(t *testing.T) {
+ // Distinct workloads acquired in parallel must not panic on map
+ // access — exercises the outer-mutex protection inside lockFor.
+ // Each iteration uses a unique key so the test stresses the
+ // insertion path (the common case for "first deploy" callers).
+ prefix := t.Name() + "-"
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ i := i
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ lk := lockFor(prefix + strconv.Itoa(i))
+ lk.Lock()
+ lk.Unlock()
+ }()
+ }
+ wg.Wait()
+}
+
+func TestRuntimeState_JSONTagsRoundTrip(t *testing.T) {
+ // saveState/loadState rely on these tag names to merge into the
+ // generic extra_json map without doubling keys.
+ in := runtimeState{
+ LastCommitSHA: "deadbeef",
+ LastSyncAt: "2026-05-16T00:00:00Z",
+ LastError: "nope",
+ Status: "deployed",
+ }
+ b, err := json.Marshal(in)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ // Decode into a generic map and assert it has the exact same key
+ // set as runtimeStateKeys. Renaming a JSON tag without updating
+ // runtimeStateKeys would break the "clearing a typed field removes
+ // the key" invariant; renaming/adding to runtimeStateKeys without
+ // changing a tag would silently drop a sibling key from extra_json.
+ // Both regressions fail this test.
+ gotKeys := map[string]bool{}
+ var asMap map[string]json.RawMessage
+ if err := json.Unmarshal(b, &asMap); err != nil {
+ t.Fatalf("unmarshal to map: %v", err)
+ }
+ for k := range asMap {
+ gotKeys[k] = true
+ }
+ wantKeys := map[string]bool{}
+ for _, k := range runtimeStateKeys {
+ wantKeys[k] = true
+ }
+ if len(gotKeys) != len(wantKeys) {
+ t.Errorf("runtimeStateKeys (%d) and JSON tag set (%d) differ in size: %v vs %v",
+ len(wantKeys), len(gotKeys), runtimeStateKeys, asMap)
+ }
+ for k := range wantKeys {
+ if !gotKeys[k] {
+ t.Errorf("runtimeStateKeys lists %q but JSON output has no such key: %v", k, asMap)
+ }
+ }
+ for k := range gotKeys {
+ if !wantKeys[k] {
+ t.Errorf("JSON output has %q but runtimeStateKeys does not: %v", k, runtimeStateKeys)
+ }
+ }
+
+ var out runtimeState
+ if err := json.Unmarshal(b, &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if out != in {
+ t.Fatalf("round-trip mismatch: %+v vs %+v", out, in)
+ }
+}
+
+func TestRuntimeState_OmitsEmptyFields(t *testing.T) {
+ // The struct uses `omitempty` everywhere so a freshly-created site
+ // with no recorded sync yet doesn't write a wall of empty strings
+ // into extra_json.
+ b, err := json.Marshal(runtimeState{})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(b) != "{}" {
+ t.Fatalf("zero runtimeState JSON = %q, want %q", string(b), "{}")
+ }
+}
diff --git a/internal/workload/plugin/source/static/state_integration_test.go b/internal/workload/plugin/source/static/state_integration_test.go
new file mode 100644
index 0000000..828f042
--- /dev/null
+++ b/internal/workload/plugin/source/static/state_integration_test.go
@@ -0,0 +1,412 @@
+package static
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/alexei/tinyforge/internal/crypto"
+ "github.com/alexei/tinyforge/internal/store"
+ "github.com/alexei/tinyforge/internal/workload/plugin"
+)
+
+// newTestStore opens an in-memory SQLite store. Mirrors the pattern in
+// internal/scheduler/scheduler_test.go.
+func newTestStore(t *testing.T) *store.Store {
+ t.Helper()
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { _ = st.Close() })
+ return st
+}
+
+func testDeps(t *testing.T) (plugin.Deps, [32]byte) {
+ t.Helper()
+ st := newTestStore(t)
+ var key [32]byte
+ for i := range key {
+ key[i] = byte(i + 1)
+ }
+ return plugin.Deps{Store: st, EncKey: key}, key
+}
+
+// seedWorkload inserts a minimal workload row so child tables with a
+// FK to workloads.id (workload_env, containers via workload_id) accept
+// inserts. Returns the row's ID so callers can use the same value for
+// child seeds.
+func seedWorkload(t *testing.T, st *store.Store, id, name string) string {
+ t.Helper()
+ w, err := st.CreateWorkload(store.Workload{
+ ID: id,
+ Kind: "plugin",
+ Name: name,
+ SourceKind: "static",
+ })
+ if err != nil {
+ t.Fatalf("seed workload: %v", err)
+ }
+ return w.ID
+}
+
+func TestLoadState_ReturnsZeroOnMissingRow(t *testing.T) {
+ deps, _ := testDeps(t)
+ w := plugin.Workload{ID: t.Name() + "-wid", Name: "x"}
+
+ state, row, err := loadState(deps, w)
+ if err != nil {
+ t.Fatalf("loadState: %v", err)
+ }
+ if row != nil {
+ t.Errorf("row = %+v, want nil", row)
+ }
+ if state != (runtimeState{}) {
+ t.Errorf("state = %+v, want zero", state)
+ }
+}
+
+func TestSaveState_CreatesRowOnFirstWrite(t *testing.T) {
+ deps, _ := testDeps(t)
+ w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}
+
+ err := saveState(deps, w, func(state *runtimeState, row *store.Container) {
+ state.Status = "deployed"
+ state.LastCommitSHA = "deadbeef"
+ row.ContainerID = "ctr-123"
+ row.State = "running"
+ })
+ if err != nil {
+ t.Fatalf("saveState: %v", err)
+ }
+
+ state, row, err := loadState(deps, w)
+ if err != nil {
+ t.Fatalf("loadState: %v", err)
+ }
+ if row == nil {
+ t.Fatal("row is nil after save")
+ }
+ if row.ID != containerRowID(w) {
+ t.Errorf("row.ID = %q, want %q", row.ID, containerRowID(w))
+ }
+ if row.WorkloadID != w.ID {
+ t.Errorf("row.WorkloadID = %q, want %q", row.WorkloadID, w.ID)
+ }
+ if row.WorkloadKind != string(store.WorkloadKindSite) {
+ t.Errorf("row.WorkloadKind = %q, want %q", row.WorkloadKind, store.WorkloadKindSite)
+ }
+ if row.ContainerID != "ctr-123" {
+ t.Errorf("row.ContainerID = %q, want %q", row.ContainerID, "ctr-123")
+ }
+ if state.Status != "deployed" {
+ t.Errorf("state.Status = %q, want %q", state.Status, "deployed")
+ }
+ if state.LastCommitSHA != "deadbeef" {
+ t.Errorf("state.LastCommitSHA = %q, want %q", state.LastCommitSHA, "deadbeef")
+ }
+}
+
+func TestSaveState_PreservesUnknownExtraJSONKeys(t *testing.T) {
+ // Future writers (per-face route maps, etc.) extend extra_json with
+ // keys runtimeState doesn't know about. The save path must not eat
+ // them.
+ deps, _ := testDeps(t)
+ w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}
+
+ // Seed a row directly with an extra key.
+ seedRow := store.Container{
+ ID: containerRowID(w),
+ WorkloadID: w.ID,
+ WorkloadKind: string(store.WorkloadKindSite),
+ Host: "local",
+ ExtraJSON: `{"status":"deployed","future_writer_key":"survives"}`,
+ }
+ if err := deps.Store.UpsertContainer(seedRow); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
+ state.LastCommitSHA = "newsha"
+ })
+ if err != nil {
+ t.Fatalf("saveState: %v", err)
+ }
+
+ _, row, err := loadState(deps, w)
+ if err != nil {
+ t.Fatalf("loadState: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal([]byte(row.ExtraJSON), &got); err != nil {
+ t.Fatalf("unmarshal extra_json: %v", err)
+ }
+ if got["future_writer_key"] != "survives" {
+ t.Errorf("unknown key dropped: %+v", got)
+ }
+ if got["last_commit_sha"] != "newsha" {
+ t.Errorf("typed field not persisted: %+v", got)
+ }
+}
+
+func TestSaveState_ClearingTypedFieldRemovesKey(t *testing.T) {
+ // runtimeStateKeys are stripped from the generic map before merge so
+ // clearing a field to "" actually drops the key — not shadowed by a
+ // stale carry-over.
+ deps, _ := testDeps(t)
+ w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}
+
+ if err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
+ state.LastError = "something broke"
+ }); err != nil {
+ t.Fatalf("seed save: %v", err)
+ }
+
+ if err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
+ state.LastError = ""
+ }); err != nil {
+ t.Fatalf("clear save: %v", err)
+ }
+
+ _, row, err := loadState(deps, w)
+ if err != nil {
+ t.Fatalf("loadState: %v", err)
+ }
+ if strings.Contains(row.ExtraJSON, "last_error") {
+ t.Errorf("last_error key not removed after clear: %s", row.ExtraJSON)
+ }
+}
+
+func TestSaveState_RecoversFromInvalidExtraJSON(t *testing.T) {
+ // loadState/saveState log and fall back to zero state when
+ // extra_json is malformed — they must not panic or refuse the save.
+ deps, _ := testDeps(t)
+ w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}
+
+ if err := deps.Store.UpsertContainer(store.Container{
+ ID: containerRowID(w),
+ WorkloadID: w.ID,
+ WorkloadKind: string(store.WorkloadKindSite),
+ Host: "local",
+ ExtraJSON: `{not json`,
+ }); err != nil {
+ t.Fatalf("seed bad row: %v", err)
+ }
+
+ err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
+ state.Status = "recovered"
+ })
+ if err != nil {
+ t.Fatalf("saveState: %v", err)
+ }
+
+ state, row, err := loadState(deps, w)
+ if err != nil {
+ t.Fatalf("loadState: %v", err)
+ }
+ if state.Status != "recovered" {
+ t.Errorf("state.Status = %q, want %q", state.Status, "recovered")
+ }
+ // Recovery must rewrite extra_json as valid JSON — the prior
+ // "{not json" garbage shouldn't survive past the save.
+ var sanity map[string]any
+ if err := json.Unmarshal([]byte(row.ExtraJSON), &sanity); err != nil {
+ t.Errorf("recovered extra_json is not valid JSON: %q (%v)", row.ExtraJSON, err)
+ }
+}
+
+func TestSaveState_ConcurrentWritesDoNotLoseUpdates(t *testing.T) {
+ // The per-workload mutex exists to serialize the read-modify-write
+ // of containers.extra_json. Without it, two parallel saveState
+ // callers can both read the SAME prior state, each apply their
+ // mutate, and the second writer's UpsertContainer overwrites the
+ // first's contribution — a lost update.
+ //
+ // To exercise that, every writer reads state.LastError, APPENDS its
+ // own marker (separated by comma), and writes the result. With the
+ // mutex held, all N markers appear in the final string. Without it,
+ // fewer than N appear because some reads see a stale empty/short
+ // LastError and overwrite a later concurrent writer's accumulation.
+ //
+ // Note: SQLite UpsertContainer is atomic on its own, so a torn-row
+ // write (ContainerID from A + extra_json from B) cannot happen at
+ // the storage layer. The race is purely the Go-side read-modify-
+ // write that surrounds it.
+ deps, _ := testDeps(t)
+ w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}
+
+ const writers = 20
+ var wg sync.WaitGroup
+ for i := 0; i < writers; i++ {
+ i := i
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ marker := fmt.Sprintf("w%02d", i)
+ if err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
+ if state.LastError == "" {
+ state.LastError = marker
+ } else {
+ state.LastError = state.LastError + "," + marker
+ }
+ }); err != nil {
+ t.Errorf("saveState: %v", err)
+ }
+ }()
+ }
+ wg.Wait()
+
+ state, _, err := loadState(deps, w)
+ if err != nil {
+ t.Fatalf("loadState: %v", err)
+ }
+
+ // Every launched writer must appear exactly once in the accumulated
+ // LastError. A missing marker means saveState lost an update
+ // — the symptom the mutex exists to prevent.
+ for i := 0; i < writers; i++ {
+ want := fmt.Sprintf("w%02d", i)
+ if !strings.Contains(state.LastError, want) {
+ t.Errorf("missing marker %q in final state.LastError = %q (writer's update was lost)",
+ want, state.LastError)
+ }
+ if c := strings.Count(state.LastError, want); c != 1 {
+ t.Errorf("marker %q appears %d times in %q (want exactly 1)", want, c, state.LastError)
+ }
+ }
+}
+
+func TestBuildEnv_PlainValues(t *testing.T) {
+ deps, _ := testDeps(t)
+ wid := seedWorkload(t, deps.Store, "wid-plain", "site")
+
+ for _, e := range []store.WorkloadEnv{
+ {WorkloadID: wid, Key: "FOO", Value: "1"},
+ {WorkloadID: wid, Key: "BAR", Value: "two"},
+ } {
+ if _, err := deps.Store.SetWorkloadEnv(e); err != nil {
+ t.Fatalf("seed env: %v", err)
+ }
+ }
+
+ got := buildEnv(deps, wid)
+ gotSet := map[string]bool{}
+ for _, line := range got {
+ gotSet[line] = true
+ }
+ want := []string{"BAR=two", "FOO=1"}
+ if len(got) != len(want) {
+ t.Fatalf("buildEnv returned %d, want %d: %v", len(got), len(want), got)
+ }
+ for _, w := range want {
+ if !gotSet[w] {
+ t.Errorf("buildEnv missing %q; got %v", w, got)
+ }
+ }
+}
+
+func TestBuildEnv_DecryptsEncryptedValues(t *testing.T) {
+ deps, key := testDeps(t)
+ wid := seedWorkload(t, deps.Store, "wid-encrypted", "site")
+
+ ciphertext, err := crypto.Encrypt(key, "supersecret")
+ if err != nil {
+ t.Fatalf("encrypt: %v", err)
+ }
+
+ if _, err := deps.Store.SetWorkloadEnv(store.WorkloadEnv{
+ WorkloadID: wid, Key: "TOKEN", Value: ciphertext, Encrypted: true,
+ }); err != nil {
+ t.Fatalf("seed encrypted env: %v", err)
+ }
+
+ got := buildEnv(deps, wid)
+ if len(got) != 1 {
+ t.Fatalf("buildEnv returned %d, want 1: %v", len(got), got)
+ }
+ if got[0] != "TOKEN=supersecret" {
+ t.Errorf("buildEnv[0] = %q, want %q", got[0], "TOKEN=supersecret")
+ }
+}
+
+func TestBuildEnv_SkipsRowsThatFailToDecrypt(t *testing.T) {
+ // The whole deploy must not fail when one rotated key misses a single
+ // env entry — the row is logged and skipped while siblings pass through.
+ deps, key := testDeps(t)
+ wid := seedWorkload(t, deps.Store, "wid-mixed", "site")
+
+ good, err := crypto.Encrypt(key, "decryptable")
+ if err != nil {
+ t.Fatalf("encrypt: %v", err)
+ }
+
+ for _, e := range []store.WorkloadEnv{
+ {WorkloadID: wid, Key: "AAA_GOOD", Value: good, Encrypted: true},
+ // Garbage ciphertext flagged encrypted: cannot decrypt.
+ {WorkloadID: wid, Key: "BBB_BAD", Value: "deadbeef-not-hex-or-aes", Encrypted: true},
+ // Plain row should still pass through untouched.
+ {WorkloadID: wid, Key: "CCC_PLAIN", Value: "raw"},
+ } {
+ if _, err := deps.Store.SetWorkloadEnv(e); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ }
+
+ got := buildEnv(deps, wid)
+ // Expect AAA_GOOD and CCC_PLAIN; BBB_BAD silently skipped. Check by
+ // set membership so the assertion doesn't depend on ListWorkloadEnv
+ // preserving any particular order.
+ gotSet := map[string]bool{}
+ for _, line := range got {
+ gotSet[line] = true
+ }
+ want := []string{"AAA_GOOD=decryptable", "CCC_PLAIN=raw"}
+ if len(got) != len(want) {
+ t.Fatalf("buildEnv returned %d, want %d: %v", len(got), len(want), got)
+ }
+ for _, w := range want {
+ if !gotSet[w] {
+ t.Errorf("buildEnv missing %q; got %v", w, got)
+ }
+ }
+ // The bad row must be fully absent — not even as a key with empty value.
+ for _, line := range got {
+ if strings.HasPrefix(line, "BBB_BAD=") {
+ t.Errorf("bad-ciphertext row leaked into output: %q", line)
+ }
+ if strings.Contains(line, "deadbeef-not-hex-or-aes") {
+ t.Errorf("bad ciphertext leaked into output: %q", line)
+ }
+ }
+}
+
+func TestBuildEnv_EmptyOnMissingWorkload(t *testing.T) {
+ deps, _ := testDeps(t)
+ got := buildEnv(deps, "wid-no-env")
+ if len(got) != 0 {
+ t.Errorf("buildEnv returned %d, want 0: %v", len(got), got)
+ }
+}
+
+func TestBuildEnv_StoreFailurePropagatesAsEmpty(t *testing.T) {
+ // buildEnv logs and returns nil when ListWorkloadEnv fails (closed
+ // store). The deploy continues without env rather than fataling.
+ //
+ // Open the store directly without the t.Cleanup-registered Close so
+ // we can close it inside the test and avoid a double-close at end.
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ if err := st.Close(); err != nil {
+ t.Fatalf("close store: %v", err)
+ }
+ deps := plugin.Deps{Store: st}
+
+ got := buildEnv(deps, "anything")
+ if len(got) != 0 {
+ t.Errorf("buildEnv returned %d, want 0 on store failure: %v", len(got), got)
+ }
+}