Harden configuration validation

This commit is contained in:
2026-08-10 21:32:15 +00:00
parent b39b68add7
commit 72a200968a
20 changed files with 595 additions and 1072 deletions

View File

@@ -41,7 +41,11 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires a session_id")
}
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
rootPrefix := ""
if base.Pipeline.Storage.S3 != nil {
rootPrefix = base.Pipeline.Storage.S3.RootPrefix
}
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
partialCfg := &config.Config{
Pipeline: base.Pipeline,

View File

@@ -142,24 +142,3 @@ func commandObjectStoreTestConfig(secretsDir string) *config.Config {
}
return cfg
}
func restoreEnvAfterTest(t *testing.T, names ...string) {
t.Helper()
originals := make(map[string]string, len(names))
present := make(map[string]bool, len(names))
for _, name := range names {
value, ok := os.LookupEnv(name)
originals[name] = value
present[name] = ok
_ = os.Unsetenv(name)
}
t.Cleanup(func() {
for _, name := range names {
if present[name] {
_ = os.Setenv(name, originals[name])
} else {
_ = os.Unsetenv(name)
}
}
})
}

View File

@@ -0,0 +1,52 @@
package app
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionInitRejectsRenderedTemplateThatOmitsExpectedPreviousSession(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
campaignDir := filepath.Dir(campaignPath)
templatePath := filepath.Join(campaignDir, "session.template.yml")
template := `session_id: {{ session_id }}
campaign: sample-campaign
inputs:
audio_dir: ./audio
`
if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil {
t.Fatalf("write session template: %v", err)
}
campaign := `campaign_id: sample-campaign
session_template_file: session.template.yml
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(campaignPath, []byte(campaign), 0o644); err != nil {
t.Fatalf("write campaign config: %v", err)
}
var out bytes.Buffer
err := SessionInit(context.Background(), []string{
"2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--output", filepath.Join(t.TempDir(), "session.yml"),
"--previous-session-id", "2026-04-26",
}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "unused template variable value(s): previous_session_id") {
t.Fatalf("SessionInit() error = %q, want missing previous-session template error", err.Error())
}
}

View File

@@ -12,8 +12,7 @@ import (
func TestLoadSecretsFromConfigLoadsValidFiles(t *testing.T) {
dir := t.TempDir()
unsetSecretEnvironment(t, "NARRATIO_TEST_SECRET_A")
unsetSecretEnvironment(t, "NARRATIO_TEST_SECRET_B")
restoreEnvAfterTest(t, "NARRATIO_TEST_SECRET_A", "NARRATIO_TEST_SECRET_B")
mustWriteSecretFile(t, filepath.Join(dir, "NARRATIO_TEST_SECRET_A"), "value-1\n")
mustWriteSecretFile(t, filepath.Join(dir, "NARRATIO_TEST_SECRET_B"), "value-2\r\n")
mustWriteSecretFile(t, filepath.Join(dir, "not-valid-name.txt"), "ignored")
@@ -162,7 +161,7 @@ func TestLoadSecretsFromConfigRejectsUnsafeModes(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
unsetSecretEnvironment(t, "OPENROUTER_API_KEY")
restoreEnvAfterTest(t, "OPENROUTER_API_KEY")
path := filepath.Join(dir, "OPENROUTER_API_KEY")
mustWriteSecretFile(t, path, tc.wantSecret)
if err := os.Chmod(dir, tc.directory); err != nil {
@@ -186,7 +185,7 @@ func TestLoadSecretsFromConfigRejectsUnsafeModes(t *testing.T) {
func TestLoadSecretsFromConfigRejectsNonRegularAndOversizedEntries(t *testing.T) {
dir := t.TempDir()
ensureSecretDirectory(t, dir)
unsetSecretEnvironment(t, "OPENROUTER_API_KEY")
restoreEnvAfterTest(t, "OPENROUTER_API_KEY")
if err := os.Mkdir(filepath.Join(dir, "OPENROUTER_API_KEY"), secretDirectoryPrivateMode); err != nil {
t.Fatalf("Mkdir(non-regular entry) error = %v", err)
}
@@ -215,7 +214,7 @@ func TestLoadSecretsFromConfigRejectsSymlinkAndAncestorReplacement(t *testing.T)
dir := t.TempDir()
outside := t.TempDir()
ensureSecretDirectory(t, dir)
unsetSecretEnvironment(t, "OPENROUTER_API_KEY")
restoreEnvAfterTest(t, "OPENROUTER_API_KEY")
outsideValue := "outside-secret-value"
mustWriteSecretFile(t, filepath.Join(outside, "OPENROUTER_API_KEY"), outsideValue)
if err := os.Symlink(filepath.Join(outside, "OPENROUTER_API_KEY"), filepath.Join(dir, "OPENROUTER_API_KEY")); err != nil {
@@ -277,21 +276,6 @@ func secretConfig(dir string) *config.Config {
return &config.Config{Pipeline: &config.PipelineConfig{Secrets: &config.SecretsConfig{EnvDir: dir}}}
}
func unsetSecretEnvironment(t *testing.T, name string) {
t.Helper()
previous, existed := os.LookupEnv(name)
if err := os.Unsetenv(name); err != nil {
t.Fatalf("Unsetenv(%q): %v", name, err)
}
t.Cleanup(func() {
if existed {
_ = os.Setenv(name, previous)
return
}
_ = os.Unsetenv(name)
})
}
func ensureSecretDirectory(t *testing.T, directory string) {
t.Helper()
if err := os.Chmod(directory, secretDirectoryPrivateMode); err != nil {

View File

@@ -100,6 +100,26 @@ inputs:
}
}
func TestPlanRequiresExpectedPreviousSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer
err := Plan(context.Background(), []string{
"2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
"--previous-session-id", "2026-04-26",
}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "previous_session_id is required") {
t.Fatalf("error = %q, want missing previous-session expectation", err.Error())
}
}
func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -0,0 +1,31 @@
package app
import (
"os"
"testing"
)
// restoreEnvAfterTest clears names for a test and restores both their prior
// values and their set/unset state during cleanup.
func restoreEnvAfterTest(t *testing.T, names ...string) {
t.Helper()
originals := make(map[string]string, len(names))
present := make(map[string]bool, len(names))
for _, name := range names {
value, ok := os.LookupEnv(name)
originals[name] = value
present[name] = ok
if err := os.Unsetenv(name); err != nil {
t.Fatalf("Unsetenv(%q): %v", name, err)
}
}
t.Cleanup(func() {
for _, name := range names {
if present[name] {
_ = os.Setenv(name, originals[name])
} else {
_ = os.Unsetenv(name)
}
}
})
}