test(static-plugin): cover pure helpers, build helpers, and state/env paths
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:
2026-05-16 18:30:37 +03:00
parent 5e78f13e06
commit ef62a41fc0
3 changed files with 896 additions and 0 deletions
@@ -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), "{}")
}
}