package config import ( "strings" "testing" "time" ) func TestValidatePublishTransformPolicy(t *testing.T) { tests := publishTransformPolicyCases() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidatePublishTransformPolicy(tt.publish, tt.transform) if tt.wantErr && err == nil { t.Fatal("ValidatePublishTransformPolicy() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("ValidatePublishTransformPolicy() error = %v", err) } }) } } func TestValidateChecksPublishTransformPolicy(t *testing.T) { tests := publishTransformPolicyCases() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{ Backend: BackendLocal, Path: "/source", }, Destinations: []Destination{{ ID: "archive", Backend: BackendLocal, Path: "/destination", Publish: &tt.publish, Transform: tt.transform, }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if tt.wantErr && err == nil { t.Fatal("Validate() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestValidatePathMapping(t *testing.T) { tests := []struct { name string mode string wantErr bool }{ {name: "preserve relative", mode: PathMappingPreserveRelative}, {name: "fixed", mode: PathMappingFixed}, {name: "invalid", mode: "archive", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{Backend: BackendLocal, Path: "/source"}, Destinations: []Destination{{ ID: "archive", Backend: BackendLocal, Path: "/destination", PathMap: PathMapping{Mode: tt.mode}, }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if tt.wantErr && err == nil { t.Fatal("Validate() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestValidateWorkflow(t *testing.T) { tests := []struct { name string workflow string wantErr bool }{ {name: "additive", workflow: WorkflowAdditive}, {name: "replacement", workflow: WorkflowReplacement}, {name: "invalid", workflow: "append", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{Backend: BackendLocal, Path: "/source"}, Destinations: []Destination{{ ID: "archive", Backend: BackendLocal, Path: "/destination", Workflow: tt.workflow, }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if tt.wantErr && err == nil { t.Fatal("Validate() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestValidateWorkflowReportsFieldContext(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{Backend: BackendLocal, Path: "/source"}, Destinations: []Destination{{ ID: "archive", Backend: BackendLocal, Path: "/destination", Workflow: "append", }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if err == nil { t.Fatal("Validate() error = nil, want error") } want := "pipelines[0].destinations[0].workflow must be additive or replacement" if !strings.Contains(err.Error(), want) { t.Fatalf("Validate() error = %q, want %q", err, want) } } func TestValidateRetentionPolicy(t *testing.T) { olderThan := Duration(24 * time.Hour) zeroDuration := Duration(0) keepZero := 0 keepThree := 3 keepNegative := -1 tests := []struct { name string retention RetentionPolicy wantErr bool }{ {name: "disabled"}, {name: "older than", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, OlderThan: &olderThan}}}, {name: "keep zero", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, KeepLatest: &keepZero}}}, {name: "keep latest", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, KeepLatest: &keepThree}}}, {name: "combined", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, OlderThan: &olderThan, KeepLatest: &keepThree}}}, {name: "missing policy", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true}}, wantErr: true}, {name: "zero older than", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, OlderThan: &zeroDuration}}, wantErr: true}, {name: "negative keep latest", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, KeepLatest: &keepNegative}}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{Backend: BackendLocal, Path: "/source"}, Destinations: []Destination{{ ID: "archive", Backend: BackendLocal, Path: "/destination", Retention: tt.retention, }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if tt.wantErr && err == nil { t.Fatal("Validate() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestValidateLinks(t *testing.T) { tests := []struct { name string links *Links wantErr bool }{ {name: "absent links"}, {name: "http", links: &Links{BaseURL: "http://reports.example.com/archive", Primary: LinkPrimaryAuto}}, {name: "https", links: &Links{BaseURL: "https://reports.example.com/archive/", Primary: LinkPrimaryHTML}}, {name: "source primary", links: &Links{BaseURL: "https://reports.example.com", Primary: LinkPrimarySource}}, {name: "missing base", links: &Links{Primary: LinkPrimaryAuto}, wantErr: true}, {name: "ftp scheme", links: &Links{BaseURL: "ftp://reports.example.com", Primary: LinkPrimaryAuto}, wantErr: true}, {name: "missing host", links: &Links{BaseURL: "https:///archive", Primary: LinkPrimaryAuto}, wantErr: true}, {name: "query", links: &Links{BaseURL: "https://reports.example.com/archive?preview=1", Primary: LinkPrimaryAuto}, wantErr: true}, {name: "fragment", links: &Links{BaseURL: "https://reports.example.com/archive#top", Primary: LinkPrimaryAuto}, wantErr: true}, {name: "invalid primary", links: &Links{BaseURL: "https://reports.example.com", Primary: "document"}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{Backend: BackendLocal, Path: "/source"}, Destinations: []Destination{{ ID: "web", Backend: BackendLocal, Path: "/destination", Links: tt.links, }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if tt.wantErr && err == nil { t.Fatal("Validate() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestValidateLinksReportsFieldContext(t *testing.T) { cfg := Config{Pipelines: []Pipeline{{ ID: "reports", Source: Backend{Backend: BackendLocal, Path: "/source"}, Destinations: []Destination{{ ID: "web", Backend: BackendLocal, Path: "/destination", Links: &Links{BaseURL: "https://reports.example.com/archive?preview=1", Primary: LinkPrimaryAuto}, }}, }}} ApplyDefaults(&cfg) err := Validate(cfg) if err == nil { t.Fatal("Validate() error = nil, want error") } want := "pipelines[0].destinations[0].links.base_url must not include a query string" if !strings.Contains(err.Error(), want) { t.Fatalf("Validate() error = %q, want %q", err, want) } } type publishTransformPolicyCase struct { name string publish PublishPolicy transform Transform wantErr bool } func publishTransformPolicyCases() []publishTransformPolicyCase { return []publishTransformPolicyCase{ { name: "source only allowed", publish: PublishPolicy{Source: true}, }, { name: "html only sidecar allowed", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeSidecar, }}, }, { name: "html only default mode allowed", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, }}, }, { name: "html only index allowed", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeIndex, }}, }, { name: "html only index input allowed", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeIndex, Input: "report.md", }}, }, { name: "html only sidecar css href allowed", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeSidecar, CssHref: "/assets/report.css", }}, }, { name: "html only index css href allowed", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeIndex, CssHref: "assets/report.css?v=20260614", }}, }, { name: "source and html sidecar allowed", publish: PublishPolicy{Source: true, HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeSidecar, }}, }, { name: "no outputs rejected", publish: PublishPolicy{}, wantErr: true, }, { name: "html without transform rejected", publish: PublishPolicy{HTML: true}, wantErr: true, }, { name: "html with disabled transform rejected", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, Mode: TransformModeSidecar, }}, wantErr: true, }, { name: "html with wrong mode rejected", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: "inline", }}, wantErr: true, }, { name: "source only enabled transform rejected", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeSidecar, }}, wantErr: true, }, { name: "enabled markdown wrong mode rejected", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: "inline", }}, wantErr: true, }, { name: "sidecar input rejected", publish: PublishPolicy{HTML: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: true, Mode: TransformModeSidecar, Input: "report.md", }}, wantErr: true, }, { name: "disabled markdown empty mode allowed", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, }}, }, { name: "disabled markdown sidecar mode allowed", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, Mode: TransformModeSidecar, }}, }, { name: "disabled markdown index mode allowed", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, Mode: TransformModeIndex, }}, }, { name: "disabled markdown input rejected", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, Mode: TransformModeIndex, Input: "report.md", }}, wantErr: true, }, { name: "disabled markdown css href rejected", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, Mode: TransformModeSidecar, CssHref: "/assets/report.css", }}, wantErr: true, }, { name: "disabled markdown wrong mode rejected", publish: PublishPolicy{Source: true}, transform: Transform{MarkdownToHTML: &MarkdownToHTML{ Enabled: false, Mode: "inline", }}, wantErr: true, }, } } func TestValidateCSSHref(t *testing.T) { tests := []struct { name string value string wantErr bool }{ {name: "empty"}, {name: "root relative", value: "/assets/report.css"}, {name: "relative", value: "assets/report.css"}, {name: "parent relative", value: "../assets/report.css"}, {name: "query", value: "/assets/report.css?v=20260614"}, {name: "http", value: "http://example.com/report.css"}, {name: "https", value: "https://example.com/assets/report.css?v=1"}, {name: "javascript", value: "javascript:alert(1)", wantErr: true}, {name: "data", value: "data:text/css,body{}", wantErr: true}, {name: "file", value: "file:///tmp/report.css", wantErr: true}, {name: "scheme relative", value: "//example.com/report.css", wantErr: true}, {name: "userinfo", value: "https://user@example.com/report.css", wantErr: true}, {name: "fragment", value: "/assets/report.css#main", wantErr: true}, {name: "space", value: "/assets/report css", wantErr: true}, {name: "tab", value: "/assets/report\tcss", wantErr: true}, {name: "newline", value: "/assets/report\ncss", wantErr: true}, {name: "backslash", value: `assets\report.css`, wantErr: true}, {name: "less than", value: "/assets/.css", wantErr: true}, {name: "double quote", value: `/assets/"report".css`, wantErr: true}, {name: "single quote", value: "/assets/'report'.css", wantErr: true}, {name: "query only", value: "?v=1", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateCSSHref(tt.value) if tt.wantErr && err == nil { t.Fatal("validateCSSHref() error = nil, want error") } if !tt.wantErr && err != nil { t.Fatalf("validateCSSHref() error = %v", err) } }) } }