package jobs import ( "os" "path/filepath" "strings" "testing" ) func write(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestDiscoverDeterministicOrder(t *testing.T) { dir := t.TempDir() for _, n := range []string{ "relspec.yml", "relspec.zeta.yml", "relspec.alpha.yaml", "relspec.beta.yml", "notes.yml", "relspec.txt", } { write(t, filepath.Join(dir, n), "version: 1\njobs: {}\n") } got, err := Discover(dir) if err != nil { t.Fatal(err) } var bases []string for _, p := range got { bases = append(bases, filepath.Base(p)) } want := []string{"relspec.yml", "relspec.alpha.yaml", "relspec.beta.yml", "relspec.zeta.yml"} if strings.Join(bases, ",") != strings.Join(want, ",") { t.Fatalf("discover order = %v, want %v", bases, want) } // Second call must return the identical order. got2, _ := Discover(dir) for i := range got { if got[i] != got2[i] { t.Fatalf("discover not deterministic: %v vs %v", got, got2) } } } func TestLoadRejectsUnknownFields(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "relspec.yml") write(t, p, "version: 1\njobs:\n a:\n command: convert\n bogus: true\n") if _, err := Load([]string{p}); err == nil { t.Fatal("expected error for unknown field") } } func TestLoadRejectsBadVersion(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "relspec.yml") write(t, p, "version: 2\njobs:\n a:\n command: convert\n") _, err := Load([]string{p}) if err == nil || !strings.Contains(err.Error(), "unsupported version") { t.Fatalf("expected unsupported version error, got %v", err) } } func TestLoadRejectsDuplicateJobAcrossFiles(t *testing.T) { dir := t.TempDir() a := filepath.Join(dir, "relspec.yml") b := filepath.Join(dir, "relspec.extra.yml") write(t, a, jobFileConvert("build")) write(t, b, jobFileConvert("build")) _, err := Load([]string{a, b}) if err == nil || !strings.Contains(err.Error(), "duplicate job") { t.Fatalf("expected duplicate job error, got %v", err) } } func jobFileConvert(name string) string { return "version: 1\njobs:\n " + name + ":\n command: convert\n" + " inputs:\n - path: a.dbml\n format: dbml\n" + " output:\n format: json\n path: out.json\n" } func loadOne(t *testing.T, content string) *Set { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "relspec.yml") write(t, p, content) set, err := Load([]string{p}) if err != nil { t.Fatalf("load: %v", err) } return set } func TestValidateUnknownCommand(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: rm-rf\n") err := set.Validate() if err == nil || !strings.Contains(err.Error(), "unsupported command") { t.Fatalf("want unsupported command, got %v", err) } } func TestValidateShellStringCommandRejected(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: \"bash -c 'echo hi'\"\n") if err := set.Validate(); err == nil { t.Fatal("expected arbitrary shell command to be rejected") } } func TestValidateMissingInputs(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: convert\n output:\n format: json\n path: o.json\n") if err := set.Validate(); err == nil || !strings.Contains(err.Error(), "at least 1 input") { t.Fatalf("want missing input error, got %v", err) } } func TestValidateUnknownFormat(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: convert\n"+ " inputs:\n - path: a.xyz\n format: xyz\n"+ " output:\n format: json\n path: o.json\n") if err := set.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported input format") { t.Fatalf("want unsupported input format, got %v", err) } } func TestValidatePathTraversalRejected(t *testing.T) { cases := []string{"../secret.dbml", "/etc/passwd", "~/x.dbml", "a/../../b.dbml"} for _, bad := range cases { set := loadOne(t, "version: 1\njobs:\n x:\n command: convert\n"+ " inputs:\n - path: \""+bad+"\"\n format: dbml\n"+ " output:\n format: json\n path: o.json\n") if err := set.Validate(); err == nil { t.Fatalf("path %q: expected rejection", bad) } } } func TestValidateOutputTraversalRejected(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: convert\n"+ " inputs:\n - path: a.dbml\n format: dbml\n"+ " output:\n format: json\n path: ../../evil.json\n") if err := set.Validate(); err == nil { t.Fatal("expected output path traversal rejection") } } func TestValidateConnEnvMustBeName(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: convert\n"+ " inputs:\n - format: pgsql\n conn_env: \"postgres://u:p@h/db\"\n"+ " output:\n format: json\n path: o.json\n") if err := set.Validate(); err == nil || !strings.Contains(err.Error(), "environment variable name") { t.Fatalf("want conn_env name error, got %v", err) } } func TestValidateDependsOnUnknown(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n x:\n command: convert\n depends_on: [nope]\n"+ " inputs:\n - path: a.dbml\n format: dbml\n"+ " output:\n format: json\n path: o.json\n") if err := set.Validate(); err == nil || !strings.Contains(err.Error(), "unknown job") { t.Fatalf("want unknown dependency error, got %v", err) } } func TestValidateDependencyCycle(t *testing.T) { content := "version: 1\njobs:\n" + jobBlock("a", "b") + jobBlock("b", "c") + jobBlock("c", "a") set := loadOne(t, content) err := set.Validate() if err == nil || !strings.Contains(err.Error(), "cycle") { t.Fatalf("want cycle error, got %v", err) } } func jobBlock(name, dep string) string { return " " + name + ":\n command: convert\n depends_on: [" + dep + "]\n" + " inputs:\n - path: a.dbml\n format: dbml\n" + " output:\n format: json\n path: " + name + ".json\n" } func TestPlanTopologicalOrder(t *testing.T) { content := "version: 1\njobs:\n" + " base:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: base.json\n" + " mid:\n command: convert\n depends_on: [base]\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: mid.json\n" + " top:\n command: convert\n depends_on: [mid]\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: top.json\n" set := loadOne(t, content) if err := set.Validate(); err != nil { t.Fatalf("validate: %v", err) } plan, err := set.Plan("top", true) if err != nil { t.Fatal(err) } var order []string for _, j := range plan { order = append(order, j.Name) } if strings.Join(order, ",") != "base,mid,top" { t.Fatalf("plan order = %v, want [base mid top]", order) } solo, err := set.Plan("top", false) if err != nil { t.Fatal(err) } if len(solo) != 1 || solo[0].Name != "top" { t.Fatalf("no-deps plan = %v, want [top]", solo) } } func TestSafeJoinStaysInsideRoot(t *testing.T) { root := t.TempDir() if _, err := SafeJoin(root, "sub/dir/file.sql"); err != nil { t.Fatalf("expected ok, got %v", err) } if _, err := SafeJoin(root, "../escape"); err == nil { t.Fatal("expected escape rejection") } if _, err := SafeJoin(root, "/abs"); err == nil { t.Fatal("expected absolute rejection") } } func TestShippedExampleIsValid(t *testing.T) { path := filepath.Join("..", "..", "examples", "jobs", "relspec.yml") set, err := Load([]string{path}) if err != nil { t.Fatalf("load example: %v", err) } if err := set.Validate(); err != nil { t.Fatalf("example manifest failed validation: %v", err) } if _, err := set.Plan("build-json", true); err != nil { t.Fatalf("plan example: %v", err) } } func TestScriptsListValidation(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n s:\n command: scripts-list\n") if err := set.Validate(); err == nil || !strings.Contains(err.Error(), "script_dir") { t.Fatalf("want script_dir required error, got %v", err) } set = loadOne(t, "version: 1\njobs:\n s:\n command: scripts-list\n script_dirs: [migrations, extra]\n") if err := set.Validate(); err != nil { t.Fatalf("expected valid scripts-list job, got %v", err) } }