Harden configuration validation
This commit is contained in:
@@ -83,6 +83,13 @@ type StorageConfig struct {
|
||||
S3 *StorageS3Config `yaml:"s3"`
|
||||
}
|
||||
|
||||
const (
|
||||
// StorageBackendLocal disables remote object-store operations.
|
||||
StorageBackendLocal = "local"
|
||||
// StorageBackendS3 enables the configured S3 object store.
|
||||
StorageBackendS3 = "s3"
|
||||
)
|
||||
|
||||
// StorageS3Config configures S3 storage coordinates.
|
||||
type StorageS3Config struct {
|
||||
Bucket string `yaml:"bucket"`
|
||||
|
||||
@@ -74,15 +74,23 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
|
||||
strings.TrimSpace(cfg.SessionID),
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(opts.PreviousSessionID) != "" &&
|
||||
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
|
||||
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
|
||||
return nil, fmt.Errorf(
|
||||
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
|
||||
label,
|
||||
strings.TrimSpace(opts.PreviousSessionID),
|
||||
strings.TrimSpace(cfg.PreviousSessionID),
|
||||
)
|
||||
if expectedPreviousSessionID := strings.TrimSpace(opts.PreviousSessionID); expectedPreviousSessionID != "" {
|
||||
actualPreviousSessionID := strings.TrimSpace(cfg.PreviousSessionID)
|
||||
if actualPreviousSessionID == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"load session config: session file %q: previous_session_id is required when --previous-session-id %q is provided",
|
||||
label,
|
||||
expectedPreviousSessionID,
|
||||
)
|
||||
}
|
||||
if actualPreviousSessionID != expectedPreviousSessionID {
|
||||
return nil, fmt.Errorf(
|
||||
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
|
||||
label,
|
||||
expectedPreviousSessionID,
|
||||
actualPreviousSessionID,
|
||||
)
|
||||
}
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -296,8 +304,10 @@ func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
|
||||
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
|
||||
}
|
||||
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != nil && err != io.EOF {
|
||||
var extra yaml.Node
|
||||
if err := dec.Decode(&extra); err == nil {
|
||||
return fmt.Errorf("%s file %q: must contain exactly one YAML document", kind, path)
|
||||
} else if err != io.EOF {
|
||||
return fmt.Errorf("%s file %q: trailing content decode failed: %w", kind, path, err)
|
||||
}
|
||||
|
||||
@@ -392,8 +402,13 @@ func applyStorageDefaults(cfg *StorageConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
backend := strings.ToLower(strings.TrimSpace(cfg.Backend))
|
||||
if backend == "" {
|
||||
backend = StorageBackendLocal
|
||||
}
|
||||
cfg.Backend = backend
|
||||
if cfg.S3 == nil {
|
||||
cfg.S3 = &StorageS3Config{}
|
||||
return
|
||||
}
|
||||
if cfg.S3.RootPrefix == "" {
|
||||
cfg.S3.RootPrefix = DefaultStorageS3RootPrefix
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
91
internal/config/loader_strict_test.go
Normal file
91
internal/config/loader_strict_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadersRejectTrailingYAMLDocuments(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||
if err := os.WriteFile(pipelinePath, []byte("workspace:\n root: /tmp/narratio\n---\nworkspace:\n root: /other\n"), 0o644); err != nil {
|
||||
t.Fatalf("write pipeline.yml: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(campaignPath, []byte("campaign_id: sample-campaign\n---\nnull\n"), 0o644); err != nil {
|
||||
t.Fatalf("write campaign.yml: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
load func() error
|
||||
}{
|
||||
{
|
||||
name: "pipeline",
|
||||
load: func() error {
|
||||
_, err := LoadPipeline(pipelinePath)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "campaign",
|
||||
load: func() error {
|
||||
_, err := LoadCampaign(campaignPath)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote session bytes",
|
||||
load: func() error {
|
||||
_, err := LoadSessionBytesWithOptions("s3://bucket/session.yml", []byte("session_id: 2026-05-03\n---\n# another document\nnull\n"), SessionLoadOptions{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "publish lock store",
|
||||
load: func() error {
|
||||
_, err := LoadPublishLockStoreBytes("s3://bucket/locks.yml", []byte("locks: []\n---\n{}\n"), nil, nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.load()
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one YAML document") {
|
||||
t.Fatalf("load error = %v, want exactly-one-document error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictYAMLRejectsParserExposedEmptyTrailingDocument(t *testing.T) {
|
||||
var target struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n---\n"), &target)
|
||||
if err == nil {
|
||||
t.Fatal("decodeStrictYAMLFromReader() error = nil, want trailing-document error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exactly one YAML document") {
|
||||
t.Fatalf("decodeStrictYAMLFromReader() error = %v, want exactly-one-document error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields(t *testing.T) {
|
||||
var target struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n"), &target); err != nil {
|
||||
t.Fatalf("decodeStrictYAMLFromReader(single document) error = %v", err)
|
||||
}
|
||||
if target.Name != "one" {
|
||||
t.Fatalf("Name = %q, want one", target.Name)
|
||||
}
|
||||
if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("unknown: one\n"), &target); err == nil || !strings.Contains(err.Error(), "strict decode failed") {
|
||||
t.Fatalf("decodeStrictYAMLFromReader(unknown field) error = %v, want strict decode error", err)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,24 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsRequiresExpectedPreviousSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{PreviousSessionID: "2026-04-26"})
|
||||
if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") {
|
||||
t.Fatalf("LoadSessionWithOptions() error = %v, want missing previous-session expectation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsUnknownFieldStillRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
@@ -217,6 +235,17 @@ func TestLoadSessionBytesWithOptionsMismatchFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionBytesWithOptionsRequiresExpectedPreviousSession(t *testing.T) {
|
||||
_, err := LoadSessionBytesWithOptions(
|
||||
"s3://bucket/session.yml",
|
||||
[]byte("session_id: 2026-05-03\n"),
|
||||
SessionLoadOptions{PreviousSessionID: "2026-04-26"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") {
|
||||
t.Fatalf("LoadSessionBytesWithOptions() error = %v, want missing previous-session expectation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertConcreteSessionTemplateError(t *testing.T, err error, vars ...string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(err.Error(), "session.yml must be concrete") {
|
||||
|
||||
@@ -153,6 +153,108 @@ storage:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageBackendSelectionValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storageYAML string
|
||||
sessionYAML string
|
||||
wantErr string
|
||||
wantBackend string
|
||||
}{
|
||||
{
|
||||
name: "omitted storage defaults local",
|
||||
wantBackend: StorageBackendLocal,
|
||||
},
|
||||
{
|
||||
name: "explicit local backend",
|
||||
storageYAML: `storage:
|
||||
backend: local
|
||||
`,
|
||||
wantBackend: StorageBackendLocal,
|
||||
},
|
||||
{
|
||||
name: "case insensitive s3 backend normalizes",
|
||||
storageYAML: `storage:
|
||||
backend: S3
|
||||
s3:
|
||||
bucket: my-dnd-archive
|
||||
`,
|
||||
wantBackend: StorageBackendS3,
|
||||
},
|
||||
{
|
||||
name: "unknown backend",
|
||||
storageYAML: `storage:
|
||||
backend: s33
|
||||
`,
|
||||
wantErr: "pipeline.storage.backend must be one of: local, s3",
|
||||
},
|
||||
{
|
||||
name: "s3 block requires s3 backend",
|
||||
storageYAML: `storage:
|
||||
backend: local
|
||||
s3:
|
||||
bucket: my-dnd-archive
|
||||
`,
|
||||
wantErr: "pipeline.storage.s3 is only supported when pipeline.storage.backend is s3",
|
||||
},
|
||||
{
|
||||
name: "s3 backend requires s3 block",
|
||||
storageYAML: `storage:
|
||||
backend: s3
|
||||
`,
|
||||
wantErr: "pipeline.storage.s3 is required when pipeline.storage.backend is s3",
|
||||
},
|
||||
{
|
||||
name: "s3 backend requires bucket for publish",
|
||||
storageYAML: `storage:
|
||||
backend: s3
|
||||
s3: {}
|
||||
`,
|
||||
wantErr: "pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled",
|
||||
},
|
||||
{
|
||||
name: "s3 audio requires s3 backend",
|
||||
sessionYAML: `session_id: 2026-05-03
|
||||
inputs:
|
||||
audio_s3:
|
||||
prefix: audio/
|
||||
`,
|
||||
wantErr: "pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pipelineYAML := testPipelineBaseYAML
|
||||
if tt.storageYAML != "" {
|
||||
pipelineYAML += "\n" + tt.storageYAML
|
||||
}
|
||||
sessionYAML := testSessionBaseYAML
|
||||
if tt.sessionYAML != "" {
|
||||
sessionYAML = tt.sessionYAML
|
||||
}
|
||||
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, 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 %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if cfg.Pipeline.Storage.Backend != tt.wantBackend {
|
||||
t.Fatalf("storage.backend = %q, want %q", cfg.Pipeline.Storage.Backend, tt.wantBackend)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpoolAndPublishDefaults(t *testing.T) {
|
||||
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML)
|
||||
|
||||
@@ -681,6 +783,7 @@ func TestStorageS3BucketRequiredWhenS3DependentFeatureEnabled(t *testing.T) {
|
||||
pipelineYAML := testPipelineBaseYAML + `
|
||||
storage:
|
||||
backend: s3
|
||||
s3: {}
|
||||
`
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
campaign: forsaken
|
||||
|
||||
@@ -130,8 +130,19 @@ func validatePipeline(cfg *PipelineConfig) error {
|
||||
}
|
||||
|
||||
func validateStorage(cfg StorageConfig) error {
|
||||
if cfg.S3 == nil {
|
||||
backend := strings.ToLower(strings.TrimSpace(cfg.Backend))
|
||||
switch backend {
|
||||
case "", StorageBackendLocal:
|
||||
if cfg.S3 != nil {
|
||||
return fmt.Errorf("pipeline.storage.s3 is only supported when pipeline.storage.backend is s3")
|
||||
}
|
||||
return nil
|
||||
case StorageBackendS3:
|
||||
if cfg.S3 == nil {
|
||||
return fmt.Errorf("pipeline.storage.s3 is required when pipeline.storage.backend is s3")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("pipeline.storage.backend must be one of: local, s3")
|
||||
}
|
||||
if strings.TrimSpace(cfg.S3.RootPrefix) == "" {
|
||||
return fmt.Errorf("pipeline.storage.s3.root_prefix must be non-empty")
|
||||
@@ -783,13 +794,16 @@ func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error
|
||||
if pipeline == nil || session == nil {
|
||||
return nil
|
||||
}
|
||||
if pipeline.Storage.S3 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
audioS3Enabled := session.Inputs.AudioS3 != nil
|
||||
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
|
||||
if (audioS3Enabled || publishUploadEnabled) && strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" {
|
||||
if audioS3Enabled && !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) {
|
||||
return fmt.Errorf("pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured")
|
||||
}
|
||||
if !audioS3Enabled && !publishUploadEnabled {
|
||||
return nil
|
||||
}
|
||||
if pipeline.Storage.S3 == nil || strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" {
|
||||
return fmt.Errorf("pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled")
|
||||
}
|
||||
return nil
|
||||
@@ -799,7 +813,7 @@ func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool {
|
||||
if pipeline == nil || pipeline.Publish == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), "s3") {
|
||||
if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) {
|
||||
return false
|
||||
}
|
||||
enabled := true
|
||||
@@ -975,9 +989,13 @@ func validateDuration(fieldName, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := time.ParseDuration(trimmed); err != nil {
|
||||
duration, err := time.ParseDuration(trimmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be a valid duration: %w", fieldName, err)
|
||||
}
|
||||
if duration <= 0 {
|
||||
return fmt.Errorf("%s must be positive", fieldName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
114
internal/config/validation_duration_test.go
Normal file
114
internal/config/validation_duration_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateDurationsRequirePositiveValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*PipelineConfig, string)
|
||||
want string
|
||||
}{
|
||||
{name: "whisperx timeout", set: func(p *PipelineConfig, value string) { p.WhisperX.Timeout = value }, want: "pipeline.whisperx.timeout"},
|
||||
{name: "whisperx retry delay", set: func(p *PipelineConfig, value string) { p.WhisperX.RetryDelay = value }, want: "pipeline.whisperx.retry_delay"},
|
||||
{name: "seriatim timeout", set: func(p *PipelineConfig, value string) { p.Seriatim.Timeout = value }, want: "pipeline.seriatim.timeout"},
|
||||
{name: "audita timeout", set: func(p *PipelineConfig, value string) { p.Audita.Timeout = value }, want: "pipeline.audita.timeout"},
|
||||
{name: "scriptorium timeout", set: func(p *PipelineConfig, value string) { p.Scriptorium.Timeout = value }, want: "pipeline.scriptorium.timeout"},
|
||||
{name: "scriptorium artifact timeout", set: func(p *PipelineConfig, value string) {
|
||||
if p.Scriptorium.Artifacts == nil {
|
||||
p.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
|
||||
}
|
||||
p.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: value}
|
||||
}, want: "pipeline.scriptorium.artifacts.session_recap.timeout"},
|
||||
{name: "trim bounds timeout", set: func(p *PipelineConfig, value string) { p.Trim.Bounds.Timeout = value }, want: "pipeline.trim.bounds.timeout"},
|
||||
{name: "notification timeout", set: func(p *PipelineConfig, value string) { p.Notification.Timeout = value }, want: "pipeline.notification.timeout"},
|
||||
}
|
||||
|
||||
for _, value := range []string{"0s", "-1ms"} {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+"/"+value, func(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
tt.set(cfg.Pipeline, value)
|
||||
err := Validate(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want+" must be positive") {
|
||||
t.Fatalf("Validate() error = %v, want positive-value error for %s", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNotariusTimeoutRequiresPositiveValue(t *testing.T) {
|
||||
for _, value := range []string{"0s", "-1ms"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
cfg.Pipeline.Notarius = &NotariusConfig{
|
||||
Enabled: true,
|
||||
Binary: "notarius",
|
||||
ConfigPath: "/tmp/notarius.yml",
|
||||
PipelineID: "session",
|
||||
Timeout: value,
|
||||
WorkingDirectory: "/tmp",
|
||||
Outputs: map[string]NotariusOutputConfig{
|
||||
"npc_registry": {
|
||||
LaneID: "npc-registry",
|
||||
MediaType: "application/json",
|
||||
SchemaID: "notarius.dnd.npc_registry",
|
||||
SchemaVersion: "v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := Validate(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "pipeline.notarius.timeout must be positive") {
|
||||
t.Fatalf("Validate() error = %v, want positive Notarius timeout error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDurationRejectsOverflowAndAcceptsPositiveSubsecondValues(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
cfg.Pipeline.WhisperX.Timeout = "999999999999999999999h"
|
||||
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "pipeline.whisperx.timeout must be a valid duration") {
|
||||
t.Fatalf("Validate() overflow error = %v, want duration parse error", err)
|
||||
}
|
||||
|
||||
cfg = loadedValidConfig(t)
|
||||
cfg.Pipeline.WhisperX.Timeout = "1ms"
|
||||
cfg.Pipeline.WhisperX.RetryDelay = "1ms"
|
||||
cfg.Pipeline.Seriatim.Timeout = "1ms"
|
||||
cfg.Pipeline.Audita.Timeout = "1ms"
|
||||
cfg.Pipeline.Scriptorium.Timeout = "1ms"
|
||||
cfg.Pipeline.Trim.Bounds.Timeout = "1ms"
|
||||
cfg.Pipeline.Notification.Timeout = "1ms"
|
||||
if cfg.Pipeline.Scriptorium.Artifacts == nil {
|
||||
cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
|
||||
}
|
||||
cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: "1ms"}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() positive subsecond durations error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsEmptyArtifactTimeoutFallback(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
if cfg.Pipeline.Scriptorium.Artifacts == nil {
|
||||
cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
|
||||
}
|
||||
cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: ""}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() empty artifact timeout error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadedValidConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML)
|
||||
cfg, err := Load(pipelinePath, sessionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
Reference in New Issue
Block a user