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) + } +}