package config import ( "os" "path/filepath" "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gopkg.in/yaml.v3" ) func TestDefaults(t *testing.T) { cfg, err := Load(LoadOptions{}) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.WeatherAPI.Units != "us" { t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units) } if cfg.WeatherAPI.Timezone != "America/Chicago" { t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone) } if cfg.WeatherAPI.Format != "json" { t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format) } if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location) } if cfg.Secrets.Directory != "" { t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory) } if cfg.Notify.Distributor.Enabled { t.Fatalf("Notify.Distributor.Enabled = true, want false") } if cfg.Notify.Distributor.Endpoint != "https://distributor.example.com" { t.Fatalf("Notify.Distributor.Endpoint = %q, want default endpoint", cfg.Notify.Distributor.Endpoint) } if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" { t.Fatalf("Notify.Distributor.TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv) } if cfg.Notify.Distributor.Timeout != 30*time.Second { t.Fatalf("Notify.Distributor.Timeout = %s, want 30s", cfg.Notify.Distributor.Timeout) } if cfg.Notify.Distributor.FailurePolicy != NotifyFailureError { t.Fatalf("Notify.Distributor.FailurePolicy = %q, want error", cfg.Notify.Distributor.FailurePolicy) } if cfg.Notify.Distributor.PipelineIDTemplate != "" { t.Fatalf("Notify.Distributor.PipelineIDTemplate = %q, want empty", cfg.Notify.Distributor.PipelineIDTemplate) } if cfg.Notify.Distributor.BundleIDTemplate != "weatherreporter.{location_id}.{report_id}" { t.Fatalf("Notify.Distributor.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.BundleIDTemplate) } if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" { t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate) } if !cfg.Notify.Distributor.Batch.Enabled { t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true") } if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" { t.Fatalf("Notify.Distributor.Batch.PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate) } if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" { t.Fatalf("Notify.Distributor.Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate) } if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" { t.Fatalf("Notify.Distributor.Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate) } if cfg.MissingSource.Default != MissingSourceWarn { t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) } } func TestLoadExampleConfig(t *testing.T) { cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml")) if err != nil { t.Fatalf("LoadFile() error = %v", err) } if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" { t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL) } if cfg.WeatherAPI.Timeout != 15*time.Second { t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout) } if cfg.MissingSource.Sources["alerts"] != MissingSourceNone { t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"]) } if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { t.Fatalf("Location = %#v, want example location", cfg.Location) } if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" { t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate) } if !cfg.Notify.Distributor.Batch.Enabled { t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true") } if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" { t.Fatalf("Batch PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate) } if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" { t.Fatalf("Batch BundleIDTemplate = %q, want example batch bundle template", cfg.Notify.Distributor.Batch.BundleIDTemplate) } if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" { t.Fatalf("Batch IdempotencyKeyTemplate = %q, want example batch idempotency template", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate) } overrides, err := cfg.ReportModuleOverrides() if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } hourly := overrides[report.Hourly] if len(hourly) != 9 { t.Fatalf("hourly example override length = %d, want 9", len(hourly)) } if hourly[0].ID != module.Metadata || hourly[1].ID != module.CurrentConditions || hourly[2].ID != module.HourlyForecast || hourly[3].ID != module.PrecipTiming || hourly[4].ID != module.AlertDigest || hourly[5].ID != module.SPCConvectiveOutlooks || hourly[6].ID != module.AreaForecastDiscussion || hourly[7].ID != module.SPCConvectiveDiscussion || hourly[8].ID != module.WeatherStory { t.Fatalf("hourly example override = %#v, want configured module order", hourly) } } func TestLoadMinimalExampleConfig(t *testing.T) { cfg, err := LoadFile(filepath.Join("..", "..", "examples", "minimal-config.yml")) if err != nil { t.Fatalf("LoadFile() error = %v", err) } if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" { t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL) } if cfg.WeatherAPI.Units != "us" { t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units) } if cfg.Scriptorium.Binary != "scriptorium" { t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary) } if cfg.Workspace.Root != "workspace" { t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root) } if cfg.Workspace.NotificationsDir != "notifications" { t.Fatalf("Workspace.NotificationsDir = %q, want notifications", cfg.Workspace.NotificationsDir) } if cfg.Location.Name != "Brentwood" { t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name) } } func TestLoadReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: daily: deterministic_modules: - metadata - current_conditions - alert_digest - spc_convective_outlooks - id: area_forecast_discussion options: sections: - short_term - spc_convective_discussion `) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } overrides, err := cfg.ReportModuleOverrides() if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } items := overrides[report.Daily] if len(items) != 6 { t.Fatalf("daily override length = %d, want 6", len(items)) } if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AlertDigest || items[3].ID != module.SPCConvectiveOutlooks || items[4].ID != module.AreaForecastDiscussion || items[5].ID != module.SPCConvectiveDiscussion { t.Fatalf("daily override = %#v, want configured module order", items) } options, ok := items[4].Options.(module.AreaForecastDiscussionOptions) if !ok { t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options) } if strings.Join(options.Sections, ",") != "short_term" { t.Fatalf("AFD sections = %#v, want short_term", options.Sections) } } func TestLoadTodayReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: today: deterministic_modules: - metadata - current_conditions - derived_daily_summary - derived_daypart_summaries - today_planning `) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } overrides, err := cfg.ReportModuleOverrides() if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } items := overrides[report.Today] if len(items) != 5 { t.Fatalf("today override length = %d, want 5", len(items)) } want := []module.ID{ module.Metadata, module.CurrentConditions, module.DerivedDailySummary, module.DerivedDaypartSummaries, module.TodayPlanning, } for i, id := range want { if items[i].ID != id { t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id) } } if _, ok := overrides[report.Daily]; ok { t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily]) } } func TestLoadHourlyReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: hourly: deterministic_modules: - metadata - hourly_forecast - precip_timing - alert_digest `) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } overrides, err := cfg.ReportModuleOverrides() if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } items := overrides[report.Hourly] if len(items) != 4 { t.Fatalf("hourly override length = %d, want 4", len(items)) } if items[0].ID != module.Metadata || items[1].ID != module.HourlyForecast || items[2].ID != module.PrecipTiming || items[3].ID != module.AlertDigest { t.Fatalf("hourly override = %#v, want configured module order", items) } } func TestLoadReportModuleOverrideAliases(t *testing.T) { path := writeConfig(t, ` reports: three-day-outlook: deterministic_modules: - metadata weekend_outlook: deterministic_modules: - metadata storm_report: deterministic_modules: - metadata `) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } overrides, err := cfg.ReportModuleOverrides() if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata { t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay]) } if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata { t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend]) } if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata { t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm]) } } func TestLoadReportDistributorPathOverrides(t *testing.T) { path := writeConfig(t, ` reports: daily: distributor: path_templates: - "daily/{valid_start_date}/{run_id}.md" - "daily/{valid_start_date}/index.md" today: deterministic_modules: - metadata `) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } daily := cfg.Reports["daily"].Distributor if !daily.PathTemplatesSet() { t.Fatal("daily distributor path_templates set = false, want true") } want := []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", } if !reflect.DeepEqual(daily.PathTemplates, want) { t.Fatalf("daily path templates = %#v, want %#v", daily.PathTemplates, want) } if cfg.Reports["today"].Distributor.PathTemplatesSet() { t.Fatal("today distributor path_templates set = true, want false") } overrides, err := cfg.ReportDistributorPathOverrides() if err != nil { t.Fatalf("ReportDistributorPathOverrides() error = %v", err) } if !reflect.DeepEqual(overrides[report.Daily], want) { t.Fatalf("daily distributor override = %#v, want %#v", overrides[report.Daily], want) } if _, ok := overrides[report.Today]; ok { t.Fatalf("today distributor override = %#v, want omitted override absent", overrides[report.Today]) } } func TestReportDistributorPathTemplatesSetTracksExplicitEmptyList(t *testing.T) { var cfg Config if err := yaml.Unmarshal([]byte(` reports: daily: distributor: path_templates: [] today: distributor: {} `), &cfg); err != nil { t.Fatalf("yaml.Unmarshal() error = %v", err) } if !cfg.Reports["daily"].Distributor.PathTemplatesSet() { t.Fatal("daily distributor path_templates set = false, want true") } if len(cfg.Reports["daily"].Distributor.PathTemplates) != 0 { t.Fatalf("daily path templates = %#v, want empty explicit list", cfg.Reports["daily"].Distributor.PathTemplates) } if cfg.Reports["today"].Distributor.PathTemplatesSet() { t.Fatal("today distributor path_templates set = true, want false") } } func TestLoadReportDistributorPathOverrideAliases(t *testing.T) { path := writeConfig(t, ` reports: three-day-outlook: distributor: path_templates: - "three-day/{valid_start_date}/index.md" weekend_outlook: distributor: path_templates: - "weekend/{valid_start_date}/index.md" `) cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } overrides, err := cfg.ReportDistributorPathOverrides() if err != nil { t.Fatalf("ReportDistributorPathOverrides() error = %v", err) } if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) { t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay]) } if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) { t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend]) } } func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) { cfg := Defaults() rawOptions := map[string]any{ "sections": []any{"short_term"}, } cfg.Reports = map[string]ReportConfig{ "daily": { DeterministicModules: []ModuleConfigItem{ {ID: module.Metadata}, {ID: module.AreaForecastDiscussion, Options: rawOptions}, }, deterministicModulesSet: true, }, } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any) if !ok || !reflect.DeepEqual(got, rawOptions) { t.Fatalf("Options after Validate = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options) } } func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(t *testing.T) { cfg := Defaults() rawOptions := map[string]any{ "sections": []any{"short_term"}, } cfg.Reports = map[string]ReportConfig{ "daily": { DeterministicModules: []ModuleConfigItem{ {ID: module.Metadata}, {ID: module.AreaForecastDiscussion, Options: rawOptions}, }, deterministicModulesSet: true, }, } overrides, err := cfg.ReportModuleOverrides() if err != nil { t.Fatalf("ReportModuleOverrides() error = %v", err) } options, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions) if !ok { t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options) } if strings.Join(options.Sections, ",") != "short_term" { t.Fatalf("override sections = %#v, want short_term", options.Sections) } if got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any); !ok || !reflect.DeepEqual(got, rawOptions) { t.Fatalf("config options after ReportModuleOverrides = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options) } } func TestValidateReportModuleAliasesDirectly(t *testing.T) { retiredDailyKey := retiredDailyReportKeyForTest() tests := []struct { name string reports map[string]ReportConfig wantErr string }{ { name: "RetiredDailyReportID", reports: map[string]ReportConfig{ "daily": {}, retiredDailyKey: {}, }, wantErr: "reports." + retiredDailyKey, }, { name: "TodayAndDailyAreDistinct", reports: map[string]ReportConfig{ "daily": {}, "today": {}, }, }, { name: "UnknownReport", reports: map[string]ReportConfig{ "moon": {}, }, wantErr: "reports.moon", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Defaults() cfg.Reports = tt.reports err := Validate(cfg) if tt.wantErr == "" { if err != nil { t.Fatalf("Validate() error = %v", err) } return } if err == nil { t.Fatal("Validate() error = nil, want report key error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("Validate() error = %q, want %q", err.Error(), tt.wantErr) } }) } } func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) { cfg := Defaults() cfg.Reports = map[string]ReportConfig{ "moon": { DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}}, deterministicModulesSet: true, }, } _, err := cfg.ReportModuleOverrides() if err == nil { t.Fatal("ReportModuleOverrides() error = nil, want invalid report key") } if !strings.Contains(err.Error(), "reports.moon") { t.Fatalf("ReportModuleOverrides() error = %q, want report key context", err.Error()) } } func TestReportModuleOverrideValidation(t *testing.T) { retiredDailyKey := retiredDailyReportKeyForTest() tests := []struct { name string yaml string wantErr string }{ { name: "UnknownReport", yaml: ` reports: moon: deterministic_modules: - metadata `, wantErr: "reports.moon", }, { name: "UnknownModule", yaml: ` reports: daily: deterministic_modules: - missing_module `, wantErr: `unknown module "missing_module"`, }, { name: "DuplicateModule", yaml: ` reports: daily: deterministic_modules: - metadata - metadata `, wantErr: `duplicate module "metadata"`, }, { name: "IncompatibleModule", yaml: ` reports: daily: deterministic_modules: - tomorrow_planning `, wantErr: `not compatible with report "daily"`, }, { name: "RemovedPlaceholderModule", yaml: ` reports: daily: deterministic_modules: - forecast_delta `, wantErr: `unknown module "forecast_delta"`, }, { name: "RetiredTomorrowReportID", yaml: ` reports: daily_tomorrow: deterministic_modules: - metadata `, wantErr: "reports.daily_tomorrow", }, { name: "InvalidOptions", yaml: ` reports: daily: deterministic_modules: - id: metadata options: sections: - short_term `, wantErr: "options are invalid", }, { name: "RetiredDailyReportID", yaml: ` reports: daily: deterministic_modules: - metadata ` + retiredDailyKey + `: deterministic_modules: - current_conditions `, wantErr: "reports." + retiredDailyKey, }, { name: "HourlyIncompatibleDailyModule", yaml: ` reports: hourly: deterministic_modules: - derived_daily_summary `, wantErr: `not compatible with report "hourly"`, }, { name: "UnknownReportField", yaml: ` reports: daily: modules: - metadata `, wantErr: `unknown report entry field "modules"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := LoadFile(writeConfig(t, tt.yaml)) if err == nil { t.Fatal("LoadFile() error = nil, want validation error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } }) } } func TestReportDistributorPathOverrideValidation(t *testing.T) { tests := []struct { name string yaml string wantErr string }{ { name: "UnknownReportField", yaml: ` reports: daily: distributor_paths: - latest.md `, wantErr: `unknown report entry field "distributor_paths"`, }, { name: "UnknownDistributorField", yaml: ` reports: daily: distributor: paths: - latest.md `, wantErr: `unknown report distributor field "paths"`, }, { name: "DuplicateReportAlias", yaml: ` reports: three-day: distributor: path_templates: - "three-day/{valid_start_date}/index.md" three_day: distributor: path_templates: - "three-day/latest.md" `, wantErr: "duplicates report override", }, { name: "UnknownTemplateVariable", yaml: ` reports: daily: distributor: path_templates: - "{unknown}.md" `, wantErr: `reports.daily.distributor.path_templates[0] contains unknown template variable "unknown"`, }, { name: "AbsolutePath", yaml: ` reports: daily: distributor: path_templates: - "/daily.md" `, wantErr: "reports.daily.distributor.path_templates[0] must render a relative path", }, { name: "ParentSegment", yaml: ` reports: daily: distributor: path_templates: - "daily/../index.md" `, wantErr: "reports.daily.distributor.path_templates[0] must not render . or .. path segments", }, { name: "Manifest", yaml: ` reports: daily: distributor: path_templates: - "daily/manifest.json" `, wantErr: `reports.daily.distributor.path_templates[0] must not render reserved path segment "manifest.json"`, }, { name: "DuplicateRenderedPath", yaml: ` reports: daily: distributor: path_templates: - "daily/index.md" - "daily/index.md" `, wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`, }, { name: "NonStormStormIDEmptyPathSegment", yaml: ` reports: daily: distributor: path_templates: - "daily/{storm_id}/index.md" `, wantErr: "reports.daily.distributor.path_templates[0] must not render empty path segments", }, { name: "EmptyOverrideList", yaml: ` reports: daily: distributor: path_templates: [] `, wantErr: "reports.daily.distributor.path_templates must contain at least one entry", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := LoadFile(writeConfig(t, tt.yaml)) if err == nil { t.Fatal("LoadFile() error = nil, want validation error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } }) } } func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) { _, err := LoadFile(writeConfig(t, ` reports: daily: distributor: path_templates: - "daily/storm-{storm_id}.md" storm: distributor: path_templates: - "storm/{storm_id}/index.md" `)) if err != nil { t.Fatalf("LoadFile() error = %v", err) } } func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) { yaml := ` reports: daily: distributor: path_templates: - "daily/{valid_start_date}/index.md" ` reports := map[string]ReportConfig{ "daily": { Distributor: ReportDistributorConfig{ PathTemplates: []string{"daily/{valid_start_date}/index.md"}, pathTemplatesSet: true, }, }, } cfg, err := LoadFile(writeConfig(t, yaml)) if err != nil { t.Fatalf("LoadFile() error = %v", err) } loaded, err := cfg.ReportDistributorPathOverrides() if err != nil { t.Fatalf("loaded ReportDistributorPathOverrides() error = %v", err) } cfg = Defaults() cfg.Reports = reports if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } constructed, err := cfg.ReportDistributorPathOverrides() if err != nil { t.Fatalf("constructed ReportDistributorPathOverrides() error = %v", err) } if !reflect.DeepEqual(loaded, constructed) { t.Fatalf("loaded overrides = %#v, constructed = %#v", loaded, constructed) } } func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) { tests := []struct { name string yaml string reports map[string]ReportConfig wantErr string }{ { name: "UnknownReport", yaml: ` reports: moon: deterministic_modules: - metadata `, reports: map[string]ReportConfig{ "moon": { DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}}, deterministicModulesSet: true, }, }, wantErr: "reports.moon", }, { name: "DuplicateReportAlias", yaml: ` reports: three-day: deterministic_modules: - metadata three_day: deterministic_modules: - metadata `, reports: map[string]ReportConfig{ "three-day": { DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}}, deterministicModulesSet: true, }, "three_day": { DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}}, deterministicModulesSet: true, }, }, wantErr: "duplicates report override", }, { name: "UnknownModule", yaml: ` reports: daily: deterministic_modules: - missing_module `, reports: map[string]ReportConfig{ "daily": { DeterministicModules: []ModuleConfigItem{{ID: module.ID("missing_module")}}, deterministicModulesSet: true, }, }, wantErr: `unknown module "missing_module"`, }, { name: "DuplicateModule", yaml: ` reports: daily: deterministic_modules: - metadata - metadata `, reports: map[string]ReportConfig{ "daily": { DeterministicModules: []ModuleConfigItem{ {ID: module.Metadata}, {ID: module.Metadata}, }, deterministicModulesSet: true, }, }, wantErr: `duplicate module "metadata"`, }, { name: "IncompatibleModule", yaml: ` reports: daily: deterministic_modules: - tomorrow_planning `, reports: map[string]ReportConfig{ "daily": { DeterministicModules: []ModuleConfigItem{{ID: module.TomorrowPlanning}}, deterministicModulesSet: true, }, }, wantErr: `not compatible with report "daily"`, }, { name: "InvalidOptions", yaml: ` reports: daily: deterministic_modules: - id: metadata options: sections: - short_term `, reports: map[string]ReportConfig{ "daily": { DeterministicModules: []ModuleConfigItem{ { ID: module.Metadata, Options: map[string]any{ "sections": []any{"short_term"}, }, }, }, deterministicModulesSet: true, }, }, wantErr: "options are invalid", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, loadErr := LoadFile(writeConfig(t, tt.yaml)) assertReportModuleError(t, "LoadFile", loadErr, tt.wantErr) cfg := Defaults() cfg.Reports = tt.reports assertReportModuleError(t, "Validate", Validate(cfg), tt.wantErr) _, overrideErr := cfg.ReportModuleOverrides() assertReportModuleError(t, "ReportModuleOverrides", overrideErr, tt.wantErr) }) } } func assertReportModuleError(t *testing.T, operation string, err error, want string) { t.Helper() if err == nil { t.Fatalf("%s error = nil, want %q", operation, want) } if !strings.Contains(err.Error(), want) { t.Fatalf("%s error = %q, want %q", operation, err.Error(), want) } } func retiredDailyReportKeyForTest() string { return strings.Join([]string{"daily", "today"}, "_") } func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) { for _, key := range []string{ strings.Join([]string{"near", "term"}, "_"), strings.Join([]string{"near", "term"}, "-"), } { t.Run(key, func(t *testing.T) { _, err := LoadFile(writeConfig(t, ` reports: `+key+`: deterministic_modules: - metadata `)) if err == nil { t.Fatal("LoadFile() error = nil, want unknown report") } if !strings.Contains(err.Error(), "is not a known report") { t.Fatalf("error = %q, want unknown report", err.Error()) } }) } } func TestExplicitMissingConfigReturnsError(t *testing.T) { _, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml")) if err == nil { t.Fatal("LoadFile() error = nil, want missing file error") } if !strings.Contains(err.Error(), "read config") { t.Fatalf("error = %q, want read config context", err.Error()) } } func writeConfig(t *testing.T, contents string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } return path } func TestInvalidConfigProducesActionableError(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte("missing_source:\n default: explode\n"), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } _, err := LoadFile(path) if err == nil { t.Fatal("LoadFile() error = nil, want validation error") } if !strings.Contains(err.Error(), "missing_source.default") { t.Fatalf("error = %q, want field path", err.Error()) } } func TestLoadAppliesOverrides(t *testing.T) { cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"}) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.WeatherAPI.Units != "metric" { t.Fatalf("Units = %q, want metric", cfg.WeatherAPI.Units) } if cfg.WeatherAPI.Timezone != "+09:30" { t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone) } } func TestDisabledDistributorNotifyAcceptsOmittedFields(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte("notify:\n distributor:\n enabled: false\n"), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } if cfg.Notify.Distributor.Enabled { t.Fatalf("Notify.Distributor.Enabled = true, want false") } } func TestDistributorNotifyRejectsRemovedGlobalReportPaths(t *testing.T) { removedField := "report_path" + "_templates" _, err := LoadFile(writeConfig(t, ` notify: distributor: `+removedField+`: - index.md `)) if err == nil { t.Fatal("LoadFile() error = nil, want removed global path field error") } if !strings.Contains(err.Error(), `unknown notify distributor field "`+removedField+`"`) { t.Fatalf("error = %q, want removed global path field rejection", err.Error()) } } func TestDistributorNotifyRejectsUnknownFields(t *testing.T) { _, err := LoadFile(writeConfig(t, ` notify: distributor: paths: - index.md `)) if err == nil { t.Fatal("LoadFile() error = nil, want unknown distributor field error") } if !strings.Contains(err.Error(), `unknown notify distributor field "paths"`) { t.Fatalf("error = %q, want unknown field rejection", err.Error()) } } func TestDistributorBatchNotifyRejectsUnknownFields(t *testing.T) { _, err := LoadFile(writeConfig(t, ` notify: distributor: batch: paths: - index.md `)) if err == nil { t.Fatal("LoadFile() error = nil, want unknown distributor batch field error") } if !strings.Contains(err.Error(), `unknown notify distributor batch field "paths"`) { t.Fatalf("error = %q, want unknown batch field rejection", err.Error()) } } func TestDistributorBatchNotifyPartialConfigPreservesDefaults(t *testing.T) { cfg, err := LoadFile(writeConfig(t, ` notify: distributor: batch: enabled: false `)) if err != nil { t.Fatalf("LoadFile() error = %v", err) } if cfg.Notify.Distributor.Batch.Enabled { t.Fatalf("Batch.Enabled = true, want false") } if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" { t.Fatalf("Batch.PipelineIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.PipelineIDTemplate) } if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" { t.Fatalf("Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate) } if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" { t.Fatalf("Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate) } } func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) { cfg := Defaults() cfg.Notify.Distributor.Enabled = false cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}" cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}" cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}" if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } } func TestEnabledDistributorNotifyValidation(t *testing.T) { tests := []struct { name string mutate func(*Config) wantErr string }{ { name: "Endpoint", mutate: func(cfg *Config) { cfg.Notify.Distributor.Endpoint = "distributor.example.com" }, wantErr: "notify.distributor.endpoint", }, { name: "TokenEnvEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.TokenEnv = "" }, wantErr: "notify.distributor.token_env", }, { name: "TokenEnvInvalid", mutate: func(cfg *Config) { cfg.Notify.Distributor.TokenEnv = "1TOKEN" }, wantErr: "notify.distributor.token_env", }, { name: "Timeout", mutate: func(cfg *Config) { cfg.Notify.Distributor.Timeout = 0 }, wantErr: "notify.distributor.timeout", }, { name: "FailurePolicy", mutate: func(cfg *Config) { cfg.Notify.Distributor.FailurePolicy = "warn" }, wantErr: "notify.distributor.failure_policy", }, { name: "PipelineTemplateEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.PipelineIDTemplate = "" }, wantErr: "notify.distributor.pipeline_id_template", }, { name: "PipelineTemplateUnknown", mutate: func(cfg *Config) { cfg.Notify.Distributor.PipelineIDTemplate = "{unknown}" }, wantErr: "notify.distributor.pipeline_id_template", }, { name: "PipelineTemplateRenderedEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.PipelineIDTemplate = " " }, wantErr: "notify.distributor.pipeline_id_template", }, { name: "BundleTemplate", mutate: func(cfg *Config) { cfg.Notify.Distributor.BundleIDTemplate = "{unknown}" }, wantErr: "notify.distributor.bundle_id_template", }, { name: "IdempotencyTemplate", mutate: func(cfg *Config) { cfg.Notify.Distributor.IdempotencyKeyTemplate = "{unknown}" }, wantErr: "notify.distributor.idempotency_key_template", }, { name: "BatchTemplate", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}" }, wantErr: "notify.distributor.batch.bundle_id_template", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Defaults() cfg.Notify.Distributor.Enabled = true cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" tt.mutate(&cfg) err := Validate(cfg) if err == nil { t.Fatal("Validate() error = nil, want error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } }) } } func TestEnabledDistributorBatchNotifyValidation(t *testing.T) { tests := []struct { name string mutate func(*Config) wantErr string }{ { name: "PipelineTemplateEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.PipelineIDTemplate = "" }, wantErr: "notify.distributor.batch.pipeline_id_template", }, { name: "PipelineTemplateUnknown", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{report_id}" }, wantErr: "notify.distributor.batch.pipeline_id_template", }, { name: "PipelineTemplateRenderedEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.PipelineIDTemplate = " " }, wantErr: "notify.distributor.batch.pipeline_id_template", }, { name: "BundleTemplateEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.BundleIDTemplate = "" }, wantErr: "notify.distributor.batch.bundle_id_template", }, { name: "BundleTemplateUnknown", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}" }, wantErr: "notify.distributor.batch.bundle_id_template", }, { name: "BundleTemplateRenderedEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.BundleIDTemplate = " " }, wantErr: "notify.distributor.batch.bundle_id_template", }, { name: "IdempotencyTemplateEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "" }, wantErr: "notify.distributor.batch.idempotency_key_template", }, { name: "IdempotencyTemplateUnknown", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{report_id}" }, wantErr: "notify.distributor.batch.idempotency_key_template", }, { name: "IdempotencyTemplateRenderedEmpty", mutate: func(cfg *Config) { cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = " " }, wantErr: "notify.distributor.batch.idempotency_key_template", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Defaults() cfg.Notify.Distributor.Enabled = true cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" tt.mutate(&cfg) err := Validate(cfg) if err == nil { t.Fatal("Validate() error = nil, want error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } }) } } func TestDisabledDistributorBatchNotifySkipsBatchTemplateValidation(t *testing.T) { cfg := Defaults() cfg.Notify.Distributor.Enabled = true cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" cfg.Notify.Distributor.Batch.Enabled = false cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}" cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}" cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}" if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } } func TestDistributorTemplateRendering(t *testing.T) { values := DistributorTemplateValues{ LocationID: "home", ReportID: "daily", RunID: "20260607T120000Z", ArtifactGroup: "daily", BatchOutputName: "daily.md", ValidStartDate: "2026-06-07", ValidEndDate: "2026-06-08", ValidStartTime: "1800", ValidEndTime: "0600", ValidStartStamp: "2026-06-07T1800", ValidEndStamp: "2026-06-08T0600", StormID: "2026-06-07T1800-2026-06-08T0600", BundleID: "weatherreporter.home.daily", } bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{storm_id}", values) if err != nil { t.Fatalf("RenderDistributorBundleID() error = %v", err) } if bundleID != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" { t.Fatalf("bundleID = %q, want rendered value", bundleID) } values.BundleID = bundleID pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{storm_id}.{bundle_id}", values) if err != nil { t.Fatalf("RenderDistributorPipelineID() error = %v", err) } if pipelineID != "weatherreporter.daily.2026-06-07T1800-2026-06-08T0600.weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" { t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID) } idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values) if err != nil { t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err) } if idempotencyKey != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600.2026-06-07T1800-2026-06-08T0600.20260607T120000Z" { t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey) } reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{ "{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md", "storm/{storm_id}/index.md", "{valid_start_date}/{artifact_group}/latest.md", }, values) if err != nil { t.Fatalf("RenderDistributorReportPaths() error = %v", err) } wantPaths := []string{ "2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md", "storm/2026-06-07T1800-2026-06-08T0600/index.md", "2026-06-07/daily/latest.md", } if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") { t.Fatalf("reportPaths = %#v, want %#v", reportPaths, wantPaths) } } func TestDistributorReportPathRenderingUsesCallerName(t *testing.T) { values := DistributorTemplateValues{ BatchOutputName: "report.md", } tests := []struct { name string templates []string wantErr string }{ { name: "UnknownVariable", templates: []string{"{unknown}.md"}, wantErr: `report.daily.distributor_path_templates[0] contains unknown template variable "unknown"`, }, { name: "InvalidPath", templates: []string{"/{batch_output_name}"}, wantErr: "report.daily.distributor_path_templates[0] must render a relative path", }, { name: "DuplicatePath", templates: []string{"latest.md", "latest.md"}, wantErr: `report.daily.distributor_path_templates renders duplicate path "latest.md"`, }, { name: "Empty", templates: nil, wantErr: "report.daily.distributor_path_templates must contain at least one entry", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", tt.templates, values) if err == nil { t.Fatal("RenderDistributorReportPaths() error = nil, want error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } }) } } func TestDistributorBatchTemplateRendering(t *testing.T) { values := DistributorBatchTemplateValues{ LocationID: "home", Batch: "evening", BatchRunID: "20260617T235037.642224552Z_evening", BatchStartedDate: "2026-06-17", } bundleID, err := RenderDistributorBatchBundleID("weatherreporter.{location_id}.{batch}", values) if err != nil { t.Fatalf("RenderDistributorBatchBundleID() error = %v", err) } if bundleID != "weatherreporter.home.evening" { t.Fatalf("bundleID = %q, want batch bundle ID", bundleID) } values.BundleID = bundleID pipelineID, err := RenderDistributorBatchPipelineID("weatherreporter", values) if err != nil { t.Fatalf("RenderDistributorBatchPipelineID() error = %v", err) } if pipelineID != "weatherreporter" { t.Fatalf("pipelineID = %q, want weatherreporter", pipelineID) } idempotencyKey, err := RenderDistributorBatchIdempotencyKey("{bundle_id}.{batch_run_id}", values) if err != nil { t.Fatalf("RenderDistributorBatchIdempotencyKey() error = %v", err) } if idempotencyKey != "weatherreporter.home.evening.20260617T235037.642224552Z_evening" { t.Fatalf("idempotencyKey = %q, want batch retry key", idempotencyKey) } bundleID, err = RenderDistributorBatchBundleID("weatherreporter.{batch_started_date}.{batch}", values) if err != nil { t.Fatalf("RenderDistributorBatchBundleID() with date error = %v", err) } if bundleID != "weatherreporter.2026-06-17.evening" { t.Fatalf("bundleID = %q, want date-aware batch bundle ID", bundleID) } } func TestDistributorBatchTemplateRejectsUnknownAndMalformedVariables(t *testing.T) { tests := []struct { name string template string }{ {name: "Unknown", template: "{report_id}"}, {name: "Unclosed", template: "{batch"}, {name: "Unopened", template: "batch}"}, {name: "Empty", template: "{}"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := RenderDistributorBatchBundleID(tt.template, DistributorBatchTemplateValues{}) if err == nil { t.Fatal("RenderDistributorBatchBundleID() error = nil, want error") } }) } } func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) { tests := []struct { name string template string }{ {name: "Unknown", template: "{unknown}"}, {name: "Unclosed", template: "{location_id"}, {name: "Unopened", template: "location_id}"}, {name: "Empty", template: "{}"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{}) if err == nil { t.Fatal("RenderDistributorBundleID() error = nil, want error") } }) } } func TestDistributorReportPathValidation(t *testing.T) { tests := []struct { name string path string ok bool }{ {name: "Simple", path: "daily.md", ok: true}, {name: "Nested", path: "reports/daily.md", ok: true}, {name: "Empty", path: "", ok: false}, {name: "Absolute", path: "/reports/daily.md", ok: false}, {name: "WindowsAbsolute", path: "C:/reports/daily.md", ok: false}, {name: "Backslash", path: `reports\daily.md`, ok: false}, {name: "CurrentSegment", path: "reports/./daily.md", ok: false}, {name: "ParentSegment", path: "reports/../daily.md", ok: false}, {name: "EmptySegment", path: "reports//daily.md", ok: false}, {name: "Manifest", path: "reports/manifest.json", ok: false}, {name: "DistributorMetadata", path: "reports/.distributor.json", ok: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateDistributorReportPath("test.path", tt.path) if tt.ok && err != nil { t.Fatalf("ValidateDistributorReportPath() error = %v", err) } if !tt.ok && err == nil { t.Fatal("ValidateDistributorReportPath() error = nil, want error") } }) } } func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) { tests := []struct { name string batchOutputName string }{ {name: "Absolute", batchOutputName: "/daily.md"}, {name: "Backslash", batchOutputName: `reports\daily.md`}, {name: "CurrentSegment", batchOutputName: "./daily.md"}, {name: "ParentSegment", batchOutputName: "../daily.md"}, {name: "EmptySegment", batchOutputName: "reports//daily.md"}, {name: "Manifest", batchOutputName: "manifest.json"}, {name: "DistributorMetadata", batchOutputName: ".distributor.json"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", []string{"{batch_output_name}"}, DistributorTemplateValues{ BatchOutputName: tt.batchOutputName, }) if err == nil { t.Fatal("RenderDistributorReportPaths() error = nil, want error") } if !strings.Contains(err.Error(), "report.daily.distributor_path_templates[0]") { t.Fatalf("error = %q, want caller path name", err.Error()) } }) } } func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) { dir := t.TempDir() secretsDir := filepath.Join(dir, "secrets") if err := os.Mkdir(secretsDir, 0o700); err != nil { t.Fatalf("create secrets directory: %v", err) } if err := os.WriteFile(filepath.Join(secretsDir, "DISTRIBUTOR_UPLOAD_TOKEN"), []byte("loaded-token"), 0o600); err != nil { t.Fatalf("write secret: %v", err) } path := filepath.Join(dir, "config.yml") configYAML := "secrets:\n" + " directory: " + secretsDir + "\n" + "notify:\n" + " distributor:\n" + " enabled: true\n" + " pipeline_id_template: weatherreporter.{report_id}\n" if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } t.Setenv("DISTRIBUTOR_UPLOAD_TOKEN", "") cfg, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile() error = %v", err) } if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" { t.Fatalf("TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv) } if got := os.Getenv(cfg.Notify.Distributor.TokenEnv); got != "loaded-token" { t.Fatalf("environment value = %q, want loaded-token", got) } } func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) { t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original") if err := loadSecrets(SecretsConfig{}); err != nil { t.Fatalf("loadSecrets() error = %v", err) } if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" { t.Fatalf("environment value = %q, want original", got) } } func TestLoadFileLoadsSecretsDirectory(t *testing.T) { dir := t.TempDir() secretsDir := filepath.Join(dir, "secrets") if err := os.Mkdir(secretsDir, 0o700); err != nil { t.Fatalf("create secrets directory: %v", err) } if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil { t.Fatalf("write secret: %v", err) } path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte("secrets:\n directory: "+secretsDir+"\n"), 0o600); err != nil { t.Fatalf("write config fixture: %v", err) } t.Setenv("WEATHERREPORTER_SECRET", "") if _, err := LoadFile(path); err != nil { t.Fatalf("LoadFile() error = %v", err) } if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" { t.Fatalf("environment value = %q, want from-file", got) } } func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil { t.Fatalf("write secret: %v", err) } t.Setenv("WEATHERREPORTER_SECRET", "existing") if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil { t.Fatalf("loadSecrets() error = %v", err) } if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" { t.Fatalf("environment value = %q, want from-file", got) } } func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) { tests := []struct { name string input string want string }{ {name: "LF", input: "value\n", want: "value"}, {name: "CRLF", input: "value\r\n", want: "value"}, {name: "TwoLF", input: "value\n\n", want: "value\n"}, {name: "LoneCR", input: "value\r", want: "value\r"}, {name: "NoNewline", input: "value", want: "value"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte(tt.input), 0o600); err != nil { t.Fatalf("write secret: %v", err) } t.Setenv("WEATHERREPORTER_SECRET", "") if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil { t.Fatalf("loadSecrets() error = %v", err) } if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want { t.Fatalf("environment value = %q, want %q", got, tt.want) } }) } } func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) { tests := []struct { name string setup func(t *testing.T, dir string) wantErr string }{ { name: "InvalidFilename", setup: func(t *testing.T, dir string) { if err := os.WriteFile(filepath.Join(dir, "1INVALID"), []byte("secret-value"), 0o600); err != nil { t.Fatalf("write invalid secret: %v", err) } }, wantErr: "invalid environment variable name", }, { name: "Subdirectory", setup: func(t *testing.T, dir string) { if err := os.Mkdir(filepath.Join(dir, "SUBDIR"), 0o700); err != nil { t.Fatalf("create subdirectory: %v", err) } }, wantErr: "not a directory", }, { name: "Symlink", setup: func(t *testing.T, dir string) { target := filepath.Join(dir, "TARGET") if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil { t.Fatalf("write target: %v", err) } if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil { t.Fatalf("create symlink: %v", err) } }, wantErr: "not a symlink", }, { name: "Unreadable", setup: func(t *testing.T, dir string) { path := filepath.Join(dir, "UNREADABLE") if err := os.WriteFile(path, []byte("secret-value"), 0o600); err != nil { t.Fatalf("write unreadable secret: %v", err) } if err := os.Chmod(path, 0o000); err != nil { t.Fatalf("chmod unreadable secret: %v", err) } t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) }, wantErr: "read secret file", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() tt.setup(t, dir) err := loadSecrets(SecretsConfig{Directory: dir}) if err == nil { t.Fatal("loadSecrets() error = nil, want error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error = %q, want no secret value", err.Error()) } }) } } func TestLoadSecretsRejectsMissingDirectory(t *testing.T) { err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")}) if err == nil { t.Fatal("loadSecrets() error = nil, want missing directory error") } if !strings.Contains(err.Error(), "read secrets directory") { t.Fatalf("error = %q, want read secrets directory context", err.Error()) } }