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 got, want := destination.Reconciliation.Mode, ReconciliationModeReplace; got != want { t.Fatalf("reconciliation mode default = %q, want %q", got, want) } if got, want := destination.State.Mode, StateModeSingleOwner; got != want { t.Fatalf("state mode default = %q, want %q", got, want) } if got, want := destination.Takeover.Mode, TakeoverModeSamePipeline; got != want { t.Fatalf("takeover mode default = %q, want %q", got, want) } if destination.Retention.Prune.Enabled { t.Fatal("retention.prune.enabled default = true, want false") } 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 TestLoadFileDefaultsPathMappingToPreserveRelative(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /destination `) if got, want := cfg.Pipelines[0].Destinations[0].PathMap.Mode, PathMappingPreserveRelative; got != want { t.Fatalf("path mapping mode = %q, want %q", got, want) } } func TestLoadFileAcceptsExplicitReconciliationModes(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive reconciliation: mode: replace - id: web backend: local path: /web reconciliation: mode: merge `) destinations := cfg.Pipelines[0].Destinations if got, want := destinations[0].Reconciliation.Mode, ReconciliationModeReplace; got != want { t.Fatalf("archive reconciliation mode = %q, want %q", got, want) } if got, want := destinations[1].Reconciliation.Mode, ReconciliationModeMerge; got != want { t.Fatalf("web reconciliation mode = %q, want %q", got, want) } } func TestLoadFileAcceptsExplicitStateModes(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive state: mode: single_owner - id: web backend: local path: /web state: mode: shared_root `) destinations := cfg.Pipelines[0].Destinations if got, want := destinations[0].State.Mode, StateModeSingleOwner; got != want { t.Fatalf("archive state mode = %q, want %q", got, want) } if got, want := destinations[1].State.Mode, StateModeSharedRoot; got != want { t.Fatalf("web state mode = %q, want %q", got, want) } } func TestLoadFileAcceptsExplicitTakeoverModes(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: same-pipeline backend: local path: /same-pipeline takeover: mode: same_pipeline - id: same-source backend: local path: /same-source takeover: mode: same_source - id: any-managed backend: local path: /any-managed takeover: mode: any_managed - id: never backend: local path: /never takeover: mode: never `) destinations := cfg.Pipelines[0].Destinations wants := []string{ TakeoverModeSamePipeline, TakeoverModeSameSource, TakeoverModeAnyManaged, TakeoverModeNever, } for index, want := range wants { if got := destinations[index].Takeover.Mode; got != want { t.Fatalf("destinations[%d].takeover.mode = %q, want %q", index, got, want) } } } func TestLoadFileAcceptsRetentionPruneConfig(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive retention: prune: enabled: true older_than: 168h keep_latest: 3 `) prune := cfg.Pipelines[0].Destinations[0].Retention.Prune if !prune.Enabled { t.Fatal("retention.prune.enabled = false, want true") } if prune.OlderThan == nil || prune.OlderThan.String() != "168h0m0s" { t.Fatalf("retention.prune.older_than = %v, want 168h", prune.OlderThan) } if prune.KeepLatest == nil || *prune.KeepLatest != 3 { t.Fatalf("retention.prune.keep_latest = %v, want 3", prune.KeepLatest) } } func TestLoadFileAcceptsFixedPathMapping(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: latest backend: local path: /destination/latest path_mapping: mode: fixed `) if got, want := cfg.Pipelines[0].Destinations[0].PathMap.Mode, PathMappingFixed; got != want { t.Fatalf("path mapping mode = %q, want %q", got, want) } } func TestLoadFileDefaultsLinksPrimaryToAuto(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: web backend: local path: /destination links: base_url: https://reports.example.com/archive `) links := cfg.Pipelines[0].Destinations[0].Links if links == nil { t.Fatal("links = nil, want config") } if links.BaseURL != "https://reports.example.com/archive" || links.Primary != LinkPrimaryAuto { t.Fatalf("links = %#v, want base URL with auto primary", links) } } func TestLoadFileDefaultsHTTPServerConfig(t *testing.T) { cfg := loadConfig(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive `) server := cfg.Server.HTTP if got, want := server.Bind, DefaultHTTPBind; got != want { t.Fatalf("server.http.bind = %q, want %q", got, want) } if got, want := server.StagingRoot, DefaultHTTPStagingRoot; got != want { t.Fatalf("server.http.staging_root = %q, want %q", got, want) } if got, want := *server.MaxUploadSize, DefaultHTTPMaxUploadSize; got != want { t.Fatalf("server.http.max_upload_size = %s, want %s", got, want) } if got, want := server.QueueSize, DefaultHTTPQueueSize; got != want { t.Fatalf("server.http.queue_size = %d, want %d", got, want) } if got, want := server.MaxConcurrency, DefaultHTTPMaxConcurrency; got != want { t.Fatalf("server.http.max_concurrency = %d, want %d", got, want) } if got, want := *server.Retention, DefaultHTTPRetention; got != want { t.Fatalf("server.http.retention = %s, want %s", got, want) } } func TestLoadFileAcceptsHTTPUploadSourceConfig(t *testing.T) { cfg := loadConfig(t, ` server: http: bind: 127.0.0.1:9090 staging_root: /srv/distributor/staging max_upload_size: 64MB queue_size: 32 max_concurrency: 2 retention: 48h pipelines: - id: weather-daily source: backend: http_upload staging_path: /srv/distributor/staging/weather-daily max_upload_size: 32MB destinations: - id: archive backend: local path: /archive upload_tokens: - id: weather-reporter token_env: WEATHER_DAILY_UPLOAD_TOKEN allow_pipelines: - weather-daily `) server := cfg.Server.HTTP if got, want := server.Bind, "127.0.0.1:9090"; got != want { t.Fatalf("server.http.bind = %q, want %q", got, want) } if got, want := server.StagingRoot, "/srv/distributor/staging"; got != want { t.Fatalf("server.http.staging_root = %q, want %q", got, want) } if got, want := *server.MaxUploadSize, ByteSize(64*1024*1024); got != want { t.Fatalf("server.http.max_upload_size = %s, want %s", got, want) } if got, want := server.QueueSize, 32; got != want { t.Fatalf("server.http.queue_size = %d, want %d", got, want) } if got, want := server.MaxConcurrency, 2; got != want { t.Fatalf("server.http.max_concurrency = %d, want %d", got, want) } if got, want := server.Retention.String(), "48h0m0s"; got != want { t.Fatalf("server.http.retention = %s, want %s", got, want) } source := cfg.Pipelines[0].Source if got, want := source.Backend, BackendHTTPUpload; got != want { t.Fatalf("source.backend = %q, want %q", got, want) } if got, want := source.Upload.StagingPath, "/srv/distributor/staging/weather-daily"; got != want { t.Fatalf("source.staging_path = %q, want %q", got, want) } if got, want := *source.Upload.MaxUploadSize, ByteSize(32*1024*1024); got != want { t.Fatalf("source.max_upload_size = %s, want %s", got, want) } } func TestLoadFileDefaultsHTTPUploadSourceConfig(t *testing.T) { cfg := loadConfig(t, ` server: http: max_upload_size: 12MB pipelines: - id: weather-daily source: backend: http_upload destinations: - id: archive backend: local path: /archive upload_tokens: - id: weather-reporter token_env: WEATHER_DAILY_UPLOAD_TOKEN allow_pipelines: - weather-daily `) source := cfg.Pipelines[0].Source if got, want := source.Upload.StagingPath, "/var/spool/distributor/weather-daily"; got != want { t.Fatalf("source.staging_path = %q, want %q", got, want) } if got, want := *source.Upload.MaxUploadSize, ByteSize(12*1024*1024); got != want { t.Fatalf("source.max_upload_size = %s, want %s", got, want) } } func TestLoadFileAcceptsHTTPUploadTokens(t *testing.T) { tests := map[string]string{ "valid multi pipeline token": ` pipelines: - id: weather-daily source: backend: http_upload destinations: - id: archive backend: local path: /archive/weather - id: calendar-daily source: backend: http_upload destinations: - id: archive backend: local path: /archive/calendar upload_tokens: - id: reporter token_env: REPORTER_UPLOAD_TOKEN allow_pipelines: - weather-daily - calendar-daily `, "multiple tokens for one pipeline": ` pipelines: - id: reports source: backend: http_upload destinations: - id: archive backend: local path: /archive upload_tokens: - id: reporter-a token_env: REPORTER_A_UPLOAD_TOKEN allow_pipelines: - reports - id: reporter-b token_env: REPORTER_B_UPLOAD_TOKEN allow_pipelines: - reports `, } for name, body := range tests { t.Run(name, func(t *testing.T) { loadConfig(t, body) }) } } func TestLoadFileRejectsInvalidUploadTokens(t *testing.T) { tests := map[string]struct { body string want string }{ "missing token list": { body: `pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "upload_tokens is required", }, "duplicate token ids": { body: `upload_tokens: [{id: reporter, token_env: ONE_UPLOAD_TOKEN, allow_pipelines: [reports]}, {id: reporter, token_env: TWO_UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "upload token id reporter is duplicated", }, "duplicate allowlist entries": { body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports, reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "allow_pipelines contains duplicate pipeline id reports", }, "unknown allowed pipeline id": { body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [missing]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "references unknown pipeline missing", }, "non upload allowed pipeline id": { body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}, {id: uploader, token_env: OTHER_UPLOAD_TOKEN, allow_pipelines: [upload]}] pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}, {id: upload, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive-upload}]}]`, want: "references non-http_upload pipeline reports", }, "upload pipeline not allowed": { body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}, {id: other, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive-other}]}]`, want: "http_upload pipeline other is not allowed by any upload token", }, "missing token id": { body: `upload_tokens: [{token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "upload_tokens[0].id is required", }, "invalid token id": { body: `upload_tokens: [{id: ".reporter", token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "upload_tokens[0].id must be a slug-like identifier", }, "missing token env": { body: `upload_tokens: [{id: reporter, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "upload_tokens[0].token_env is required", }, "missing allowlist": { body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, want: "upload_tokens[0].allow_pipelines is required", }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { assertLoadError(t, tt.body, tt.want) }) } } 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 TestLoadFileRejectsInvalidHTTPUploadConfig(t *testing.T) { tests := map[string]string{ "server size": `server: {http: {max_upload_size: 20XB}}`, "source size": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload, max_upload_size: 20XB}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "zero source size": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload, max_upload_size: 0B}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "server duration": `server: {http: {retention: forever}}`, "zero server duration": `server: {http: {retention: 0s}}`, "prune duration": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive, retention: {prune: {enabled: true, older_than: forever}}}]}]`, "missing upload tokens": `pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "destination http upload": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: ingest, backend: http_upload}]}]`, "literal token": `upload_tokens: [{id: reporter, token: secret, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "unknown server field": `server: {http: {surprise: true}}`, "legacy source token env": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload, token_env: UPLOAD_TOKEN}, destinations: [{id: archive, backend: local, path: /archive}]}]`, "unknown source field": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}] pipelines: [{id: reports, source: {backend: http_upload, surprise: true}, 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 TestLoadFileRejectsInvalidTakeoverMode(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive takeover: mode: unmanaged `, "takeover.mode must be same_pipeline, same_source, any_managed, or never") } func TestLoadFileRejectsUnknownTakeoverFields(t *testing.T) { assertLoadError(t, ` pipelines: - id: reports source: backend: local path: /source destinations: - id: archive backend: local path: /archive takeover: surprise: true `, "field surprise not found") } 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/archive-and-latest.yml", "../../examples/merge-reconciliation.yml", "../../examples/shared-root.yml", "../../examples/http-upload-local.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 }