bd7a11d4e7
Extract the verbatim-duplicated helpers into shared homes: - buildEnv -> plugin.BuildWorkloadEnv (base plugin pkg; a sourceName param preserves each plugin's slog prefix / log-scraper text) - idShort -> plugin.IDShort - commitStatusReporter -> staticsite.CommitStatusReporter, re-parameterized on primitives (owner/repo/sha/targetURL/enabled) so staticsite needs no dependency on the plugin package; reporter tests ported to staticsite (plus a new nil-provider case) containerNameFor/imageTagFor are intentionally left per-plugin: their prefixes differ (dw-site- vs tf-build-) and name real Docker resources, so merging them would risk mis-routing. Behavior-preserving; the static/dockerfile test suites pass unchanged. Reviewed: go APPROVE (0 CRITICAL/HIGH).
323 lines
9.8 KiB
Go
323 lines
9.8 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 := plugin.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 := plugin.IDShort(plugin.Workload{ID: "abc"})
|
|
if got != "abc" {
|
|
t.Fatalf("idShort = %q, want %q", got, "abc")
|
|
}
|
|
}
|
|
|
|
func TestIdShort_ExactlyEightChars(t *testing.T) {
|
|
got := plugin.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 TestSaveLock_FreedWhenIdle(t *testing.T) {
|
|
// After the last holder releases, the reference-counted entry must be
|
|
// removed from the map so the lock table cannot grow without bound.
|
|
// 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"
|
|
lk := acquireSaveLock(key)
|
|
saveLocks.mu.Lock()
|
|
_, present := saveLocks.locks[key]
|
|
saveLocks.mu.Unlock()
|
|
if !present {
|
|
t.Fatal("acquireSaveLock did not register the entry while held")
|
|
}
|
|
releaseSaveLock(key, lk)
|
|
saveLocks.mu.Lock()
|
|
_, stillPresent := saveLocks.locks[key]
|
|
saveLocks.mu.Unlock()
|
|
if stillPresent {
|
|
t.Fatal("releaseSaveLock left the entry behind after the last holder released")
|
|
}
|
|
}
|
|
|
|
func TestSaveLock_DistinctWorkloadsDoNotSerialize(t *testing.T) {
|
|
// Two different workloads must be lockable at the same time. If they
|
|
// shared a mutex the second acquire would block forever (deadlock).
|
|
a := acquireSaveLock(t.Name() + "-a")
|
|
b := acquireSaveLock(t.Name() + "-b")
|
|
releaseSaveLock(t.Name()+"-b", b)
|
|
releaseSaveLock(t.Name()+"-a", a)
|
|
}
|
|
|
|
func TestSaveLock_SerializesConcurrentAcquisitions(t *testing.T) {
|
|
// Goroutines acquiring the same workload's lock must run sequentially.
|
|
// The counter would race past 1 if locking were broken; with the lock,
|
|
// peak in-flight stays at 1.
|
|
key := 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 := acquireSaveLock(key)
|
|
defer releaseSaveLock(key, lk)
|
|
|
|
mu.Lock()
|
|
counter++
|
|
if counter > peak {
|
|
peak = counter
|
|
}
|
|
mu.Unlock()
|
|
|
|
mu.Lock()
|
|
counter--
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if peak != 1 {
|
|
t.Fatalf("acquireSaveLock failed to serialize: peak in-flight = %d, want 1", peak)
|
|
}
|
|
}
|
|
|
|
func TestSaveLock_ConcurrentMapAccessIsSafe(t *testing.T) {
|
|
// Distinct workloads acquired+released in parallel must not panic on map
|
|
// access — exercises the outer-mutex protection inside acquire/release.
|
|
// Each iteration uses a unique key so the test stresses the insertion +
|
|
// refcount-cleanup paths (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()
|
|
key := prefix + strconv.Itoa(i)
|
|
lk := acquireSaveLock(key)
|
|
releaseSaveLock(key, lk)
|
|
}()
|
|
}
|
|
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), "{}")
|
|
}
|
|
}
|