package app import ( "encoding/json" "errors" "os" "path/filepath" "strings" "testing" aiservice "GoNavi-Wails/internal/ai/service" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/secretstore" ) func TestStartSecurityUpdateCreatesBackupAndImportsSavedConfig(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", "headers": map[string]any{ "Authorization": "Bearer ai-test", "X-Team": "platform", }, }, }, }) status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", status.OverallStatus) } if status.MigrationID == "" { t.Fatal("expected migration ID to be created") } if status.Summary.Total != 3 || status.Summary.Updated != 3 { t.Fatalf("expected summary total=3 updated=3, got %#v", status.Summary) } savedConnections, err := app.GetSavedConnections() if err != nil { t.Fatalf("GetSavedConnections returned error: %v", err) } if len(savedConnections) != 1 { t.Fatalf("expected 1 saved connection, got %d", len(savedConnections)) } resolvedConnection, err := app.resolveConnectionSecrets(savedConnections[0].Config) if err != nil { t.Fatalf("resolveConnectionSecrets returned error: %v", err) } if resolvedConnection.Password != "postgres-secret" { t.Fatalf("expected imported connection password, got %q", resolvedConnection.Password) } globalProxyView, err := app.loadStoredGlobalProxyView() if err != nil { t.Fatalf("loadStoredGlobalProxyView returned error: %v", err) } globalProxyBundle, err := app.loadGlobalProxySecretBundle(globalProxyView) if err != nil { t.Fatalf("loadGlobalProxySecretBundle returned error: %v", err) } if globalProxyBundle.Password != "proxy-secret" { t.Fatalf("expected imported proxy password, got %q", globalProxyBundle.Password) } providerStore := aiservice.NewProviderConfigStore(app.configDir, app.secretStore) providerSnapshot, err := providerStore.Load() if err != nil { t.Fatalf("provider store Load returned error: %v", err) } if len(providerSnapshot.Providers) != 1 { t.Fatalf("expected 1 AI provider, got %d", len(providerSnapshot.Providers)) } if providerSnapshot.Providers[0].APIKey != "sk-ai-test" { t.Fatalf("expected migrated AI provider apiKey, got %q", providerSnapshot.Providers[0].APIKey) } for _, name := range []string{ securityUpdateManifestFileName, securityUpdateSourceCurrentAppFileName, securityUpdateNormalizedPreviewFileName, securityUpdateResultFileName, } { if _, err := os.Stat(filepath.Join(status.BackupPath, name)); err != nil { t.Fatalf("expected backup artifact %q: %v", name, err) } } } func TestGetSecurityUpdateStatusReturnsPendingWhenOnlyAIProviderNeedsSecurityUpdate(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", }, }, }) status, err := app.GetSecurityUpdateStatus() if err != nil { t.Fatalf("GetSecurityUpdateStatus returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusPending { t.Fatalf("expected pending status, got %q", status.OverallStatus) } if !status.CanStart || !status.ReminderVisible { t.Fatalf("expected pending status to expose start/reminder flags, got %#v", status) } } func TestGetSecurityUpdateStatusIncludesPendingAIProviderIssuesBeforeStart(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", }, }, }) status, err := app.GetSecurityUpdateStatus() if err != nil { t.Fatalf("GetSecurityUpdateStatus returned error: %v", err) } if len(status.Issues) != 1 { t.Fatalf("expected 1 pending issue, got %#v", status.Issues) } if status.Summary.Total != 1 || status.Summary.Pending != 1 { t.Fatalf("expected summary total=1 pending=1, got %#v", status.Summary) } issue := status.Issues[0] if issue.Scope != SecurityUpdateIssueScopeAIProvider { t.Fatalf("expected AI provider issue scope, got %#v", issue) } if issue.RefID != "openai-main" || issue.Title != "OpenAI" { t.Fatalf("expected provider issue to point at openai-main/OpenAI, got %#v", issue) } if issue.Status != SecurityUpdateItemStatusPending || issue.Action != SecurityUpdateIssueActionOpenAISettings { t.Fatalf("expected pending AI settings issue, got %#v", issue) } } func TestRetrySecurityUpdateCurrentRoundReusesMigrationIDAfterPendingIssueIsFixed(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() ref, err := secretstore.BuildRef("ai-provider", "openai-main") if err != nil { t.Fatalf("BuildRef returned error: %v", err) } writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "hasSecret": true, "secretRef": ref, "baseUrl": "https://api.openai.com/v1", }, }, }) initial, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if initial.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", initial.OverallStatus) } if err := store.Put(ref, []byte(`{"apiKey":"sk-fixed","sensitiveHeaders":{"Authorization":"Bearer fixed"}}`)); err != nil { t.Fatalf("Put returned error: %v", err) } retried, err := app.RetrySecurityUpdateCurrentRound(RetrySecurityUpdateRequest{ MigrationID: initial.MigrationID, }) if err == nil { t.Fatalf("expected retry to be rejected after completed round, got %#v", retried) } if !strings.Contains(err.Error(), "requires status needs_attention") { t.Fatalf("expected completed round retry rejection, got %v", err) } } func TestRetrySecurityUpdateCurrentRoundDoesNotReimportBrokenLegacySourceAfterUserFix(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() ref, err := secretstore.BuildRef("ai-provider", "openai-main") if err != nil { t.Fatalf("BuildRef returned error: %v", err) } writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "hasSecret": true, "secretRef": ref, "baseUrl": "https://api.openai.com/v1", }, }, }) initial, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if initial.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", initial.OverallStatus) } if _, err := app.SaveConnection(connection.SavedConnectionInput{ ID: "legacy-1", Name: "Legacy Fixed", Config: connection.ConnectionConfig{ ID: "legacy-1", Type: "postgres", Host: "db-fixed.local", Port: 5432, User: "postgres", Password: "postgres-fixed", }, }); err != nil { t.Fatalf("SaveConnection returned error: %v", err) } if err := store.Put(ref, []byte(`{"apiKey":"sk-fixed"}`)); err != nil { t.Fatalf("Put returned error: %v", err) } retried, err := app.RetrySecurityUpdateCurrentRound(RetrySecurityUpdateRequest{ MigrationID: initial.MigrationID, }) if err == nil { t.Fatalf("expected retry to be rejected after completed round, got %#v", retried) } if !strings.Contains(err.Error(), "requires status needs_attention") { t.Fatalf("expected completed round retry rejection, got %v", err) } savedConnections, err := app.GetSavedConnections() if err != nil { t.Fatalf("GetSavedConnections returned error: %v", err) } if len(savedConnections) != 1 { t.Fatalf("expected 1 saved connection, got %d", len(savedConnections)) } resolvedConnection, err := app.resolveConnectionSecrets(savedConnections[0].Config) if err != nil { t.Fatalf("resolveConnectionSecrets returned error: %v", err) } if resolvedConnection.Host != "db-fixed.local" { t.Fatalf("expected retry to keep user-fixed host, got %q", resolvedConnection.Host) } if resolvedConnection.Password != "postgres-fixed" { t.Fatalf("expected retry to keep user-fixed password, got %q", resolvedConnection.Password) } } func TestRestartSecurityUpdateCreatesNewMigrationID(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() initial, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } restarted, err := app.RestartSecurityUpdate(RestartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("RestartSecurityUpdate returned error: %v", err) } if restarted.MigrationID == initial.MigrationID { t.Fatal("expected restart to create a new migration ID") } } func TestDismissSecurityUpdateReminderMarksStatusPostponed(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() status, err := app.DismissSecurityUpdateReminder() if err != nil { t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusPostponed { t.Fatalf("expected postponed status, got %q", status.OverallStatus) } if status.PostponedAt == "" { t.Fatal("expected postponedAt to be recorded") } } func TestDismissSecurityUpdateReminderKeepsCurrentRoundContext(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() ref, err := secretstore.BuildRef("ai-provider", "openai-main") if err != nil { t.Fatalf("BuildRef returned error: %v", err) } writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "hasSecret": true, "secretRef": ref, "baseUrl": "https://api.openai.com/v1", }, }, }) initial, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if initial.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", initial.OverallStatus) } postponed, err := app.DismissSecurityUpdateReminder() if err != nil { t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err) } if postponed.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status to be preserved, got %q", postponed.OverallStatus) } if postponed.MigrationID != initial.MigrationID { t.Fatalf("expected migration ID %q to be preserved, got %q", initial.MigrationID, postponed.MigrationID) } if postponed.BackupPath != initial.BackupPath { t.Fatalf("expected backupPath %q to be preserved, got %q", initial.BackupPath, postponed.BackupPath) } if postponed.Summary != initial.Summary { t.Fatalf("expected summary %#v to be preserved, got %#v", initial.Summary, postponed.Summary) } if len(postponed.Issues) != len(initial.Issues) { t.Fatalf("expected %d issues to be preserved, got %#v", len(initial.Issues), postponed.Issues) } if postponed.PostponedAt != "" { t.Fatalf("expected completed round to keep empty postponedAt, got %q", postponed.PostponedAt) } } func TestDismissSecurityUpdateReminderKeepsPendingAIProviderDetailsWithoutCurrentRound(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", }, }, }) status, err := app.DismissSecurityUpdateReminder() if err != nil { t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusPostponed { t.Fatalf("expected postponed status, got %q", status.OverallStatus) } if status.Summary.Total != 1 || status.Summary.Pending != 1 { t.Fatalf("expected summary total=1 pending=1, got %#v", status.Summary) } if len(status.Issues) != 1 { t.Fatalf("expected 1 pending issue, got %#v", status.Issues) } if status.Issues[0].RefID != "openai-main" || status.Issues[0].Action != SecurityUpdateIssueActionOpenAISettings { t.Fatalf("expected postponed issue to keep AI provider repair entry, got %#v", status.Issues[0]) } } func TestDismissSecurityUpdateReminderDoesNotOverrideCompletedRound(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() repo := newSecurityUpdateStateRepository(app.configDir) completed := SecurityUpdateStatus{ SchemaVersion: securityUpdateSchemaVersion, MigrationID: "migration-1", OverallStatus: SecurityUpdateOverallStatusCompleted, SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, BackupPath: filepath.Join(app.configDir, securityUpdateBackupRootDirName, "migration-1"), StartedAt: "2026-04-09T00:00:00Z", UpdatedAt: "2026-04-09T00:05:00Z", CompletedAt: "2026-04-09T00:05:00Z", Summary: SecurityUpdateSummary{ Total: 1, Updated: 1, }, Issues: []SecurityUpdateIssue{}, } if err := repo.WriteResult(completed); err != nil { t.Fatalf("WriteResult returned error: %v", err) } status, err := app.DismissSecurityUpdateReminder() if err != nil { t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status to be preserved, got %q", status.OverallStatus) } if status.MigrationID != completed.MigrationID { t.Fatalf("expected migration ID %q to be preserved, got %q", completed.MigrationID, status.MigrationID) } if status.PostponedAt != "" { t.Fatalf("expected completed round to keep empty postponedAt, got %q", status.PostponedAt) } } func TestDismissSecurityUpdateReminderDoesNotOverrideRolledBackRound(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() repo := newSecurityUpdateStateRepository(app.configDir) rolledBack := SecurityUpdateStatus{ SchemaVersion: securityUpdateSchemaVersion, MigrationID: "migration-1", OverallStatus: SecurityUpdateOverallStatusRolledBack, SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, BackupPath: filepath.Join(app.configDir, securityUpdateBackupRootDirName, "migration-1"), StartedAt: "2026-04-09T00:00:00Z", UpdatedAt: "2026-04-09T00:05:00Z", Summary: SecurityUpdateSummary{ Total: 1, Failed: 1, }, Issues: []SecurityUpdateIssue{ { ID: "system-blocked", Scope: SecurityUpdateIssueScopeSystem, Title: "安全更新未完成", Severity: SecurityUpdateIssueSeverityHigh, Status: SecurityUpdateItemStatusFailed, ReasonCode: SecurityUpdateIssueReasonCodeEnvironmentBlocked, Action: SecurityUpdateIssueActionViewDetails, Message: "当前环境无法完成本次安全更新,请稍后重试", }, }, } if err := repo.WriteResult(rolledBack); err != nil { t.Fatalf("WriteResult returned error: %v", err) } status, err := app.DismissSecurityUpdateReminder() if err != nil { t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusRolledBack { t.Fatalf("expected rolled_back status to be preserved, got %q", status.OverallStatus) } if status.MigrationID != rolledBack.MigrationID { t.Fatalf("expected migration ID %q to be preserved, got %q", rolledBack.MigrationID, status.MigrationID) } if status.PostponedAt != "" { t.Fatalf("expected rolled_back round to keep empty postponedAt, got %q", status.PostponedAt) } if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem { t.Fatalf("expected rolled_back issue details to be preserved, got %#v", status.Issues) } } func TestStartSecurityUpdateRollsBackWhenSecretStoreUnavailable(t *testing.T) { withTestGOOS(t, "linux") app := NewAppWithSecretStore(nil) app.configDir = t.TempDir() status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", status.OverallStatus) } if len(status.Issues) != 0 { t.Fatalf("expected no blocking issues, got %#v", status.Issues) } } func TestStartSecurityUpdateRollsBackWhenAIProviderSecretStoreUnavailable(t *testing.T) { app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked")) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", }, }, }) status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: "", }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", status.OverallStatus) } if len(status.Issues) != 0 { t.Fatalf("expected no blocking issues, got %#v", status.Issues) } } func TestStartSecurityUpdateRollsBackPartialConnectionImportWhenLaterProviderStepFails(t *testing.T) { app := NewAppWithSecretStore(secretstore.NewUnavailableStore("blocked")) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", }, }, }) payload, err := json.Marshal(map[string]any{ "state": map[string]any{ "connections": []map[string]any{ { "id": "legacy-1", "name": "Legacy", "config": map[string]any{ "id": "legacy-1", "type": "postgres", "host": "db.local", "port": 5432, "user": "postgres", }, }, }, }, }) if err != nil { t.Fatalf("Marshal returned error: %v", err) } status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: string(payload), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusCompleted { t.Fatalf("expected completed status, got %q", status.OverallStatus) } savedConnections, err := app.GetSavedConnections() if err != nil { t.Fatalf("GetSavedConnections returned error: %v", err) } if len(savedConnections) != 1 { t.Fatalf("expected imported connection to remain after completed update, got %#v", savedConnections) } if savedConnections[0].ID != "legacy-1" || savedConnections[0].Config.Host != "db.local" { t.Fatalf("expected imported connection metadata to be preserved, got %#v", savedConnections[0]) } } func TestStartSecurityUpdateRollsBackExistingConnectionMetadataAndSecretWhenLaterProviderStepFails(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() if _, err := app.SaveConnection(connection.SavedConnectionInput{ ID: "legacy-1", Name: "Existing", Config: connection.ConnectionConfig{ ID: "legacy-1", Type: "postgres", Host: "db-old.local", Port: 5432, User: "postgres", Password: "old-secret", }, }); err != nil { t.Fatalf("SaveConnection returned error: %v", err) } if err := os.WriteFile(filepath.Join(app.configDir, "ai_config.json"), []byte("{"), 0o644); err != nil { t.Fatalf("WriteFile returned error: %v", err) } payload, err := json.Marshal(map[string]any{ "state": map[string]any{ "connections": []map[string]any{ { "id": "legacy-1", "name": "Migrated", "config": map[string]any{ "id": "legacy-1", "type": "postgres", "host": "db-new.local", "port": 5432, "user": "postgres", "password": "new-secret", }, }, }, }, }) if err != nil { t.Fatalf("Marshal returned error: %v", err) } status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: string(payload), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusRolledBack { t.Fatalf("expected rolled_back status, got %q", status.OverallStatus) } savedConnections, err := app.GetSavedConnections() if err != nil { t.Fatalf("GetSavedConnections returned error: %v", err) } if len(savedConnections) != 1 { t.Fatalf("expected existing connection to remain, got %#v", savedConnections) } if savedConnections[0].Name != "Existing" || savedConnections[0].Config.Host != "db-old.local" { t.Fatalf("expected existing connection metadata to be restored, got %#v", savedConnections[0]) } resolved, err := app.resolveConnectionSecrets(savedConnections[0].Config) if err != nil { t.Fatalf("resolveConnectionSecrets returned error: %v", err) } if resolved.Password != "old-secret" { t.Fatalf("expected existing connection secret to be restored, got %q", resolved.Password) } } func TestStartSecurityUpdateRollsBackExistingGlobalProxyWhenLaterProviderStepFails(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ Enabled: true, Type: "http", Host: "proxy-old.local", Port: 8080, User: "ops", Password: "old-proxy-secret", }); err != nil { t.Fatalf("saveGlobalProxy returned error: %v", err) } if err := os.WriteFile(filepath.Join(app.configDir, "ai_config.json"), []byte("{"), 0o644); err != nil { t.Fatalf("WriteFile returned error: %v", err) } payload, err := json.Marshal(map[string]any{ "state": map[string]any{ "globalProxy": map[string]any{ "enabled": true, "type": "http", "host": "proxy-new.local", "port": 8081, "user": "ops-new", "password": "new-proxy-secret", }, }, }) if err != nil { t.Fatalf("Marshal returned error: %v", err) } status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: string(payload), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusRolledBack { t.Fatalf("expected rolled_back status, got %q", status.OverallStatus) } view, err := app.loadStoredGlobalProxyView() if err != nil { t.Fatalf("loadStoredGlobalProxyView returned error: %v", err) } if view.Host != "proxy-old.local" || view.Port != 8080 || view.User != "ops" { t.Fatalf("expected existing global proxy metadata to be restored, got %#v", view) } bundle, err := app.loadGlobalProxySecretBundle(view) if err != nil { t.Fatalf("loadGlobalProxySecretBundle returned error: %v", err) } if bundle.Password != "old-proxy-secret" { t.Fatalf("expected existing global proxy secret to be restored, got %q", bundle.Password) } } func TestStartSecurityUpdateRollsBackAllChangesWhenPreviewArtifactWriteFails(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", "headers": map[string]any{ "Authorization": "Bearer ai-test", }, }, }, }) restoreWriteJSONFile := swapSecurityUpdateWriteJSONFile(func(path string, payload any) error { if strings.HasSuffix(filepath.ToSlash(path), "/"+securityUpdateNormalizedPreviewFileName) { return errors.New("forced preview write failure") } return writeJSONFile(path, payload) }) defer restoreWriteJSONFile() status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusRolledBack { t.Fatalf("expected rolled_back status, got %q", status.OverallStatus) } assertSecurityUpdateRollbackRestoredCurrentAppState(t, app, store) } func TestStartSecurityUpdateRollsBackAllChangesWhenFinalResultWriteFails(t *testing.T) { store := newFakeAppSecretStore() app := NewAppWithSecretStore(store) app.configDir = t.TempDir() writeLegacyAIProviderConfig(t, app.configDir, map[string]any{ "providers": []map[string]any{ { "id": "openai-main", "type": "openai", "name": "OpenAI", "apiKey": "sk-ai-test", "baseUrl": "https://api.openai.com/v1", "headers": map[string]any{ "Authorization": "Bearer ai-test", }, }, }, }) resultWrites := 0 restoreWriteJSONFile := swapSecurityUpdateWriteJSONFile(func(path string, payload any) error { if strings.HasSuffix(filepath.ToSlash(path), "/"+securityUpdateResultFileName) { resultWrites++ if resultWrites == 2 { return errors.New("forced result write failure") } } return writeJSONFile(path, payload) }) defer restoreWriteJSONFile() status, err := app.StartSecurityUpdate(StartSecurityUpdateRequest{ SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig, RawPayload: buildLegacySecurityUpdatePayload(), }) if err != nil { t.Fatalf("StartSecurityUpdate returned error: %v", err) } if status.OverallStatus != SecurityUpdateOverallStatusRolledBack { t.Fatalf("expected rolled_back status, got %q", status.OverallStatus) } assertSecurityUpdateRollbackRestoredCurrentAppState(t, app, store) } func buildLegacySecurityUpdatePayload() string { payload, _ := json.Marshal(map[string]any{ "state": map[string]any{ "connections": []map[string]any{ { "id": "legacy-1", "name": "Legacy", "config": map[string]any{ "id": "legacy-1", "type": "postgres", "host": "db.local", "port": 5432, "user": "postgres", "password": "postgres-secret", }, }, }, "globalProxy": map[string]any{ "enabled": true, "type": "http", "host": "127.0.0.1", "port": 8080, "user": "ops", "password": "proxy-secret", }, }, }) return string(payload) } func writeLegacyAIProviderConfig(t *testing.T, configDir string, payload map[string]any) { t.Helper() data, err := json.MarshalIndent(payload, "", " ") if err != nil { t.Fatalf("MarshalIndent returned error: %v", err) } if err := os.WriteFile(filepath.Join(configDir, "ai_config.json"), data, 0o644); err != nil { t.Fatalf("WriteFile returned error: %v", err) } } func swapSecurityUpdateWriteJSONFile(next func(path string, payload any) error) func() { original := securityUpdateWriteJSONFile securityUpdateWriteJSONFile = next return func() { securityUpdateWriteJSONFile = original } } func assertSecurityUpdateRollbackRestoredCurrentAppState(t *testing.T, app *App, store *fakeAppSecretStore) { t.Helper() savedConnections, err := app.GetSavedConnections() if err != nil { t.Fatalf("GetSavedConnections returned error: %v", err) } if len(savedConnections) != 0 { t.Fatalf("expected rollback to leave no imported connections, got %#v", savedConnections) } if _, err := app.loadStoredGlobalProxyView(); !os.IsNotExist(err) { t.Fatalf("expected rollback to remove imported global proxy, got err=%v", err) } inspection, err := aiservice.NewProviderConfigStore(app.configDir, app.secretStore).Inspect() if err != nil { t.Fatalf("Inspect returned error: %v", err) } if len(inspection.ProvidersNeedingMigration) != 1 || inspection.ProvidersNeedingMigration[0] != "openai-main" { t.Fatalf("expected AI provider migration requirement to be restored, got %#v", inspection.ProvidersNeedingMigration) } ref, err := secretstore.BuildRef("ai-provider", "openai-main") if err != nil { t.Fatalf("BuildRef returned error: %v", err) } if _, err := store.Get(ref); !os.IsNotExist(err) { t.Fatalf("expected rollback to remove migrated AI provider secret, got err=%v", err) } }