mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 00:19:40 +08:00
943 lines
30 KiB
Go
943 lines
30 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"encoding/json"
|
|
"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 != SecurityUpdateOverallStatusNeedsAttention {
|
|
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
|
|
}
|
|
if len(initial.Issues) != 1 || initial.Issues[0].Scope != SecurityUpdateIssueScopeAIProvider {
|
|
t.Fatalf("expected AI provider issue, got %#v", initial.Issues)
|
|
}
|
|
|
|
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("RetrySecurityUpdateCurrentRound returned error: %v", err)
|
|
}
|
|
if retried.MigrationID != initial.MigrationID {
|
|
t.Fatalf("expected retry to reuse migration ID %q, got %q", initial.MigrationID, retried.MigrationID)
|
|
}
|
|
if retried.OverallStatus != SecurityUpdateOverallStatusCompleted {
|
|
t.Fatalf("expected completed status after retry, got %q", retried.OverallStatus)
|
|
}
|
|
}
|
|
|
|
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 != SecurityUpdateOverallStatusNeedsAttention {
|
|
t.Fatalf("expected needs_attention 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("RetrySecurityUpdateCurrentRound returned error: %v", err)
|
|
}
|
|
if retried.OverallStatus != SecurityUpdateOverallStatusCompleted {
|
|
t.Fatalf("expected completed status after retry, got %q", retried.OverallStatus)
|
|
}
|
|
|
|
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 != SecurityUpdateOverallStatusNeedsAttention {
|
|
t.Fatalf("expected needs_attention status, got %q", initial.OverallStatus)
|
|
}
|
|
|
|
postponed, err := app.DismissSecurityUpdateReminder()
|
|
if err != nil {
|
|
t.Fatalf("DismissSecurityUpdateReminder returned error: %v", err)
|
|
}
|
|
if postponed.OverallStatus != SecurityUpdateOverallStatusPostponed {
|
|
t.Fatalf("expected postponed status, 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.Fatal("expected postponedAt to be recorded")
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 != SecurityUpdateOverallStatusRolledBack {
|
|
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
|
|
}
|
|
if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem {
|
|
t.Fatalf("expected single system issue, 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 != SecurityUpdateOverallStatusRolledBack {
|
|
t.Fatalf("expected rolled_back status, got %q", status.OverallStatus)
|
|
}
|
|
if len(status.Issues) != 1 || status.Issues[0].Scope != SecurityUpdateIssueScopeSystem {
|
|
t.Fatalf("expected single system issue, 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 != 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) != 0 {
|
|
t.Fatalf("expected rollback to leave no imported connections, got %#v", savedConnections)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|