package config import ( "strings" "testing" ) func TestStorageS3DefaultsAndValidation(t *testing.T) { pipelineYAML := testPipelineBaseYAML + ` storage: backend: s3 s3: bucket: my-dnd-archive ` pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Pipeline.Storage.S3 == nil { t.Fatal("storage.s3 should be initialized") } if cfg.Pipeline.Storage.S3.RootPrefix != "dnd" { t.Fatalf("storage.s3.root_prefix = %q, want dnd", cfg.Pipeline.Storage.S3.RootPrefix) } if cfg.Pipeline.Storage.S3.AccessKeyIDEnv != DefaultS3AccessKeyIDEnv { t.Fatalf("storage.s3.access_key_id_env = %q, want %q", cfg.Pipeline.Storage.S3.AccessKeyIDEnv, DefaultS3AccessKeyIDEnv) } if cfg.Pipeline.Storage.S3.SecretKeyEnv != DefaultS3SecretAccessKeyEnv { t.Fatalf("storage.s3.secret_access_key_env = %q, want %q", cfg.Pipeline.Storage.S3.SecretKeyEnv, DefaultS3SecretAccessKeyEnv) } if cfg.Pipeline.Storage.S3.ForcePathStyle { t.Fatalf("storage.s3.force_path_style = true, want false default") } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } } func TestStorageS3CredentialEnvNamesLoadAndValidate(t *testing.T) { pipelineYAML := testPipelineBaseYAML + ` storage: backend: s3 s3: bucket: my-dnd-archive access_key_id_env: CUSTOM_KEY_ID secret_access_key_env: CUSTOM_SECRET ` pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Pipeline.Storage.S3.AccessKeyIDEnv != "CUSTOM_KEY_ID" { t.Fatalf("storage.s3.access_key_id_env = %q, want CUSTOM_KEY_ID", cfg.Pipeline.Storage.S3.AccessKeyIDEnv) } if cfg.Pipeline.Storage.S3.SecretKeyEnv != "CUSTOM_SECRET" { t.Fatalf("storage.s3.secret_access_key_env = %q, want CUSTOM_SECRET", cfg.Pipeline.Storage.S3.SecretKeyEnv) } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } } func TestStorageS3CredentialEnvValidation(t *testing.T) { tests := []struct { name string pipelineYML string wantErr string }{ { name: "invalid access key env name", pipelineYML: testPipelineBaseYAML + ` storage: backend: s3 s3: bucket: my-dnd-archive access_key_id_env: "123BAD" `, wantErr: "pipeline.storage.s3.access_key_id_env must be a valid environment variable name", }, { name: "invalid secret key env name", pipelineYML: testPipelineBaseYAML + ` storage: backend: s3 s3: bucket: my-dnd-archive secret_access_key_env: "bad-name" `, wantErr: "pipeline.storage.s3.secret_access_key_env must be a valid environment variable name", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelinePath, sessionPath := writeConfigFiles(t, tt.pipelineYML, testSessionBaseYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } err = Validate(cfg) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("Validate() error = %v, want to contain %q", err, tt.wantErr) } }) } } func TestSpoolAndArchiveDefaults(t *testing.T) { pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Pipeline.Spool.Root != "/var/spool/narratio" { t.Fatalf("spool.root = %q, want /var/spool/narratio", cfg.Pipeline.Spool.Root) } if cfg.Pipeline.Spool.DeleteAudioAfterArchive { t.Fatalf("spool.delete_audio_after_archive = true, want false") } if cfg.Pipeline.Workspace.CleanupAfterArchive { t.Fatalf("workspace.cleanup_after_archive = true, want false") } if cfg.Pipeline.Archive == nil { t.Fatal("archive should be initialized by defaults") } if cfg.Pipeline.Archive.Enabled == nil || !*cfg.Pipeline.Archive.Enabled { t.Fatalf("archive.enabled = %#v, want true", cfg.Pipeline.Archive.Enabled) } if cfg.Pipeline.Archive.UploadRun == nil || !*cfg.Pipeline.Archive.UploadRun { t.Fatalf("archive.upload_run = %#v, want true", cfg.Pipeline.Archive.UploadRun) } if len(cfg.Pipeline.Archive.PromoteArtifacts) != 2 { t.Fatalf("archive.promote_artifacts len = %d, want 2 defaults", len(cfg.Pipeline.Archive.PromoteArtifacts)) } for i, item := range cfg.Pipeline.Archive.PromoteArtifacts { if item.Required == nil || !*item.Required { t.Fatalf("archive.promote_artifacts[%d].required = %#v, want true", i, item.Required) } } } func TestArchivePromotionPathValidation(t *testing.T) { tests := []struct { name string ruleYML string wantErr string }{ { name: "absolute from path rejected", ruleYML: `archive: promote_artifacts: - from: "/transcripts/trimmed.json" to: "transcripts/trimmed.json" `, wantErr: "must be a relative path", }, { name: "traversal to path rejected", ruleYML: `archive: promote_artifacts: - from: "transcripts/trimmed.json" to: "../trimmed.json" `, wantErr: "must not contain path traversal", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelineYAML := testPipelineBaseYAML + "\n" + tt.ruleYML pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } err = Validate(cfg) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("Validate() error = %v, want to contain %q", err, tt.wantErr) } }) } } func TestSessionAudioS3Validation(t *testing.T) { tests := []struct { name string sessionYAML string wantErr string }{ { name: "valid audio_s3 prefix", sessionYAML: `session_id: 2026-05-03 campaign: forsaken inputs: audio_s3: prefix: audio/ speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, }, { name: "invalid audio_s3 absolute prefix", sessionYAML: `session_id: 2026-05-03 campaign: forsaken inputs: audio_s3: prefix: /audio/ speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantErr: "session.inputs.audio_s3.prefix must be a relative path", }, { name: "invalid audio_s3 traversal prefix", sessionYAML: `session_id: 2026-05-03 campaign: forsaken inputs: audio_s3: prefix: ../audio/ speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantErr: "session.inputs.audio_s3.prefix must not contain path traversal", }, { name: "local and s3 audio conflict", sessionYAML: `session_id: 2026-05-03 campaign: forsaken inputs: audio_dir: ./audio audio_s3: prefix: audio/ speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantErr: "mutually exclusive", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelineYAML := testPipelineBaseYAML + ` storage: backend: s3 s3: bucket: my-dnd-archive ` pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, tt.sessionYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } err = Validate(cfg) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("Validate() error = %v, want to contain %q", err, tt.wantErr) } return } if err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestStorageS3BucketRequiredWhenS3DependentFeatureEnabled(t *testing.T) { pipelineYAML := testPipelineBaseYAML + ` storage: backend: s3 ` sessionYAML := `session_id: 2026-05-03 campaign: forsaken inputs: audio_s3: prefix: audio/ speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml ` pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } err = Validate(cfg) if err == nil || !strings.Contains(err.Error(), "pipeline.storage.s3.bucket is required") { t.Fatalf("Validate() error = %v, want bucket requirement", err) } } func TestLocalAudioConfigStillValid(t *testing.T) { pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } }