package jobs import ( "os" "path/filepath" "strings" "testing" ) // validateYAML loads one job file and returns the Validate error text ("" when valid). func validateYAML(t *testing.T, body string) string { t.Helper() set := loadOne(t, "version: 1\njobs:\n"+body) if err := set.Validate(); err != nil { return err.Error() } return "" } func TestValidateJobTable(t *testing.T) { in := " inputs:\n - path: a.dbml\n format: dbml\n" out := " output:\n format: json\n path: out.json\n" tests := []struct { name string job string want string // substring of the error, "" for valid }{ {"missing command", " x:\n description: d\n", "missing command"}, {"convert valid", " x:\n command: convert\n" + in + out, ""}, {"convert script dirs", " x:\n command: convert\n script_dirs: [s]\n" + in + out, "script_dirs is not valid"}, {"convert missing output", " x:\n command: convert\n" + in, "missing output"}, {"convert output missing format", " x:\n command: convert\n" + in + " output:\n path: o\n", "output: missing format"}, {"convert output unsupported format", " x:\n command: convert\n" + in + " output:\n format: nope\n path: o\n", "unsupported output format"}, {"convert output missing path", " x:\n command: convert\n" + in + " output:\n format: json\n", "output: missing path"}, {"convert output conn_env on non-exec format", " x:\n command: convert\n" + in + " output:\n format: json\n conn_env: DB\n", "not supported for format"}, {"convert output path and conn_env", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: DB\n path: o.sql\n", "either path or conn_env"}, {"convert output conn_env ok", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: DB\n", ""}, {"output secret conn_env", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: postgres://u:p@h/db\n", "environment variable name"}, {"merge needs two inputs", " x:\n command: merge\n" + in + out, "at least 2 input"}, {"input missing format", " x:\n command: convert\n inputs:\n - path: a\n" + out, "missing format"}, {"input unsupported format", " x:\n command: convert\n inputs:\n - path: a\n format: nope\n" + out, "unsupported input format"}, {"input file missing path", " x:\n command: convert\n inputs:\n - format: dbml\n" + out, "missing path"}, {"input file with conn_env", " x:\n command: convert\n inputs:\n - path: a\n format: dbml\n conn_env: DB\n" + out, "does not use conn_env"}, {"input db missing conn_env", " x:\n command: convert\n inputs:\n - format: pgsql\n" + out, "requires conn_env"}, {"input db with path", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: DB\n path: a\n" + out, "takes conn_env, not path"}, {"input db ok", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: DB\n" + out, ""}, {"input secret conn_env", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: \"host=h password=p\"\n" + out, "environment variable name"}, {"bad log size", " x:\n command: convert\n log_max_size: lots\n" + in + out, "log_max_size"}, {"absolute logfile", " x:\n command: convert\n logfile: /var/log/x.log\n" + in + out, "absolute paths"}, {"home path", " x:\n command: convert\n template: ~/t\n" + in + out, "home-relative"}, {"report path traversal", " x:\n command: inspect\n" + in + " report:\n format: json\n path: ../r.json\n", "escapes"}, {"script_dir traversal", " x:\n command: scripts-list\n script_dirs: [../x]\n", "escapes"}, {"templ valid", " x:\n command: templ\n" + in + " template: t.tmpl\n mode: table\n output:\n format: text\n path: o\n", ""}, {"templ pgsql input valid", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: DB\n template: t.tmpl\n", ""}, {"templ no inputs", " x:\n command: templ\n template: t.tmpl\n", "at least 1 input"}, {"templ no template", " x:\n command: templ\n" + in, "requires template"}, {"templ bad mode", " x:\n command: templ\n" + in + " template: t\n mode: weird\n", "unsupported mode"}, {"templ script dirs", " x:\n command: templ\n" + in + " template: t\n script_dirs: [s]\n", "script_dirs is not valid"}, {"templ db output", " x:\n command: templ\n" + in + " template: t\n output:\n conn_env: DB\n", "does not support database output"}, {"templ non-text output", " x:\n command: templ\n" + in + " template: t\n output:\n format: json\n path: o\n", "only output.format: text"}, {"templ input missing format", " x:\n command: templ\n inputs:\n - path: a\n template: t\n", "missing format"}, {"templ pgsql input without conn_env", " x:\n command: templ\n inputs:\n - format: pgsql\n template: t\n", "requires conn_env"}, {"templ pgsql input with path", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: DB\n path: a\n template: t\n", "takes conn_env, not path"}, {"templ file input without path", " x:\n command: templ\n inputs:\n - format: dbml\n template: t\n", "missing path"}, {"templ file input with conn_env", " x:\n command: templ\n inputs:\n - path: a\n format: dbml\n conn_env: DB\n template: t\n", "does not use conn_env"}, {"templ unsupported input format", " x:\n command: templ\n inputs:\n - path: a\n format: nope\n template: t\n", "unsupported templ input format"}, {"templ secret conn_env", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: a/b\n template: t\n", "environment variable name"}, {"split needs input", " x:\n command: split\n" + out, "at least 1 input"}, {"split script dirs", " x:\n command: split\n" + in + " script_dirs: [s]\n" + out, "script_dirs is not valid"}, {"split report", " x:\n command: split\n" + in + " report:\n format: json\n path: r\n" + out, "report is not valid"}, {"split db output", " x:\n command: split\n" + in + " output:\n format: pgsql\n conn_env: DB\n", "writes a file"}, {"inspect script dirs", " x:\n command: inspect\n" + in + " script_dirs: [s]\n report:\n path: r\n", "script_dirs is not valid"}, {"inspect output", " x:\n command: inspect\n" + in + out + " report:\n path: r\n", "output is not valid"}, {"inspect bad report format", " x:\n command: inspect\n" + in + " report:\n format: html\n path: r\n", "not supported"}, {"inspect report without path", " x:\n command: inspect\n" + in + " report:\n format: json\n", "requires report.path"}, {"inspect default format ok", " x:\n command: inspect\n" + in + " report:\n path: r.md\n", ""}, {"diff summary without path ok", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n report:\n format: summary\n", ""}, {"diff json needs path", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n report:\n format: json\n", "requires report.path"}, {"diff output", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n" + out + " report:\n format: summary\n", "output is not valid"}, {"diff script dirs", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n script_dirs: [s]\n report:\n format: summary\n", "script_dirs is not valid"}, {"diff no report", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n", "requires a report block"}, {"scripts-list inputs", " x:\n command: scripts-list\n script_dirs: [s]\n" + in, "inputs is not valid"}, {"scripts-list output", " x:\n command: scripts-list\n script_dirs: [s]\n" + out, "output is not valid"}, {"scripts-exec inputs", " x:\n command: scripts-exec\n script_dirs: [s]\n" + in + " output:\n conn_env: DB\n", "inputs is not valid"}, {"scripts-exec report", " x:\n command: scripts-exec\n script_dirs: [s]\n report:\n path: r\n output:\n conn_env: DB\n", "report is not valid"}, {"scripts-exec output path", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n path: p\n", "output.path is not supported"}, {"scripts-exec non-pgsql", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n format: mssql\n", "only supports pgsql"}, {"scripts-exec secret conn_env", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: \"postgres://u@h/d\"\n", "environment variable name"}, {"scripts-exec no script dirs", " x:\n command: scripts-exec\n output:\n conn_env: DB\n", "requires at least one script_dir"}, {"scripts-exec pgsql format ok", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n format: pgsql\n", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := validateYAML(t, tt.job) if tt.want == "" { if got != "" { t.Errorf("expected valid, got: %s", got) } return } if !strings.Contains(got, tt.want) { t.Errorf("error %q does not contain %q", got, tt.want) } }) } } func TestFromJobInputShape(t *testing.T) { producer := " p:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: out.json\n" tests := []struct { name string input string want string }{ {"path", " - from_job: p\n path: x\n", "takes no path"}, {"format", " - from_job: p\n format: json\n", "drop format"}, {"conn_env", " - from_job: p\n conn_env: DB\n", "takes no conn_env"}, } for _, tt := range tests { for _, cmd := range []string{"convert", "templ"} { t.Run(cmd+"/"+tt.name, func(t *testing.T) { extra := " output:\n format: json\n path: o.json\n" if cmd == "templ" { extra = " template: t.tmpl\n" } got := validateYAML(t, producer+" c:\n command: "+cmd+"\n inputs:\n"+tt.input+extra) if !strings.Contains(got, tt.want) { t.Errorf("error %q does not contain %q", got, tt.want) } }) } } } func TestResolvedLogPolicy(t *testing.T) { keep2 := 2 keep0 := 0 tests := []struct { name string job Job want LogPolicy }{ {"built-in defaults", Job{}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}}, {"file defaults", Job{fileDefaults: &Defaults{LogMaxSize: "1MB", LogKeep: 7}}, LogPolicy{MaxSizeBytes: 1 << 20, Keep: 7}}, {"file defaults invalid size falls back", Job{fileDefaults: &Defaults{LogMaxSize: "junk", LogKeep: 0}}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}}, {"job overrides file", Job{fileDefaults: &Defaults{LogMaxSize: "1MB", LogKeep: 7}, LogMaxSize: "2kb", LogKeep: &keep2}, LogPolicy{MaxSizeBytes: 2 << 10, Keep: 2}}, {"job keep zero is honoured", Job{LogKeep: &keep0}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: 0}}, {"job invalid size ignored", Job{LogMaxSize: "junk"}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.job.ResolvedLogPolicy(); got != tt.want { t.Errorf("got %+v, want %+v", got, tt.want) } }) } } func TestLoadAppliesFileDefaultsAndDir(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "relspec.yml") write(t, p, "version: 1\ndefaults:\n log_max_size: 1MB\n log_keep: 9\n"+"jobs:\n a:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o.json\n") set, err := Load([]string{p}) if err != nil { t.Fatal(err) } job := set.Jobs["a"] if job.Dir() != dir { t.Errorf("Dir = %q, want %q", job.Dir(), dir) } if pol := job.ResolvedLogPolicy(); pol.MaxSizeBytes != 1<<20 || pol.Keep != 9 { t.Errorf("policy %+v", pol) } } func TestSetNamesSorted(t *testing.T) { set := &Set{Jobs: map[string]*Job{"b": {}, "a": {}, "c": {}}} if got := strings.Join(set.Names(), ","); got != "a,b,c" { t.Errorf("got %s", got) } } func TestPlanErrors(t *testing.T) { set := loadOne(t, "version: 1\njobs:\n"+ " a:\n command: convert\n depends_on: [ghost]\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o.json\n"+ " b:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o2.json\n") if _, err := set.Plan("nope", true); err == nil || !strings.Contains(err.Error(), "unknown job") || !strings.Contains(err.Error(), "a, b") { t.Errorf("unknown job: %v", err) } if _, err := set.Plan("a", true); err == nil || !strings.Contains(err.Error(), "unknown job \"ghost\"") { t.Errorf("unknown dependency: %v", err) } // Without dependencies the declared dependency is not walked. if got, err := set.Plan("a", false); err != nil || len(got) != 1 || got[0].Name != "a" { t.Errorf("no-deps plan: %v %v", got, err) } } func TestPlanCycleAtRuntime(t *testing.T) { set := &Set{Jobs: map[string]*Job{ "a": {Name: "a", DependsOn: []string{"b"}}, "b": {Name: "b", DependsOn: []string{"a"}}, }} if _, err := set.Plan("a", true); err == nil || !strings.Contains(err.Error(), "cycle") { t.Errorf("want cycle error, got %v", err) } } func TestDiscoverErrorsAndFiltering(t *testing.T) { if _, err := Discover(filepath.Join(t.TempDir(), "missing")); err == nil { t.Error("missing dir must fail") } dir := t.TempDir() for _, f := range []string{"relspec.yaml", "relspec.b.yml", "relspec.a.yaml", "relspec.txt", "other.yml", "relspec"} { write(t, filepath.Join(dir, f), "") } if err := os.Mkdir(filepath.Join(dir, "relspec.dir.yml"), 0o755); err != nil { t.Fatal(err) } got, err := Discover(dir) if err != nil { t.Fatal(err) } var names []string for _, p := range got { names = append(names, filepath.Base(p)) } if strings.Join(names, ",") != "relspec.yaml,relspec.a.yaml,relspec.b.yml" { t.Errorf("got %v", names) } } func TestSafeJoinCases(t *testing.T) { root := t.TempDir() if got, err := SafeJoin(root, "sub/file.sql"); err != nil || !strings.HasSuffix(got, filepath.Join("sub", "file.sql")) { t.Errorf("nested: %q %v", got, err) } for _, bad := range []string{"", "/etc/passwd", "~/x", "..", "../x", "a/../../x"} { if _, err := SafeJoin(root, bad); err == nil { t.Errorf("SafeJoin(%q) must fail", bad) } } if _, err := SafeJoin(filepath.Join(root, "does", "not", "exist"), "x"); err == nil { t.Error("unresolvable root must fail") } } func TestLooksLikeSecret(t *testing.T) { for in, want := range map[string]bool{ "": false, "DB_URL": false, "MY_DB": false, "postgres://u:p@h/db": true, "host=h": true, "a b": true, "a/b": true, "u@h": true, "k:v": true, } { if got := looksLikeSecret(in); got != want { t.Errorf("looksLikeSecret(%q) = %v, want %v", in, got, want) } } }