Files
MyGoNavi/internal/app/security_update_engine_test.go
Syngnat c7cf9526de 🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
2026-04-13 12:40:25 +08:00

942 lines
30 KiB
Go

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)
}
}