410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
289 lines
8.9 KiB
Go
289 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|