package dockerfile import ( "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/alexei/tinyforge/internal/workload/plugin" ) // ── Source interface plumbing ─────────────────────────────────────── func TestSource_Kind(t *testing.T) { if (&source{}).Kind() != "dockerfile" { t.Fatalf("Kind = %q, want \"dockerfile\"", (&source{}).Kind()) } } func TestSource_Registered_AtInit(t *testing.T) { // init() runs once on import; we just verify the registry returns // our concrete kind. A failure here is a regression of the global // plugin.RegisterSource path or our package-level init. got, err := plugin.GetSource("dockerfile") if err != nil { t.Fatalf("GetSource(dockerfile): %v", err) } if got.Kind() != "dockerfile" { t.Fatalf("registered source has wrong kind: %q", got.Kind()) } } func TestSource_SchemaSample_RoundTrips(t *testing.T) { s := (&source{}).SchemaSample() raw, err := json.Marshal(s) if err != nil { t.Fatalf("marshal sample: %v", err) } if err := (&source{}).Validate(raw); err != nil { t.Fatalf("Validate(sample) = %v, want nil", err) } } // ── Validate ──────────────────────────────────────────────────────── func TestValidate_RejectsEmpty(t *testing.T) { if err := (&source{}).Validate(nil); err == nil { t.Fatal("expected error on empty config, got nil") } } func TestValidate_RejectsMissingRepo(t *testing.T) { cases := []Config{ {RepoName: "x", Port: 80}, // owner missing {RepoOwner: "y", Port: 80}, // name missing {RepoOwner: " ", RepoName: "x", Port: 80}, // owner whitespace-only } for i, c := range cases { raw, _ := json.Marshal(c) if err := (&source{}).Validate(raw); err == nil { t.Errorf("case %d: expected error, got nil", i) } } } func TestValidate_RejectsBadPort(t *testing.T) { for _, port := range []int{0, -1, 70000} { raw, _ := json.Marshal(Config{RepoOwner: "a", RepoName: "b", Port: port}) if err := (&source{}).Validate(raw); err == nil { t.Errorf("port %d: expected error, got nil", port) } } } func TestValidate_RejectsPathEscape(t *testing.T) { cases := []Config{ {RepoOwner: "a", RepoName: "b", Port: 80, DockerfilePath: "/etc/passwd"}, {RepoOwner: "a", RepoName: "b", Port: 80, DockerfilePath: "../../etc/passwd"}, {RepoOwner: "a", RepoName: "b", Port: 80, ContextPath: "../../"}, {RepoOwner: "a", RepoName: "b", Port: 80, ContextPath: "/etc"}, } for i, c := range cases { raw, _ := json.Marshal(c) if err := (&source{}).Validate(raw); err == nil { t.Errorf("case %d: expected path-escape rejection, got nil", i) } } } func TestValidate_AcceptsValid(t *testing.T) { raw, _ := json.Marshal(Config{ RepoOwner: "owner", RepoName: "repo", Port: 8080, DockerfilePath: "docker/Dockerfile", ContextPath: "services/api", }) if err := (&source{}).Validate(raw); err != nil { t.Fatalf("Validate(valid) = %v", err) } } // ── Naming helpers ────────────────────────────────────────────────── func TestNaming_SameNameDifferentIDs_NoCollision(t *testing.T) { a := plugin.Workload{ID: "aaaaaaaa-rest", Name: "svc"} b := plugin.Workload{ID: "bbbbbbbb-rest", Name: "svc"} if containerNameFor(a) == containerNameFor(b) { t.Errorf("container names collide: %q", containerNameFor(a)) } if imageTagFor(a) == imageTagFor(b) { t.Errorf("image tags collide: %q", imageTagFor(a)) } } func TestNaming_ShortIDsPassThrough(t *testing.T) { w := plugin.Workload{ID: "abc", Name: "tiny"} if !strings.HasSuffix(containerNameFor(w), "-abc") { t.Errorf("container name lost short id: %q", containerNameFor(w)) } } // ── Context + Dockerfile resolution ───────────────────────────────── func TestResolveContextDir_Empty_ReturnsRoot(t *testing.T) { dir := t.TempDir() got, err := resolveContextDir(dir, "") if err != nil { t.Fatalf("resolveContextDir: %v", err) } if real, _ := filepath.EvalSymlinks(dir); got != real && got != dir { t.Errorf("got %q, want %q (or symlink-resolved equivalent)", got, dir) } } func TestResolveContextDir_Subfolder_OK(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "api") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } got, err := resolveContextDir(dir, "api") if err != nil { t.Fatalf("resolveContextDir: %v", err) } if !strings.HasSuffix(got, "api") { t.Errorf("got %q, expected suffix 'api'", got) } } func TestResolveContextDir_NonexistentSubfolder(t *testing.T) { dir := t.TempDir() if _, err := resolveContextDir(dir, "missing"); err == nil { t.Fatal("expected error for missing subfolder") } } func TestResolveContextDir_RejectsEscape(t *testing.T) { dir := t.TempDir() // resolveContextDir is the second wall — Validate is the first. // We pass an absolute escape via a synthesized symlink. Even if // the user bypasses Validate (e.g. by direct DB edit), this must // still reject. outside := t.TempDir() link := filepath.Join(dir, "escape") if err := os.Symlink(outside, link); err != nil { t.Skipf("symlink unsupported in this environment: %v", err) } if _, err := resolveContextDir(dir, "escape"); err == nil { t.Fatal("expected escape-path rejection") } } func TestVerifyDockerfileExists_Present(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatalf("write: %v", err) } if err := verifyDockerfileExists(dir, ""); err != nil { t.Fatalf("verifyDockerfileExists(default) = %v, want nil", err) } } func TestVerifyDockerfileExists_Missing(t *testing.T) { dir := t.TempDir() if err := verifyDockerfileExists(dir, ""); err == nil { t.Fatal("expected error for missing Dockerfile") } } func TestVerifyDockerfileExists_CustomPath(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "docker"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(dir, "docker", "Dockerfile.prod"), []byte("FROM scratch\n"), 0o644); err != nil { t.Fatalf("write: %v", err) } if err := verifyDockerfileExists(dir, "docker/Dockerfile.prod"); err != nil { t.Fatalf("verifyDockerfileExists(custom) = %v, want nil", err) } } func TestVerifyDockerfileExists_RejectsAbsolutePath(t *testing.T) { dir := t.TempDir() if err := verifyDockerfileExists(dir, "/etc/passwd"); err == nil { t.Fatal("expected error for absolute dockerfile path") } } // ── Sanitiser ─────────────────────────────────────────────────────── func TestSanitizeError_RedactsToken(t *testing.T) { tok := "ghp_supersecret" got := sanitizeError("401 from gitea token="+tok+" ok", tok) if strings.Contains(got, tok) { t.Errorf("token leaked: %q", got) } if !strings.Contains(got, "[REDACTED]") { t.Errorf("missing [REDACTED] marker: %q", got) } } func TestSanitizeError_CollapsesWhitespace(t *testing.T) { got := sanitizeError("a\nb\rc\td", "") if strings.ContainsAny(got, "\n\r\t") { t.Errorf("did not collapse: %q", got) } } func TestSanitizeError_TruncatesUTF8Safe(t *testing.T) { // 1000 copies of a 2-byte rune = 2000 bytes, well over the 240 // cap. Output must remain valid UTF-8 (no torn rune at the cap). long := strings.Repeat("é", 1000) got := sanitizeError(long, "") if !strings.HasSuffix(got, "…") { t.Errorf("missing ellipsis: %q", got) } // Walk the result: every byte should be either an ASCII char or // part of a complete UTF-8 sequence. utf8.ValidString is the // canonical guard but a simple "ends on rune boundary" check // suffices for this fixture. if !isValidUTF8Slice([]byte(got)) { t.Errorf("truncation produced broken UTF-8: %q", got) } } func isValidUTF8Slice(b []byte) bool { for i := 0; i < len(b); { switch { case b[i] < 0x80: i++ case b[i] < 0xC0: return false // continuation byte at sequence start case b[i] < 0xE0: if i+1 >= len(b) { return false } i += 2 case b[i] < 0xF0: if i+2 >= len(b) { return false } i += 3 default: if i+3 >= len(b) { return false } i += 4 } } return true } // ── State row ID ──────────────────────────────────────────────────── func TestContainerRowID_Deterministic(t *testing.T) { w := plugin.Workload{ID: "abcd1234-rest"} a := containerRowID(w) b := containerRowID(w) if a != b { t.Errorf("containerRowID not deterministic: %q vs %q", a, b) } if !strings.HasSuffix(a, ":dockerfile") { t.Errorf("containerRowID missing suffix: %q", a) } }