ef62a41fc0
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.
311 lines
9.2 KiB
Go
311 lines
9.2 KiB
Go
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), "{}")
|
|
}
|
|
}
|