package app import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if storeInitCalls != 1 { t.Fatalf("object store init calls = %d, want 1", storeInitCalls) } if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") { t.Fatalf("stdout = %q, want plan output", stdout.String()) } if _, ok := fake.Objects[remoteKey]; !ok { t.Fatalf("remote session key %q was not seeded", remoteKey) } } func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) { tests := []struct { name string sessionYAML string command []string configureRun func() wantSuccessful bool }{ { name: "success", sessionYAML: `session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `, command: []string{"session", "plan", "2026-05-03"}, wantSuccessful: true, }, { name: "validation failure", sessionYAML: `session_id: 2026-05-03 `, command: []string{"session", "plan", "2026-05-03"}, }, { name: "load failure", sessionYAML: `session_id: 2026-05-03 unknown: true `, command: []string{"session", "plan", "2026-05-03"}, }, { name: "adapter failure", sessionYAML: `session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `, command: []string{"run", "2026-05-03"}, configureRun: func() { executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) { return nil, errors.New("adapter failed") } }, }, { name: "cancellation", sessionYAML: `session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `, command: []string{"run", "2026-05-03"}, configureRun: func() { executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) { return nil, context.Canceled } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", tt.sessionYAML) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var downloadedPath string captureRemoteSessionTempPath(t, &downloadedPath) if tt.configureRun != nil { origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) tt.configureRun() } args := append(append([]string(nil), tt.command...), "--config", pipelinePath, "--campaign-file", campaignPath) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(args, &stdout, &stderr) if tt.wantSuccessful && code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !tt.wantSuccessful && code == 0 { t.Fatal("exit code = 0, want non-zero") } if downloadedPath == "" { t.Fatal("remote session download path was not captured") } if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err) } }) } } func TestRemoteSessionConfigCloseIsIdempotent(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var downloadedPath string captureRemoteSessionTempPath(t, &downloadedPath) loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"}) if err != nil { t.Fatalf("loadCommandConfig() error = %v", err) } if err := loaded.Close(); err != nil { t.Fatalf("first Close() error = %v", err) } if err := loaded.Close(); err != nil { t.Fatalf("second Close() error = %v", err) } if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err) } } func captureRemoteSessionTempPath(t *testing.T, destination *string) { t.Helper() original := downloadObjectToTempFn downloadObjectToTempFn = func(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) { path, err := original(ctx, store, key, pattern) if err == nil { *destination = path } return path, err } t.Cleanup(func() { downloadObjectToTempFn = original }) } func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) accessKeyEnv := "NARRATIO_TEST_REMOTE_SESSION_KEY_ID" secretKeyEnv := "NARRATIO_TEST_REMOTE_SESSION_SECRET" restoreEnvAfterTest(t, accessKeyEnv, secretKeyEnv) secretsDir := t.TempDir() mustWriteSecretFile(t, filepath.Join(secretsDir, accessKeyEnv), "remote-session-key-id\n") mustWriteSecretFile(t, filepath.Join(secretsDir, secretKeyEnv), "remote-session-secret\n") addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv) fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `) origStoreFn := newObjectStoreFromConfigFn origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...) config.DefaultSessionConfigSearchPaths = []string{filepath.Join(t.TempDir(), "session.yml")} newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { if os.Getenv(accessKeyEnv) != "remote-session-key-id" || os.Getenv(secretKeyEnv) != "remote-session-secret" { return nil, fmt.Errorf("secrets were not loaded before remote session object store init") } return fake, nil } t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn config.DefaultSessionConfigSearchPaths = origSessionDefaults }) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } } func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if storeInitCalls != 0 { t.Fatalf("object store init calls = %d, want 0", storeInitCalls) } } func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if storeInitCalls != 0 { t.Fatalf("object store init calls = %d, want 0", storeInitCalls) } } func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int missingSessionPath := filepath.Join(t.TempDir(), "session.yml") restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{missingSessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "remote session") || !strings.Contains(stderr.String(), "session.yml") || !strings.Contains(stderr.String(), "not found") { t.Fatalf("stderr = %q, want remote session not found context", stderr.String()) } if !strings.Contains(stderr.String(), missingSessionPath) { t.Fatalf("stderr = %q, want local searched path", stderr.String()) } } func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) var storeInitCalls int restoreAppConfigTestGlobals(t, &storage.FakeBackend{}, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "plan: session_id is required") { t.Fatalf("stderr = %q, want session_id guidance", stderr.String()) } if storeInitCalls != 0 { t.Fatalf("object store init calls = %d, want 0", storeInitCalls) } } func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) origStoreFn := newObjectStoreFromConfigFn origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...) config.DefaultSessionConfigSearchPaths = []string{filepath.Join(t.TempDir(), "session.yml")} newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return nil, errors.New("storage unavailable") } t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn config.DefaultSessionConfigSearchPaths = origSessionDefaults }) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "storage unavailable") || !strings.Contains(stderr.String(), "remote session") { t.Fatalf("stderr = %q, want remote storage context", stderr.String()) } } func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-03\nunknown: true\n") var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "strict decode failed") { t.Fatalf("stderr = %q, want strict decode context", stderr.String()) } } func TestExecuteRemoteSessionTemplateFailsConcreteSessionCheck(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}" inputs: audio_s3: prefix: audio/ `) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "session.yml must be concrete") || !strings.Contains(stderr.String(), "run narratio session init") { t.Fatalf("stderr = %q, want concrete session guidance", stderr.String()) } } func TestExecuteRemoteSessionMismatchFails(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-04\ninputs:\n audio_s3:\n prefix: audio/\n") var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "session_id mismatch") { t.Fatalf("stderr = %q, want session_id mismatch", stderr.String()) } } func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) { t.Helper() origStoreFn := newObjectStoreFromConfigFn origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...) config.DefaultSessionConfigSearchPaths = append([]string(nil), sessionDefaults...) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { if storeInitCalls != nil { (*storeInitCalls)++ } return fake, nil } t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn config.DefaultSessionConfigSearchPaths = origSessionDefaults }) } func seedRemoteSessionConfig(t *testing.T, fake *storage.FakeBackend, sessionID, content string) string { t.Helper() sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", sessionID) remoteKey := artifacts.S3SessionConfigKey(sessionPrefix) fake.SeedObject(storage.FakeObject{ Key: remoteKey, Data: []byte(content), ETag: "remote-session-etag", }) return remoteKey } func addSecretsToPipelineConfig(t *testing.T, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv string) { t.Helper() pipelineData, err := os.ReadFile(pipelinePath) if err != nil { t.Fatalf("read pipeline: %v", err) } pipelineYAML := strings.Replace( string(pipelineData), "storage:\n backend: s3\n s3:\n bucket: test-bucket\n", "storage:\n backend: s3\n s3:\n bucket: test-bucket\n access_key_id_env: "+accessKeyEnv+"\n secret_access_key_env: "+secretKeyEnv+"\nsecrets:\n env_dir: "+secretsDir+"\n", 1, ) if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline: %v", err) } }