test(static-plugin): cover pure helpers, build helpers, and state/env paths
Build / build (push) Successful in 11m3s
Build / build (push) Successful in 11m3s
Bring the previously-untested internal/workload/plugin/source/static/ package from 0% to 23.6% coverage with three new test files: helpers_test.go (20 cases) - idShort/containerNameFor/imageTagFor/ siteVolumeKey shape + same-name-workload collision avoidance; sanitizeError newline collapse, empty-token no-op, 240-byte cap, and multi-byte UTF-8 validity at the cap; containerRowID determinism; lockFor map semantics (same lock for same workload, distinct locks for different workloads, real serialization under contention, safe concurrent insertion); runtimeStateKeys exactly equals the JSON-tag key set. build_test.go (8 cases) - copyDir copies files + subdirs and preserves modes on Unix; verifyDownloadInsideRoot accepts clean trees and surfaces ErrNotExist for missing roots; both functions reject symlinks (skipped cleanly on Windows non-admin where the SeCreateSymbolicLink privilege is absent); prepareStaticBuild writes the Dockerfile even for an empty source. state_integration_test.go (12 cases) - loadState/saveState round- trip on an in-memory SQLite store, including: unknown extra_json keys (future writers) survive a save; clearing a typed field drops the key; malformed extra_json is recovered from rather than panicked on; concurrent writers exercise the per-workload mutex by accumulating into state.LastError - the test verified to fail loudly (15+ lost markers) when the mutex is disabled. buildEnv returns plain values, decrypts encrypted ones, skips rows that fail to decrypt without leaking ciphertext, and returns empty on store failure without panicking. Review followups from go-reviewer pass applied inline: H1 rewrite to exercise actual lost-update race (verified against disabled mutex), H2 workload-ID scoping by t.Name() so the package-global saveLocks map cannot bleed across tests or -count=N runs, set- based env-assertions, JSON tag-set equality check, multi-byte truncation case, valid-JSON-on-recovery assertion, unique-keys in concurrent map test, double-close cleanup.
This commit is contained in:
@@ -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("<h1>hi</h1>"), 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) != "<h1>hi</h1>" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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), "{}")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user