package config import ( "os" "path/filepath" "reflect" "runtime" "strings" "testing" ) func TestLoadPipelineCompositionImportsDisjointFieldsAndTracksOwnership(t *testing.T) { dir := t.TempDir() rootPath := writePipelineSource(t, dir, "pipeline.yml", `composition: imports: - conf.d/platform.yml - conf.d/artifacts.yml whisperx: transcribe_url: https://transcription.example.com/transcribe `) platformPath := writePipelineSource(t, dir, "conf.d/platform.yml", `workspace: root: /srv/narratio/work storage: backend: local `) artifactsPath := writePipelineSource(t, dir, "conf.d/artifacts.yml", `scriptorium: artifacts: player_handout: enabled: false session_recap: enabled: true prompt_id: dnd.session_recap output_path: artifacts/session_recap.md `) cfg, err := LoadPipeline(rootPath) if err != nil { t.Fatalf("LoadPipeline() error = %v", err) } if cfg.Workspace.Root != "/srv/narratio/work" || cfg.Storage.Backend != StorageBackendLocal { t.Fatalf("imported platform config = workspace=%q storage=%#v", cfg.Workspace.Root, cfg.Storage) } if cfg.Scriptorium == nil || len(cfg.Scriptorium.Artifacts) != 2 || cfg.Scriptorium.Artifacts["player_handout"].Enabled { t.Fatalf("imported artifacts = %#v", cfg.Scriptorium) } if cfg.WhisperX.TranscribeURL != "https://transcription.example.com/transcribe" { t.Fatalf("root field = %q", cfg.WhisperX.TranscribeURL) } if cfg.resolution == nil { t.Fatal("pipeline resolution metadata = nil") } wantSources := []string{absolutePath(t, rootPath), absolutePath(t, platformPath), absolutePath(t, artifactsPath)} if !reflect.DeepEqual(cfg.resolution.sources, wantSources) || !reflect.DeepEqual(cfg.resolution.imports, wantSources[1:]) { t.Fatalf("resolution sources=%#v imports=%#v, want %#v / %#v", cfg.resolution.sources, cfg.resolution.imports, wantSources, wantSources[1:]) } assertPipelineFieldOwner(t, cfg, "whisperx.transcribe_url", absolutePath(t, rootPath)) assertPipelineFieldOwner(t, cfg, "workspace.root", absolutePath(t, platformPath)) assertPipelineFieldOwner(t, cfg, "scriptorium.artifacts.session_recap.prompt_id", absolutePath(t, artifactsPath)) } func TestLoadPipelineCompositionMergesDisjointKeyedEntries(t *testing.T) { dir := t.TempDir() rootPath := writePipelineSource(t, dir, "pipeline.yml", `composition: imports: [first.yml, second.yml] whisperx: transcribe_url: https://transcription.example.com/transcribe scriptorium: artifacts: root_artifact: enabled: false `) writePipelineSource(t, dir, "first.yml", `scriptorium: artifacts: first_artifact: enabled: false `) writePipelineSource(t, dir, "second.yml", `scriptorium: artifacts: second_artifact: enabled: false `) cfg, err := LoadPipeline(rootPath) if err != nil { t.Fatal(err) } if got := len(cfg.Scriptorium.Artifacts); got != 3 { t.Fatalf("artifact count = %d, want 3: %#v", got, cfg.Scriptorium.Artifacts) } } func TestLoadPipelineCompositionRejectsBaseConflictsWithAllSources(t *testing.T) { tests := []struct { name string root string imports map[string]string path string sources []string }{ { name: "root and import identical scalar", root: "whisperx:\n language: en\n", imports: map[string]string{"one.yml": "whisperx:\n language: en\n"}, path: "whisperx.language", sources: []string{"pipeline.yml", "one.yml"}, }, { name: "all import claimants", imports: map[string]string{ "one.yml": "workspace:\n root: /one\n", "two.yml": "workspace:\n root: /two\n", "three.yml": "workspace:\n root: /three\n", }, path: "workspace.root", sources: []string{"one.yml", "two.yml", "three.yml"}, }, { name: "atomic list", root: "audita:\n modules: [one]\n", imports: map[string]string{"one.yml": "audita:\n modules: [two]\n"}, path: "audita.modules", sources: []string{"pipeline.yml", "one.yml"}, }, { name: "kind conflict", root: "workspace:\n root: /work\n", imports: map[string]string{"one.yml": "workspace: invalid\n"}, path: "workspace", sources: []string{"pipeline.yml", "one.yml"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() order := make([]string, 0, len(tt.imports)) for _, name := range []string{"one.yml", "two.yml", "three.yml"} { if _, ok := tt.imports[name]; ok { order = append(order, name) } } root := "composition:\n imports:\n" for _, name := range order { root += " - " + name + "\n" } root += tt.root rootPath := writePipelineSource(t, dir, "pipeline.yml", root) for name, content := range tt.imports { writePipelineSource(t, dir, name, content) } _, err := LoadPipeline(rootPath) if err == nil { t.Fatal("LoadPipeline() error = nil") } if !strings.Contains(err.Error(), tt.path) { t.Fatalf("error = %q, want path %q", err, tt.path) } for _, source := range tt.sources { if !strings.Contains(err.Error(), source) { t.Fatalf("error = %q, want source %q", err, source) } } }) } } func TestLoadPipelineCompositionRejectsUnsafeOrInvalidImports(t *testing.T) { tests := []struct { name string imports []string setup func(*testing.T, string) want string }{ {name: "empty", imports: []string{""}, want: "non-empty path"}, {name: "surrounding whitespace", imports: []string{" one.yml "}, want: "surrounding whitespace"}, {name: "absolute", imports: []string{"/tmp/one.yml"}, want: "invalid"}, {name: "traversal", imports: []string{"../one.yml"}, want: "invalid"}, {name: "unsupported extension", imports: []string{"one.json"}, want: ".yml or .yaml"}, {name: "missing", imports: []string{"missing.yml"}, want: "open composition.imports"}, {name: "duplicate normalized", imports: []string{"one.yml", "./one.yml"}, setup: func(t *testing.T, dir string) { writePipelineSource(t, dir, "one.yml", "workspace:\n root: /work\n") }, want: "duplicates composition.imports"}, {name: "root self import", imports: []string{"pipeline.yml"}, want: "root pipeline itself"}, {name: "directory", imports: []string{"directory.yml"}, setup: func(t *testing.T, dir string) { if err := os.Mkdir(filepath.Join(dir, "directory.yml"), 0o755); err != nil { t.Fatal(err) } }, want: "regular file"}, {name: "symlink file", imports: []string{"link.yml"}, setup: func(t *testing.T, dir string) { writePipelineSource(t, dir, "target.yml", "workspace:\n root: /work\n") if err := os.Symlink("target.yml", filepath.Join(dir, "link.yml")); err != nil { t.Skipf("symlink unavailable: %v", err) } }, want: "not a regular file"}, {name: "symlink directory", imports: []string{"linked/one.yml"}, setup: func(t *testing.T, dir string) { writePipelineSource(t, dir, "actual/one.yml", "workspace:\n root: /work\n") if err := os.Symlink("actual", filepath.Join(dir, "linked")); err != nil { t.Skipf("symlink unavailable: %v", err) } }, want: "not a regular directory"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() if tt.setup != nil { tt.setup(t, dir) } rootPath := writeImportRoot(t, dir, tt.imports) _, err := LoadPipeline(rootPath) if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.want)) { t.Fatalf("LoadPipeline() error = %v, want containing %q", err, tt.want) } }) } } func TestLoadPipelineCompositionRejectsSameFileAliasesAndImportedComposition(t *testing.T) { t.Run("same file through hard link", func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("hard-link identity behavior is platform-specific") } dir := t.TempDir() writePipelineSource(t, dir, "one.yml", "workspace:\n root: /work\n") if err := os.Link(filepath.Join(dir, "one.yml"), filepath.Join(dir, "two.yml")); err != nil { t.Skipf("hard links unavailable: %v", err) } rootPath := writeImportRoot(t, dir, []string{"one.yml", "two.yml"}) _, err := LoadPipeline(rootPath) if err == nil || !strings.Contains(err.Error(), "same file") { t.Fatalf("LoadPipeline() error = %v, want same-file rejection", err) } }) t.Run("imported composition", func(t *testing.T) { dir := t.TempDir() rootPath := writeImportRoot(t, dir, []string{"nested.yml"}) writePipelineSource(t, dir, "nested.yml", "composition:\n imports: []\n") _, err := LoadPipeline(rootPath) if err == nil || !strings.Contains(err.Error(), "only the root pipeline") || !strings.Contains(err.Error(), "nested.yml") { t.Fatalf("LoadPipeline() error = %v, want imported composition rejection", err) } }) t.Run("future profile field", func(t *testing.T) { dir := t.TempDir() rootPath := writePipelineSource(t, dir, "pipeline.yml", "composition:\n profiles: {}\n") _, err := LoadPipeline(rootPath) if err == nil || !strings.Contains(err.Error(), "unknown composition field") || !strings.Contains(err.Error(), "profiles") { t.Fatalf("LoadPipeline() error = %v, want profile field rejected before its implementation", err) } }) } func TestLoadPipelineCompositionReportsImportedParseAndSchemaSources(t *testing.T) { t.Run("malformed imported YAML", func(t *testing.T) { dir := t.TempDir() rootPath := writeImportRoot(t, dir, []string{"broken.yml"}) brokenPath := writePipelineSource(t, dir, "broken.yml", "workspace: [\n") _, err := LoadPipeline(rootPath) if err == nil || !strings.Contains(err.Error(), absolutePath(t, brokenPath)) || !strings.Contains(err.Error(), "decode YAML") { t.Fatalf("LoadPipeline() error = %v, want imported parse source", err) } }) t.Run("unknown imported field", func(t *testing.T) { dir := t.TempDir() rootPath := writeImportRoot(t, dir, []string{"unknown.yml"}) unknownPath := writePipelineSource(t, dir, "unknown.yml", "unknown_field: true\n") _, err := LoadPipeline(rootPath) if err == nil || !strings.Contains(err.Error(), absolutePath(t, unknownPath)) || !strings.Contains(err.Error(), "strict decode failed") { t.Fatalf("LoadPipeline() error = %v, want assembled source-aware strict error", err) } }) } func TestLoadPipelineCompositionKeepsRelativePathsRootBased(t *testing.T) { rootDir := t.TempDir() monolithicPath := writePipelineSource(t, rootDir, "monolithic.yml", testPipelineBaseYAML+` notarius: enabled: true config_path: tool/notarius.yml pipeline_id: dnd-session outputs: npc_registry: lane_id: npc-registry media_type: application/json schema_id: notarius.dnd.npc_registry schema_version: v1 `) composedPath := writePipelineSource(t, rootDir, "pipeline.yml", `composition: imports: [conf.d/extraction.yml] `+testPipelineBaseYAML) writePipelineSource(t, rootDir, "conf.d/extraction.yml", `notarius: enabled: true config_path: tool/notarius.yml pipeline_id: dnd-session outputs: npc_registry: lane_id: npc-registry media_type: application/json schema_id: notarius.dnd.npc_registry schema_version: v1 `) monolithic, err := LoadPipeline(monolithicPath) if err != nil { t.Fatal(err) } composed, err := LoadPipeline(composedPath) if err != nil { t.Fatal(err) } want := filepath.Join(rootDir, "tool", "notarius.yml") if monolithic.Notarius.ConfigPath != want || composed.Notarius.ConfigPath != want { t.Fatalf("config paths = monolithic %q composed %q, want %q", monolithic.Notarius.ConfigPath, composed.Notarius.ConfigPath, want) } if monolithic.Notarius.WorkingDirectory != filepath.Dir(want) || composed.Notarius.WorkingDirectory != filepath.Dir(want) { t.Fatalf("working directories = %q / %q", monolithic.Notarius.WorkingDirectory, composed.Notarius.WorkingDirectory) } } func writeImportRoot(t *testing.T, dir string, imports []string) string { t.Helper() var builder strings.Builder builder.WriteString("composition:\n imports:\n") for _, imported := range imports { builder.WriteString(" - ") if imported == "" { builder.WriteString(`""`) } else { builder.WriteString(`"` + imported + `"`) } builder.WriteByte('\n') } builder.WriteString("whisperx:\n transcribe_url: https://transcription.example.com/transcribe\n") return writePipelineSource(t, dir, "pipeline.yml", builder.String()) } func writePipelineSource(t *testing.T, root, relative, content string) string { t.Helper() path := filepath.Join(root, filepath.FromSlash(relative)) 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) } return path } func absolutePath(t *testing.T, path string) string { t.Helper() absolute, err := filepath.Abs(path) if err != nil { t.Fatal(err) } return absolute } func assertPipelineFieldOwner(t *testing.T, cfg *PipelineConfig, path, source string) { t.Helper() if cfg == nil || cfg.resolution == nil { t.Fatal("pipeline resolution metadata is absent") } for _, ownership := range cfg.resolution.ownership { if ownership.path == path { if !reflect.DeepEqual(ownership.sources, []string{source}) { t.Fatalf("owner of %s = %#v, want %q", path, ownership.sources, source) } return } } t.Fatalf("ownership path %q not found: %#v", path, cfg.resolution.ownership) }