package config import ( "os" "path/filepath" "strings" "testing" ) func TestLoadFileValidMinimalLocalToLocalConfig(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: local-copy source: backend: local path: /var/spool/reports destinations: - id: archive backend: local path: /srv/archive `) if got, want := len(cfg.Pipelines), 1; got != want { t.Fatalf("pipeline count = %d, want %d", got, want) } destination := cfg.Pipelines[0].Destinations[0] if !destination.Publish.Source || destination.Publish.HTML { t.Fatalf("publish defaults = source:%t html:%t, want source:true html:false", destination.Publish.Source, destination.Publish.HTML) } if got, want := cfg.Pipelines[0].Validation.OnDigestMismatch, ValidationActionFail; got != want { t.Fatalf("validation default = %q, want %q", got, want) } if got, want := destination.Transfer.OnDestinationOlder, TransferActionReplace; got != want { t.Fatalf("transfer default = %q, want %q", got, want) } if cfg.Secrets.Directory != "" { t.Fatalf("secrets.directory = %q, want empty", cfg.Secrets.Directory) } } func TestLoadFileValidSecretsDirectoryConfig(t *testing.T) { cfg := loadConfig(t, ` secrets: directory: /run/secrets/distributor pipelines: - id: local-copy source: backend: local path: /var/spool/reports destinations: - id: archive backend: local path: /srv/archive `) if got, want := cfg.Secrets.Directory, "/run/secrets/distributor"; got != want { t.Fatalf("secrets.directory = %q, want %q", got, want) } } func TestLoadFileValidFanOutConfig(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: fan-out source: backend: local path: /var/spool/reports destinations: - id: markdown-archive backend: s3 endpoint: https://s3.example.com bucket: reports prefix: archive publish: source: true html: false - id: static-site backend: ssh host: example.com user: deploy port: 22 path: /srv/www/reports publish: source: false html: true transform: markdown_to_html: enabled: true mode: sidecar `) if got, want := len(cfg.Pipelines[0].Destinations), 2; got != want { t.Fatalf("destination count = %d, want %d", got, want) } } func TestLoadFileValidMarkdownIndexConfig(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: static-site source: backend: local path: /var/spool/reports destinations: - id: web backend: local path: /srv/www/reports publish: source: false html: true transform: markdown_to_html: enabled: true mode: index input: report.md `) markdown := cfg.Pipelines[0].Destinations[0].Transform.MarkdownToHTML if markdown == nil || markdown.Mode != TransformModeIndex || markdown.Input != "report.md" { t.Fatalf("markdown config = %#v, want index input", markdown) } } func TestLoadFileDefaultsMarkdownModeToSidecar(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: static-site source: backend: local path: /var/spool/reports destinations: - id: web backend: local path: /srv/www/reports publish: source: false html: true transform: markdown_to_html: enabled: true `) markdown := cfg.Pipelines[0].Destinations[0].Transform.MarkdownToHTML if markdown == nil || markdown.Mode != TransformModeSidecar { t.Fatalf("markdown mode = %#v, want sidecar default", markdown) } } func TestLoadFileValidBackendConfigs(t *testing.T) { tests := map[string]string{ "local": ` pipelines: - id: local-backend source: backend: local path: /source destinations: - id: local-destination backend: local path: /destination `, "ssh": ` pipelines: - id: ssh-backend source: backend: ssh host: source.example.com user: reports path: /source destinations: - id: ssh-destination backend: ssh host: destination.example.com user: deploy port: 2222 path: /destination ssh_key_file: /home/deploy/.ssh/id_ed25519 known_hosts: /home/deploy/.ssh/known_hosts host_key_policy: strict `, "s3": ` pipelines: - id: s3-backend source: backend: s3 endpoint: https://s3.example.com bucket: source prefix: incoming region: us-east-1 force_path_style: true credentials: access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY destinations: - id: s3-destination backend: s3 endpoint: https://s3.example.com bucket: destination prefix: archive `, } for name, body := range tests { t.Run(name, func(t *testing.T) { loadConfig(t, body) }) } } func TestLoadFileDefaultsS3Config(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: s3-defaults source: backend: s3 endpoint: http://127.0.0.1:9000 bucket: source prefix: /incoming/reports/ destinations: - id: archive backend: s3 endpoint: http://127.0.0.1:9000 bucket: destination `) source := cfg.Pipelines[0].Source if got, want := source.Region, DefaultS3Region; got != want { t.Fatalf("source region = %q, want %q", got, want) } if got, want := source.Prefix, "incoming/reports"; got != want { t.Fatalf("source prefix = %q, want %q", got, want) } if !ForcePathStyle(source.ForcePath) { t.Fatal("source force_path_style = false, want true") } destination := cfg.Pipelines[0].Destinations[0] if got, want := destination.Region, DefaultS3Region; got != want { t.Fatalf("destination region = %q, want %q", got, want) } if !ForcePathStyle(destination.ForcePath) { t.Fatal("destination force_path_style = false, want true") } } func TestLoadFilePreservesExplicitS3ForcePathStyleFalse(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: s3-force-path source: backend: s3 endpoint: https://s3.example.com bucket: source force_path_style: false destinations: - id: archive backend: local path: /archive `) if ForcePathStyle(cfg.Pipelines[0].Source.ForcePath) { t.Fatal("force_path_style = true, want explicit false") } } func TestLoadFileRejectsDuplicatePipelineIDs(t *testing.T) { assertLoadError(t, ` pipelines: - id: duplicate source: backend: local path: /one destinations: - id: archive backend: local path: /archive - id: duplicate source: backend: local path: /two destinations: - id: archive backend: local path: /archive `, "pipeline id duplicate is duplicated") } func TestLoadFileRejectsDuplicateDestinationIDs(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive-one - id: archive backend: local path: /archive-two `, "destination id archive is duplicated") } func TestLoadFileRejectsMissingRequiredFields(t *testing.T) { tests := map[string]string{ "pipelines": ``, "pipeline id": `pipelines: [{source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "source backend": `pipelines: [{id: reports, source: {path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "destinations": `pipelines: [{id: reports, source: {backend: local, path: /source}}]`, "destination id": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{backend: local, path: /archive}]}]`, "local path": `pipelines: [{id: reports, source: {backend: local}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "ssh host": `pipelines: [{id: reports, source: {backend: ssh, path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "s3 bucket": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com"}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "publish outputs": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive, publish: {source: false, html: false}}]}]`, } for name, body := range tests { t.Run(name, func(t *testing.T) { assertLoadError(t, body, "") }) } } func TestLoadFileRejectsInvalidS3Config(t *testing.T) { tests := map[string]string{ "prefix traversal": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, prefix: "../reports"}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "prefix backslash": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, prefix: 'a\b'}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "partial creds": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, credentials: {access_key_id_env: ACCESS_KEY_ID}}, destinations: [{id: archive, backend: local, path: /archive}]}]`, } for name, body := range tests { t.Run(name, func(t *testing.T) { assertLoadError(t, body, "") }) } } func TestLoadFileDefaultsSSHConfig(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: ssh-defaults source: backend: ssh host: source.example.com path: /source destinations: - id: archive backend: ssh host: destination.example.com path: /archive host_key_policy: false `) source := cfg.Pipelines[0].Source if source.Port != 22 { t.Fatalf("source port = %d, want 22", source.Port) } if source.SSH.HostKeyPolicy != HostKeyPolicyAcceptNew { t.Fatalf("source host key policy = %q, want accept-new", source.SSH.HostKeyPolicy) } destination := cfg.Pipelines[0].Destinations[0] if destination.Port != 22 { t.Fatalf("destination port = %d, want 22", destination.Port) } if destination.SSH.HostKeyPolicy != HostKeyPolicyOff { t.Fatalf("destination host key policy = %q, want off", destination.SSH.HostKeyPolicy) } } func TestLoadFileNormalizesSSHHostKeyPolicies(t *testing.T) { tests := map[string]HostKeyPolicy{ `true`: HostKeyPolicyStrict, `"true"`: HostKeyPolicyStrict, `strict`: HostKeyPolicyStrict, `accept-new`: HostKeyPolicyAcceptNew, `false`: HostKeyPolicyOff, `"false"`: HostKeyPolicyOff, `off`: HostKeyPolicyOff, `"STRICT"`: HostKeyPolicyStrict, `"ACCEPT-NEW"`: HostKeyPolicyAcceptNew, `"OFF"`: HostKeyPolicyOff, } for value, want := range tests { t.Run(value, func(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: ssh-policy source: backend: ssh host: source.example.com path: /source host_key_policy: `+value+` destinations: - id: archive backend: local path: /archive `) if got := cfg.Pipelines[0].Source.SSH.HostKeyPolicy; got != want { t.Fatalf("host key policy = %q, want %q", got, want) } }) } } func TestLoadFileRejectsLegacySSHURIFieldAsUnknown(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: ssh uri: ssh://reports@example.com:22 path: /source destinations: - id: archive backend: local path: /archive `, "field uri not found") } func TestLoadFileRejectsUnsupportedBackend(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: ftp path: /source destinations: - id: archive backend: local path: /archive `, "backend ftp is unsupported") } func TestLoadFileRejectsInvalidTransferAction(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive transfer: on_destination_older: overwrite `, "on_destination_older must be replace or fail") } func TestLoadFileRejectsInvalidValidationAction(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: local path: /source validation: on_digest_mismatch: warn destinations: - id: archive backend: local path: /archive `, "on_digest_mismatch must be fail") } func TestLoadFileRejectsHTMLPublishWithoutTransform(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: static backend: local path: /srv/www publish: source: false html: true `, "markdown_to_html is required") } func TestLoadFileRejectsUnknownFields(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports surprise: true source: backend: local path: /source destinations: - id: archive backend: local path: /archive `, "field surprise not found") } func TestLoadFileRejectsUnknownSecretsFields(t *testing.T) { assertLoadError(t, ` secrets: directory: /run/secrets/distributor surprise: true pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive `, "field surprise not found") } func TestExampleConfigsLoad(t *testing.T) { for _, path := range []string{ "../../examples/local-to-local.yml", "../../examples/local-publish.yml", "../../examples/local-html.yml", "../../examples/local-index.yml", "../../examples/fan-out.yml", "../../examples/ssh-destination.yml", "../../examples/s3-destination.yml", } { t.Run(path, func(t *testing.T) { if _, err := LoadFile(path); err != nil { t.Fatalf("LoadFile(%q) error = %v", path, err) } }) } } func loadConfig(t *testing.T, body string) Config { t.Helper() path := writeConfig(t, body) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } return cfg } func assertLoadError(t *testing.T, body, want string) { t.Helper() path := writeConfig(t, body) _, err := LoadFile(path) if err == nil { t.Fatal("LoadFile() error = nil, want error") } if want != "" && !strings.Contains(err.Error(), want) { t.Fatalf("LoadFile() error = %q, want substring %q", err.Error(), want) } } func writeConfig(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(path, []byte(strings.TrimSpace(body)+"\n"), 0o600); err != nil { t.Fatalf("write config: %v", err) } return path }