feat(security): 完成密文升级与连接恢复包导入导出

This commit is contained in:
tianqijiuyun-latiao
2026-04-10 21:29:45 +08:00
parent 1a042321d2
commit 070ff72ad8
54 changed files with 8644 additions and 332 deletions

5
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# IDE
.idea/
*.iml
.gitignore
# build / release artifacts
frontend/release/
**/release/
@@ -27,4 +27,5 @@ docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
docs
docs
.tmp_superpowers_edit

339
cmd/manualtestseed/main.go Normal file
View File

@@ -0,0 +1,339 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/ai"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
const (
modeSeedSecureStorage = "seed-secure-storage"
modeSeedAIUpdate = "seed-ai-update"
)
const (
testConnectionID = "manualtest-postgres"
testSecureProviderID = "manualtest-secure-provider"
testPendingProviderID = "manualtest-pending-provider"
testBackupDirName = "manual-test-backups"
connectionsFileName = "connections.json"
globalProxyFileName = "global_proxy.json"
aiConfigFileName = "ai_config.json"
securityUpdateFileName = "config-security-update.json"
)
type backupManifest struct {
CreatedAt string `json:"createdAt"`
ConfigDir string `json:"configDir"`
Files []backupManifestFile `json:"files"`
}
type backupManifestFile struct {
RelativePath string `json:"relativePath"`
Existed bool `json:"existed"`
}
type storedAIConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
}
func main() {
mode := flag.String("mode", modeSeedSecureStorage, "seed mode: seed-secure-storage | seed-ai-update")
flag.Parse()
configDir, err := resolveConfigDir()
if err != nil {
fatalf("resolve config dir failed: %v", err)
}
store := secretstore.NewKeyringStore()
if err := store.HealthCheck(); err != nil {
fatalf("secret store unavailable: %v", err)
}
backupDir, err := backupConfigFiles(configDir)
if err != nil {
fatalf("backup config files failed: %v", err)
}
switch strings.TrimSpace(*mode) {
case modeSeedSecureStorage:
if err := seedSecureStorage(configDir, store); err != nil {
fatalf("seed secure storage failed: %v", err)
}
fmt.Printf("mode=%s\nbackup=%s\nconnectionId=%s\nproviderId=%s\n", modeSeedSecureStorage, backupDir, testConnectionID, testSecureProviderID)
case modeSeedAIUpdate:
if err := seedAIUpdate(configDir, store); err != nil {
fatalf("seed ai update failed: %v", err)
}
fmt.Printf("mode=%s\nbackup=%s\npendingProviderId=%s\n", modeSeedAIUpdate, backupDir, testPendingProviderID)
default:
fatalf("unsupported mode: %s", *mode)
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
func resolveConfigDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".gonavi"), nil
}
func backupConfigFiles(configDir string) (string, error) {
backupDir := filepath.Join(configDir, testBackupDirName, time.Now().Format("20060102-150405"))
files := []string{
connectionsFileName,
globalProxyFileName,
aiConfigFileName,
filepath.Join("migrations", securityUpdateFileName),
}
manifest := backupManifest{
CreatedAt: time.Now().Format(time.RFC3339),
ConfigDir: configDir,
Files: make([]backupManifestFile, 0, len(files)),
}
for _, relativePath := range files {
srcPath := filepath.Join(configDir, relativePath)
info, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
manifest.Files = append(manifest.Files, backupManifestFile{
RelativePath: relativePath,
Existed: false,
})
continue
}
return "", err
}
if info.IsDir() {
continue
}
dstPath := filepath.Join(backupDir, relativePath)
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return "", err
}
data, err := os.ReadFile(srcPath)
if err != nil {
return "", err
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
return "", err
}
manifest.Files = append(manifest.Files, backupManifestFile{
RelativePath: relativePath,
Existed: true,
})
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", err
}
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0o644); err != nil {
return "", err
}
return backupDir, nil
}
func seedSecureStorage(configDir string, store secretstore.SecretStore) error {
if err := cleanupKnownTestSecrets(store); err != nil {
return err
}
appService := app.NewAppWithSecretStore(store)
_ = appService.DeleteConnection(testConnectionID)
if _, err := appService.SaveConnection(connection.SavedConnectionInput{
ID: testConnectionID,
Name: "手工测试 PostgreSQL",
Config: connection.ConnectionConfig{
ID: testConnectionID,
Type: "postgres",
Host: "127.0.0.1",
Port: 5432,
User: "postgres",
Password: "manualtest-pg-secret",
Database: "postgres",
},
}); err != nil {
return err
}
if _, err := appService.SaveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 7890,
User: "manual-test",
Password: "manualtest-proxy-secret",
}); err != nil {
return err
}
storeConfig := aiservice.NewProviderConfigStore(configDir, store)
snapshot, err := storeConfig.LoadRuntime()
if err != nil {
return err
}
snapshot.Providers = filterProviders(snapshot.Providers, testSecureProviderID, testPendingProviderID)
snapshot.Providers = append(snapshot.Providers, ai.ProviderConfig{
ID: testSecureProviderID,
Type: "custom",
Name: "手工测试 Secure Provider",
APIKey: "manualtest-ai-secret",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIFormat: "openai",
Headers: map[string]string{
"Authorization": "Bearer manualtest-header-secret",
"X-Trace-Id": "manualtest-visible",
},
MaxTokens: 2048,
Temperature: 0.2,
})
if snapshot.SafetyLevel == "" {
snapshot.SafetyLevel = ai.PermissionReadOnly
}
if snapshot.ContextLevel == "" {
snapshot.ContextLevel = ai.ContextSchemaOnly
}
return storeConfig.Save(snapshot)
}
func seedAIUpdate(configDir string, store secretstore.SecretStore) error {
if err := cleanupKnownTestSecrets(store); err != nil {
return err
}
configPath := filepath.Join(configDir, aiConfigFileName)
cfg, err := readStoredAIConfig(configPath)
if err != nil {
return err
}
cfg.Providers = filterProviders(cfg.Providers, testSecureProviderID, testPendingProviderID)
cfg.Providers = append(cfg.Providers, ai.ProviderConfig{
ID: testPendingProviderID,
Type: "custom",
Name: "手工测试 待迁移 AI",
APIKey: "manualtest-ai-update-secret",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIFormat: "openai",
MaxTokens: 1024,
})
if cfg.SchemaVersion == 0 {
cfg.SchemaVersion = 2
}
if cfg.Providers == nil {
cfg.Providers = []ai.ProviderConfig{}
}
if err := os.MkdirAll(configDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0o644)
}
func readStoredAIConfig(configPath string) (storedAIConfig, error) {
cfg := storedAIConfig{
Providers: []ai.ProviderConfig{},
SafetyLevel: string(ai.PermissionReadOnly),
ContextLevel: string(ai.ContextSchemaOnly),
SchemaVersion: 2,
ActiveProvider: "",
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return storedAIConfig{}, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return storedAIConfig{}, err
}
if cfg.Providers == nil {
cfg.Providers = []ai.ProviderConfig{}
}
return cfg, nil
}
func filterProviders(providers []ai.ProviderConfig, excludedIDs ...string) []ai.ProviderConfig {
excluded := make(map[string]struct{}, len(excludedIDs))
for _, id := range excludedIDs {
excluded[strings.TrimSpace(id)] = struct{}{}
}
filtered := make([]ai.ProviderConfig, 0, len(providers))
for _, provider := range providers {
if _, skip := excluded[strings.TrimSpace(provider.ID)]; skip {
continue
}
filtered = append(filtered, provider)
}
return filtered
}
func cleanupKnownTestSecrets(store secretstore.SecretStore) error {
type secretRef struct {
kind string
id string
}
refs := []secretRef{
{kind: "connection", id: testConnectionID},
{kind: "global-proxy", id: "default"},
{kind: "ai-provider", id: testSecureProviderID},
{kind: "ai-provider", id: testPendingProviderID},
}
for _, item := range refs {
ref, err := secretstore.BuildRef(item.kind, item.id)
if err != nil {
return err
}
if err := store.Delete(ref); err != nil && !isIgnorableDeleteError(err) {
return err
}
}
return nil
}
func isIgnorableDeleteError(err error) bool {
if err == nil || os.IsNotExist(err) {
return true
}
message := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(message, "could not be found") ||
strings.Contains(message, "not be found in the keyring") ||
strings.Contains(message, "element not found")
}

View File

@@ -1 +1 @@
f697e821b4acd5cf614d63d46453e8a4
20168ff7047e0ecea00acb73f413f7db

View File

@@ -1,25 +1,51 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LogPanel from './components/LogPanel';
import AIChatPanel from './components/AIChatPanel';
import AISettingsModal from './components/AISettingsModal';
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal';
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
import { DEFAULT_APPEARANCE, useStore } from './store';
import { SavedConnection } from './types';
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
import { getConnectionWorkbenchState } from './utils/startupReadiness';
import { createGlobalProxyDraft, toSaveGlobalProxyInput } from './utils/globalProxyDraft';
import { LEGACY_PERSIST_KEY, readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './utils/legacyConnectionStorage';
import { toSaveGlobalProxyInput } from './utils/globalProxyDraft';
import { detectConnectionImportKind, normalizeConnectionPackagePassword } from './utils/connectionExport';
import {
bootstrapSecureConfig,
finalizeSecurityUpdateStatus,
mergeSecurityUpdateStatusWithLegacySource,
startSecurityUpdateFromBootstrap,
} from './utils/secureConfigBootstrap';
import {
LEGACY_PERSIST_KEY,
hasLegacyMigratableSensitiveItems,
stripLegacyPersistedConnectionById,
} from './utils/legacyConnectionStorage';
import {
getSecurityUpdateStatusMeta,
resolveSecurityUpdateEntryVisibility,
} from './utils/securityUpdatePresentation';
import {
resolveSecurityUpdateRepairEntry,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
type SecurityUpdateRepairSource,
} from './utils/securityUpdateRepairFlow';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
@@ -38,7 +64,7 @@ import {
resolveAIEdgeHandleDockStyle,
resolveAIEdgeHandleStyle,
} from './utils/aiEntryLayout';
import { SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import { GetSavedConnections, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const { Sider, Content } = Layout;
@@ -48,6 +74,17 @@ const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
const DEFAULT_UI_SCALE = 1.0;
const DEFAULT_FONT_SIZE = 14;
const createEmptySecurityUpdateStatus = (): SecurityUpdateStatus => ({
overallStatus: 'not_detected',
summary: {
total: 0,
updated: 0,
pending: 0,
skipped: 0,
failed: 0,
},
issues: [],
});
const detectNavigatorPlatform = (): string => {
if (typeof navigator === 'undefined') {
@@ -63,16 +100,6 @@ const detectNavigatorPlatform = (): string => {
};
const toLegacySavedConnectionInput = (item: any) => ({
id: typeof item?.id === 'string' ? item.id : '',
name: typeof item?.name === 'string' ? item.name : '',
config: (item?.config && typeof item.config === 'object') ? item.config : {},
includeDatabases: Array.isArray(item?.includeDatabases) ? item.includeDatabases : undefined,
includeRedisDatabases: Array.isArray(item?.includeRedisDatabases) ? item.includeRedisDatabases : undefined,
iconType: typeof item?.iconType === 'string' ? item.iconType : '',
iconColor: typeof item?.iconColor === 'string' ? item.iconColor : '',
});
const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnection[]): SavedConnection[] => {
const merged = new Map<string, SavedConnection>();
current.forEach((conn) => merged.set(conn.id, conn));
@@ -80,6 +107,24 @@ const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnec
return Array.from(merged.values());
};
type ConnectionPackageDialogMode = 'import' | 'export';
type ConnectionPackageDialogState = {
open: boolean;
mode: ConnectionPackageDialogMode;
password: string;
error: string;
confirmLoading: boolean;
};
const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogState => ({
open: false,
mode: 'export',
password: '',
error: '',
confirmLoading: false,
});
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
@@ -124,6 +169,18 @@ function App() {
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false);
const [securityUpdateStatus, setSecurityUpdateStatus] = useState<SecurityUpdateStatus>(() => createEmptySecurityUpdateStatus());
const [securityUpdateRawPayload, setSecurityUpdateRawPayload] = useState<string | null>(null);
const [securityUpdateHasLegacySensitiveItems, setSecurityUpdateHasLegacySensitiveItems] = useState(false);
const [isSecurityUpdateIntroOpen, setIsSecurityUpdateIntroOpen] = useState(false);
const [isSecurityUpdateBannerDismissed, setIsSecurityUpdateBannerDismissed] = useState(false);
const [isSecurityUpdateSettingsOpen, setIsSecurityUpdateSettingsOpen] = useState(false);
const [isSecurityUpdateProgressOpen, setIsSecurityUpdateProgressOpen] = useState(false);
const [securityUpdateProgressStage, setSecurityUpdateProgressStage] = useState('正在检查已保存配置');
const [securityUpdateRepairSource, setSecurityUpdateRepairSource] = useState<SecurityUpdateRepairSource | null>(null);
const [focusedAIProviderId, setFocusedAIProviderId] = useState<string | undefined>(undefined);
const [connectionPackageDialog, setConnectionPackageDialog] = useState<ConnectionPackageDialogState>(() => createClosedConnectionPackageDialogState());
const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState<string | null>(null);
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
@@ -131,6 +188,14 @@ function App() {
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const globalProxyInvalidHintShownRef = React.useRef(false);
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
const securityUpdateStatusMeta = useMemo(
() => getSecurityUpdateStatusMeta(securityUpdateStatus),
[securityUpdateStatus],
);
const securityUpdateEntryVisibility = useMemo(
() => resolveSecurityUpdateEntryVisibility(securityUpdateStatus),
[securityUpdateStatus],
);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
@@ -185,6 +250,39 @@ function App() {
};
}, [isStoreHydrated]);
const normalizeSecurityUpdateStatus = useCallback((status?: Partial<SecurityUpdateStatus> | null): SecurityUpdateStatus => {
const fallback = createEmptySecurityUpdateStatus();
return {
...fallback,
...(status ?? {}),
summary: {
...fallback.summary,
...(status?.summary ?? {}),
},
issues: Array.isArray(status?.issues) ? status.issues : [],
};
}, []);
const applySecurityUpdateStatus = useCallback((
status?: Partial<SecurityUpdateStatus> | null,
options?: {
openSettings?: boolean;
resetBannerDismissed?: boolean;
},
) => {
const nextStatus = normalizeSecurityUpdateStatus(status);
const visibility = resolveSecurityUpdateEntryVisibility(nextStatus);
setSecurityUpdateStatus(nextStatus);
setIsSecurityUpdateIntroOpen(visibility.showIntro);
if (options?.resetBannerDismissed !== false) {
setIsSecurityUpdateBannerDismissed(false);
}
if (options?.openSettings) {
setIsSecurityUpdateSettingsOpen(true);
}
return nextStatus;
}, [normalizeSecurityUpdateStatus]);
useEffect(() => {
if (!isStoreHydrated) {
return;
@@ -192,82 +290,32 @@ function App() {
let cancelled = false;
const loadSecureConfig = async () => {
const backendApp = (window as any).go?.app?.App;
const persistedPayload = typeof window !== 'undefined'
? window.localStorage.getItem(LEGACY_PERSIST_KEY)
: null;
const legacy = readLegacyPersistedSecrets(persistedPayload);
let importedLegacyConnections = false;
let importedLegacyGlobalProxy = false;
if (legacy.connections.length > 0) {
if (typeof backendApp?.ImportLegacyConnections === 'function') {
try {
await backendApp.ImportLegacyConnections(
legacy.connections.map(toLegacySavedConnectionInput)
);
importedLegacyConnections = true;
} catch (err) {
console.warn('Failed to import legacy saved connections', err);
}
} else {
replaceConnections(legacy.connections);
try {
const result = await bootstrapSecureConfig({
backend: (window as any).go?.app?.App,
replaceConnections,
replaceGlobalProxy,
});
if (cancelled) {
return;
}
}
if (legacy.globalProxy) {
if (typeof backendApp?.ImportLegacyGlobalProxy === 'function') {
try {
await backendApp.ImportLegacyGlobalProxy(toSaveGlobalProxyInput(legacy.globalProxy));
importedLegacyGlobalProxy = true;
} catch (err) {
console.warn('Failed to import legacy global proxy', err);
}
} else {
replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy));
setSecurityUpdateRawPayload(result.rawPayload);
setSecurityUpdateHasLegacySensitiveItems(result.hasLegacySensitiveItems);
applySecurityUpdateStatus(result.status);
} catch (err) {
console.warn('Failed to bootstrap secure config', err);
} finally {
if (!cancelled) {
setHasLoadedSecureConfig(true);
}
}
if ((importedLegacyConnections || importedLegacyGlobalProxy) && persistedPayload && typeof window !== 'undefined') {
const sanitizedPayload = stripLegacyPersistedSecrets(persistedPayload);
if (sanitizedPayload && sanitizedPayload !== persistedPayload) {
window.localStorage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
}
if (typeof backendApp?.GetSavedConnections === 'function') {
try {
const savedConnections = await backendApp.GetSavedConnections();
if (!cancelled && Array.isArray(savedConnections)) {
replaceConnections(savedConnections);
}
} catch (err) {
console.warn('Failed to load saved connections from backend', err);
}
}
if (typeof backendApp?.GetGlobalProxyConfig === 'function') {
try {
const proxyResult = await backendApp.GetGlobalProxyConfig();
if (!cancelled && proxyResult?.success && proxyResult.data) {
replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data));
}
} catch (err) {
console.warn('Failed to load global proxy from backend', err);
}
}
if (!cancelled) {
setHasLoadedSecureConfig(true);
}
};
void loadSecureConfig();
return () => {
cancelled = true;
};
}, [isStoreHydrated, replaceConnections, replaceGlobalProxy]);
}, [applySecurityUpdateStatus, isStoreHydrated, replaceConnections, replaceGlobalProxy]);
useEffect(() => {
if (!isStoreHydrated || !hasLoadedSecureConfig) {
@@ -772,6 +820,161 @@ function App() {
const connections = useStore(state => state.connections);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const handleOpenSecurityUpdateSettings = useCallback(() => {
setIsSecurityUpdateIntroOpen(false);
setIsSecurityUpdateSettingsOpen(true);
}, []);
const runSecurityUpdateRound = useCallback(async (mode: 'start' | 'retry' | 'restart') => {
const backendApp = (window as any).go?.app?.App;
const stageText = mode === 'retry'
? '正在校验更新结果'
: '正在更新安全存储';
setSecurityUpdateProgressStage(stageText);
setIsSecurityUpdateProgressOpen(true);
setIsSecurityUpdateIntroOpen(false);
try {
let nextStatus: SecurityUpdateStatus | null = null;
if (mode === 'start') {
const result = await startSecurityUpdateFromBootstrap({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
});
if (result.error) {
throw result.error;
}
nextStatus = normalizeSecurityUpdateStatus(result.status);
} else if (mode === 'retry') {
if (typeof backendApp?.RetrySecurityUpdateCurrentRound !== 'function') {
throw new Error('安全更新能力不可用');
}
nextStatus = normalizeSecurityUpdateStatus(await backendApp.RetrySecurityUpdateCurrentRound({
migrationId: securityUpdateStatus.migrationId,
}));
} else {
if (typeof backendApp?.RestartSecurityUpdate !== 'function') {
throw new Error('安全更新能力不可用');
}
nextStatus = normalizeSecurityUpdateStatus(await backendApp.RestartSecurityUpdate({
migrationId: securityUpdateStatus.migrationId,
sourceType: 'current_app_saved_config',
rawPayload: securityUpdateRawPayload ?? '',
options: {
allowPartial: true,
writeBackup: true,
},
}));
}
if (mode !== 'start') {
nextStatus = await finalizeSecurityUpdateStatus({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
}, nextStatus);
}
const shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
applySecurityUpdateStatus(nextStatus, {
openSettings: shouldOpenSettings,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
setIsSecurityUpdateSettingsOpen(false);
void message.success('已保存配置已完成安全更新');
} else if (nextStatus.overallStatus === 'needs_attention') {
void message.warning('更新尚未完成,有少量配置需要你处理');
} else if (nextStatus.overallStatus === 'rolled_back') {
void message.warning('本次更新未完成,系统已保留当前可用配置');
}
} catch (err: any) {
console.warn('Failed to execute security update round', err);
void message.error(err?.message || '安全更新未完成,请稍后重试');
} finally {
setIsSecurityUpdateProgressOpen(false);
}
}, [
applySecurityUpdateStatus,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
securityUpdateRawPayload,
securityUpdateStatus.migrationId,
]);
const handleStartSecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('start');
}, [runSecurityUpdateRound]);
const handleRetrySecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('retry');
}, [runSecurityUpdateRound]);
const handleRestartSecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('restart');
}, [runSecurityUpdateRound]);
const handlePostponeSecurityUpdate = useCallback(async () => {
const backendApp = (window as any).go?.app?.App;
setIsSecurityUpdateIntroOpen(false);
try {
if (typeof backendApp?.DismissSecurityUpdateReminder === 'function') {
const nextStatus = mergeSecurityUpdateStatusWithLegacySource(
await backendApp.DismissSecurityUpdateReminder(),
securityUpdateRawPayload,
);
applySecurityUpdateStatus(nextStatus);
return;
}
applySecurityUpdateStatus({
overallStatus: 'postponed',
canStart: true,
canPostpone: true,
summary: securityUpdateStatus.summary,
issues: securityUpdateStatus.issues,
});
} catch (err: any) {
console.warn('Failed to dismiss security update reminder', err);
void message.error(err?.message || '暂时无法延后本次安全更新');
}
}, [
applySecurityUpdateStatus,
securityUpdateRawPayload,
securityUpdateStatus.issues,
securityUpdateStatus.summary,
]);
const handleSecurityUpdateIssueAction = useCallback((issue: SecurityUpdateIssue) => {
const repairEntry = resolveSecurityUpdateRepairEntry(issue, connections);
if (repairEntry.type === 'warning') {
void message.warning(repairEntry.message);
return;
}
if (repairEntry.type === 'connection') {
setIsSecurityUpdateSettingsOpen(false);
setSecurityUpdateRepairSource(repairEntry.repairSource);
setEditingConnection(repairEntry.connection);
setIsModalOpen(true);
return;
}
if (repairEntry.type === 'proxy') {
setIsSecurityUpdateSettingsOpen(false);
setSecurityUpdateRepairSource(repairEntry.repairSource);
setIsProxyModalOpen(true);
return;
}
if (repairEntry.type === 'ai') {
setIsSecurityUpdateSettingsOpen(false);
setSecurityUpdateRepairSource(repairEntry.repairSource);
setFocusedAIProviderId(repairEntry.providerId);
setIsAISettingsOpen(true);
return;
}
if (repairEntry.type === 'retry') {
void runSecurityUpdateRound('retry');
return;
}
setSecurityUpdateRepairSource(null);
setIsSecurityUpdateSettingsOpen(true);
}, [connections, runSecurityUpdateRound]);
const updateCheckInFlightRef = React.useRef(false);
const updateDownloadInFlightRef = React.useRef(false);
const updateUserDismissedRef = React.useRef(false);
@@ -1179,37 +1382,74 @@ function App() {
});
}, [activeTabId, tabs, connections, activeContext, addTab]);
const closeConnectionPackageDialog = useCallback(() => {
setConnectionPackageDialog(createClosedConnectionPackageDialogState());
setPendingConnectionImportPayload(null);
}, []);
const refreshConnectionsAfterImport = useCallback(async (importedViews: SavedConnection[]) => {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.GetSavedConnections === 'function') {
const latestConnections = await GetSavedConnections();
if (!Array.isArray(latestConnections)) {
throw new Error('导入成功,但刷新连接列表失败:后端未返回连接列表');
}
replaceConnections(latestConnections as SavedConnection[]);
return;
}
const latestConnections = useStore.getState().connections;
replaceConnections(mergeSavedConnections(latestConnections, importedViews));
}, [replaceConnections]);
const importConnectionsPayload = useCallback(async (raw: string, password: string) => {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.ImportConnectionsPayload !== 'function') {
throw new Error('导入失败:当前后端未提供新版导入能力');
}
const importedViews = await backendApp.ImportConnectionsPayload(raw, password);
if (!Array.isArray(importedViews)) {
throw new Error('导入失败:后端未返回连接列表');
}
await refreshConnectionsAfterImport(importedViews as SavedConnection[]);
return importedViews as SavedConnection[];
}, [refreshConnectionsAfterImport]);
const handleImportConnections = async () => {
const res = await (window as any).go.app.App.ImportConfigFile();
if (res.success) {
try {
const imported = JSON.parse(res.data);
if (!Array.isArray(imported)) {
void message.error("文件格式错误:需要 JSON 数组");
return;
}
const normalizedItems = imported.map(toLegacySavedConnectionInput);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.ImportLegacyConnections === 'function') {
const importedViews = await backendApp.ImportLegacyConnections(normalizedItems);
if (!Array.isArray(importedViews)) {
throw new Error('导入失败:后端未返回连接列表');
}
replaceConnections(mergeSavedConnections(connections, importedViews));
void message.success(`成功导入 ${importedViews.length} 个连接`);
return;
}
const fallbackItems = normalizedItems as SavedConnection[];
replaceConnections(mergeSavedConnections(connections, fallbackItems));
void message.success(`成功导入 ${fallbackItems.length} 个连接`);
} catch (e: any) {
void message.error(e?.message || "解析 JSON 失败");
if (!res.success) {
if (res.message !== "已取消") {
void message.error("导入失败: " + res.message);
}
} else if (res.message !== "已取消") {
void message.error("导入失败: " + res.message);
return;
}
const raw = typeof res.data === 'string' ? res.data : String(res.data ?? '');
const importKind = detectConnectionImportKind(raw);
if (importKind === 'invalid') {
void message.error('文件格式错误:仅支持 GoNavi 恢复包或历史 JSON 连接数组');
return;
}
if (importKind === 'encrypted-package') {
setPendingConnectionImportPayload(raw);
setConnectionPackageDialog({
open: true,
mode: 'import',
password: '',
error: '',
confirmLoading: false,
});
return;
}
try {
const importedViews = await importConnectionsPayload(raw, '');
void message.success(`成功导入 ${importedViews.length} 个连接`);
} catch (e: any) {
void message.error(e?.message || '导入失败');
}
};
@@ -1218,11 +1458,64 @@ function App() {
void message.warning("没有连接可导出");
return;
}
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases','iconType','iconColor'], "connections", "json");
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "已取消") {
void message.error("导出失败: " + res.message);
setConnectionPackageDialog({
open: true,
mode: 'export',
password: '',
error: '',
confirmLoading: false,
});
};
const handleConfirmConnectionPackageDialog = async () => {
const backendApp = (window as any).go?.app?.App;
const password = normalizeConnectionPackagePassword(connectionPackageDialog.password);
if (!password) {
setConnectionPackageDialog((current) => ({
...current,
error: '恢复包密码不能为空',
}));
return;
}
setConnectionPackageDialog((current) => ({
...current,
password,
error: '',
confirmLoading: true,
}));
try {
if (connectionPackageDialog.mode === 'export') {
if (typeof backendApp?.ExportConnectionsPackage !== 'function') {
throw new Error('导出失败:当前后端未提供新版导出能力');
}
const res = await backendApp.ExportConnectionsPackage(password);
if (!res?.success) {
throw new Error(res?.message || '导出失败');
}
closeConnectionPackageDialog();
void message.success('导出成功');
return;
}
if (!pendingConnectionImportPayload) {
throw new Error('导入失败:未找到待导入的恢复包内容');
}
const importedViews = await importConnectionsPayload(pendingConnectionImportPayload, password);
closeConnectionPackageDialog();
void message.success(`成功导入 ${importedViews.length} 个连接`);
} catch (e: any) {
setConnectionPackageDialog((current) => ({
...current,
confirmLoading: false,
error: e?.message || (current.mode === 'export' ? '导出失败' : '导入失败'),
}));
}
};
@@ -1259,7 +1552,10 @@ function App() {
key: 'proxy',
title: '代理',
icon: <GlobalOutlined />,
onClick: () => setIsProxyModalOpen(true),
onClick: () => {
setSecurityUpdateRepairSource(null);
setIsProxyModalOpen(true);
},
},
theme: {
key: 'theme',
@@ -1342,14 +1638,90 @@ function App() {
document.removeEventListener('mouseup', handleLogResizeUp);
};
const handleCreateConnection = () => {
setSecurityUpdateRepairSource(null);
setEditingConnection(null);
setIsModalOpen(true);
};
const handleEditConnection = (conn: SavedConnection) => {
setSecurityUpdateRepairSource(null);
setEditingConnection(conn);
setIsModalOpen(true);
};
const handleConnectionSaved = useCallback(async (savedConnection: SavedConnection) => {
if (!shouldRetrySecurityUpdateAfterRepairSave(securityUpdateRepairSource)) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (securityUpdateStatus.migrationId) {
if (typeof backendApp?.RetrySecurityUpdateCurrentRound !== 'function') {
return;
}
const rawStatus = await backendApp.RetrySecurityUpdateCurrentRound({
migrationId: securityUpdateStatus.migrationId,
});
const nextStatus = await finalizeSecurityUpdateStatus({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
}, normalizeSecurityUpdateStatus(rawStatus));
applySecurityUpdateStatus(nextStatus, {
openSettings: false,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
}
return;
}
if (!securityUpdateRawPayload || !savedConnection?.id) {
return;
}
const nextRawPayload = stripLegacyPersistedConnectionById(securityUpdateRawPayload, savedConnection.id);
if (!nextRawPayload || nextRawPayload === securityUpdateRawPayload) {
return;
}
window.localStorage.setItem(LEGACY_PERSIST_KEY, nextRawPayload);
const rawStatus = typeof backendApp?.GetSecurityUpdateStatus === 'function'
? await backendApp.GetSecurityUpdateStatus()
: securityUpdateStatus;
const nextStatus = mergeSecurityUpdateStatusWithLegacySource(rawStatus, nextRawPayload);
const nextHasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(nextRawPayload);
setSecurityUpdateRawPayload(nextRawPayload);
setSecurityUpdateHasLegacySensitiveItems(nextHasLegacySensitiveItems);
applySecurityUpdateStatus(nextStatus, {
openSettings: false,
});
}, [
applySecurityUpdateStatus,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
securityUpdateRawPayload,
securityUpdateRepairSource,
securityUpdateStatus,
securityUpdateStatus.migrationId,
]);
const handleCloseModal = () => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsModalOpen(false);
setEditingConnection(null);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
};
const handleOpenDriverManagerFromConnection = () => {
@@ -1358,6 +1730,45 @@ function App() {
setIsDriverModalOpen(true);
};
const handleCloseDriverManager = useCallback(() => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsDriverModalOpen(false);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
}, [securityUpdateRepairSource]);
const handleOpenGlobalProxySettings = useCallback(() => {
setSecurityUpdateRepairSource(null);
setIsProxyModalOpen(true);
}, []);
const handleCloseGlobalProxySettings = useCallback(() => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsProxyModalOpen(false);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
}, [securityUpdateRepairSource]);
const handleOpenAISettings = useCallback((providerId?: string) => {
setSecurityUpdateRepairSource(null);
setFocusedAIProviderId(providerId);
setIsAISettingsOpen(true);
}, []);
const handleCloseAISettings = useCallback(() => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsAISettingsOpen(false);
setFocusedAIProviderId(undefined);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
}, [securityUpdateRepairSource]);
const handleTitleBarWindowToggle = async () => {
try {
if (await WindowIsFullscreen()) {
@@ -1811,7 +2222,7 @@ function App() {
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
</Button>
<Button icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" style={sidebarCreateConnectionActionStyle}>
<Button icon={<PlusOutlined />} onClick={handleCreateConnection} title="新建连接" style={sidebarCreateConnectionActionStyle}>
</Button>
</div>
@@ -1912,6 +2323,18 @@ function App() {
/>
</Sider>
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
{securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && (
<SecurityUpdateBanner
status={securityUpdateStatus}
darkMode={darkMode}
overlayTheme={overlayTheme}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
onOpenDetails={handleOpenSecurityUpdateSettings}
onDismiss={() => setIsSecurityUpdateBannerDismissed(true)}
/>
)}
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
<TabManager />
@@ -1928,7 +2351,9 @@ function App() {
{renderAIEdgeHandle()}
</div>
)}
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
handleOpenAISettings();
}} overlayTheme={overlayTheme} />
</div>
)}
</div>
@@ -1946,6 +2371,7 @@ function App() {
onClose={handleCloseModal}
initialValues={editingConnection}
onOpenDriverManager={handleOpenDriverManagerFromConnection}
onSaved={handleConnectionSaved}
/>
<Modal
title={renderUtilityModalTitle(<ToolOutlined />, '工具中心', '集中处理连接配置、同步、驱动和快捷键相关操作。')}
@@ -2007,6 +2433,18 @@ function App() {
setIsShortcutModalOpen(true);
},
},
{
key: 'security-update',
icon: <SafetyCertificateOutlined />,
title: '安全更新',
description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems
? `当前状态:${securityUpdateStatusMeta.label}`
: '查看已保存配置的安全更新状态。',
onClick: () => {
setIsToolsModalOpen(false);
setIsSecurityUpdateSettingsOpen(true);
},
},
].map((item) => (
<Button key={item.key} type="text" style={utilityActionCardStyle} onClick={item.onClick}>
<span style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
@@ -2026,14 +2464,59 @@ function App() {
/>
<DriverManagerModal
open={isDriverModalOpen}
onClose={() => setIsDriverModalOpen(false)}
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
onClose={handleCloseDriverManager}
onOpenGlobalProxySettings={handleOpenGlobalProxySettings}
/>
<SecurityUpdateIntroModal
open={isSecurityUpdateIntroOpen}
loading={isSecurityUpdateProgressOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
onStart={handleStartSecurityUpdate}
onPostpone={handlePostponeSecurityUpdate}
onViewDetails={handleOpenSecurityUpdateSettings}
/>
<SecurityUpdateSettingsModal
open={isSecurityUpdateSettingsOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
status={securityUpdateStatus}
onClose={() => setIsSecurityUpdateSettingsOpen(false)}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
onIssueAction={handleSecurityUpdateIssueAction}
/>
<SecurityUpdateProgressModal
open={isSecurityUpdateProgressOpen}
stageText={securityUpdateProgressStage}
overlayTheme={overlayTheme}
/>
<AISettingsModal
open={isAISettingsOpen}
onClose={() => setIsAISettingsOpen(false)}
onClose={handleCloseAISettings}
darkMode={darkMode}
overlayTheme={overlayTheme}
focusProviderId={focusedAIProviderId}
/>
<ConnectionPackagePasswordModal
open={connectionPackageDialog.open}
title={connectionPackageDialog.mode === 'export' ? '输入导出密码' : '输入导入密码'}
password={connectionPackageDialog.password}
error={connectionPackageDialog.error}
confirmLoading={connectionPackageDialog.confirmLoading}
confirmText={connectionPackageDialog.mode === 'export' ? '开始导出' : '开始导入'}
onPasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
password: value,
error: '',
}));
}}
onConfirm={() => {
void handleConfirmConnectionPackageDialog();
}}
onCancel={closeConnectionPackageDialog}
/>
<Modal
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
@@ -2454,7 +2937,7 @@ function App() {
<Modal
title={renderUtilityModalTitle(<GlobalOutlined />, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')}
open={isProxyModalOpen}
onCancel={() => setIsProxyModalOpen(false)}
onCancel={handleCloseGlobalProxySettings}
footer={null}
width={520}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}

View File

@@ -28,6 +28,7 @@ interface AISettingsModalProps {
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
@@ -79,7 +80,7 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
@@ -135,6 +136,17 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => {
if (!open || !focusProviderId) {
return;
}
if (!providers.some((provider) => provider.id === focusProviderId)) {
return;
}
setActiveSection('providers');
setActiveProviderId(focusProviderId);
}, [focusProviderId, open, providers]);
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
setEditingProvider(session.editingProvider as AIProviderConfig | null);
setIsEditing(session.isEditing);

View File

@@ -5,6 +5,11 @@ import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICO
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
} from '../utils/connectionModalPresentation';
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
@@ -135,7 +140,8 @@ const ConnectionModal: React.FC<{
onClose: () => void;
initialValues?: SavedConnection | null;
onOpenDriverManager?: () => void;
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [useSSL, setUseSSL] = useState(false);
@@ -1443,6 +1449,13 @@ const ConnectionModal: React.FC<{
message.success('配置已保存(未连接)');
}
if (onSaved) {
void Promise.resolve(onSaved(savedConnection)).catch((error: unknown) => {
console.warn('Failed to refresh post-save state', error);
void message.warning('配置已保存,但安全更新状态暂未刷新,请稍后重新检查');
});
}
form.resetFields();
setUseSSL(false);
setUseSSH(false);
@@ -1453,7 +1466,7 @@ const ConnectionModal: React.FC<{
setClearSecrets(createEmptyConnectionSecretClearState());
onClose();
} catch (e: any) {
message.error(e?.message || '保存失败');
message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败'));
} finally {
setLoading(false);
}
@@ -1508,10 +1521,14 @@ const ConnectionModal: React.FC<{
}
return null;
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
return `测试失败: ${normalized}`;
const applyTestFailureFeedback = (feedback: { message: string; shouldToast: boolean }) => {
setTestResult({ type: 'error', message: feedback.message });
if (feedback.shouldToast) {
void message.error({
content: feedback.message,
key: 'connection-test-failure',
});
}
};
const handleTest = async () => {
@@ -1522,14 +1539,21 @@ const ConnectionModal: React.FC<{
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'driver_unavailable',
reason: unavailableReason,
fallback: '驱动未安装启用',
}));
promptInstallDriver(values.type, unavailableReason);
return;
}
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
if (blockingSecretClearMessage) {
setTestResult({ type: 'error', message: blockingSecretClearMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'secret_blocked',
reason: blockingSecretClearMessage,
fallback: '连接参数不完整',
}));
return;
}
setLoading(true);
@@ -1555,6 +1579,7 @@ const ConnectionModal: React.FC<{
);
if (res.success) {
void message.destroy('connection-test-failure');
setTestResult({ type: 'success', message: res.message });
if (isRedisType) {
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
@@ -1578,27 +1603,33 @@ const ConnectionModal: React.FC<{
}
} else {
setDbList([]);
message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`);
message.warning(`连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, '未知错误')}`);
}
}
} else {
const failMessage = buildTestFailureMessage(
res?.message,
'连接被拒绝或参数无效,请检查后重试'
);
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: res?.message,
fallback: '连接被拒绝或参数无效,请检查后重试',
}));
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const failMessage = '测试失败: 请先完善必填项后再测试连接';
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'validation',
reason: '',
fallback: '请先完善必填项后再测试连接',
}));
return;
}
const reason = e instanceof Error
? e.message
: (typeof e === 'string' ? e : '未知异常');
const failMessage = buildTestFailureMessage(reason, '未知异常');
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason,
fallback: '未知异常',
}));
} finally {
testInFlightRef.current = false;
setLoading(false);
@@ -1624,7 +1655,7 @@ const ConnectionModal: React.FC<{
}
const result = await MongoDiscoverMembers(config as any);
if (!result.success) {
message.error(result.message || '成员发现失败');
message.error(normalizeConnectionSecretErrorMessage(result.message, '成员发现失败'));
return;
}
const data = (result.data as Record<string, any>) || {};
@@ -1645,7 +1676,7 @@ const ConnectionModal: React.FC<{
}
message.success(result.message || `发现 ${members.length} 个成员`);
} catch (error: any) {
message.error(error?.message || '成员发现失败');
message.error(normalizeConnectionSecretErrorMessage(error?.message || error, '成员发现失败'));
} finally {
setDiscoveringMembers(false);
}
@@ -2233,7 +2264,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="留空沿用主库用户名" />
</Form.Item>
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主库密码" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
emptyPlaceholder: '留空沿用主库密码',
retainedLabel: '已保存从库密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
@@ -2283,7 +2321,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
</div>
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主密码" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
emptyPlaceholder: '留空沿用主密码',
retainedLabel: '已保存副本集密码',
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'mongoReplicaPassword',
@@ -2364,7 +2409,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
<Form.Item name="password" label="密码 (可选)">
<Input.Password {...noAutoCapInputProps} placeholder="Redis 密码(如果设置了 requirepass" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: 'Redis 密码(如果设置了 requirepass',
retainedLabel: '已保存 Redis 密码',
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'password',
@@ -2397,7 +2449,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} />
</Form.Item>
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
<Input.Password {...noAutoCapInputProps} />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})}
/>
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
@@ -2518,7 +2577,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="root" />
</Form.Item>
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
<Input.Password {...noAutoCapInputProps} placeholder="密码" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasSSHPassword,
emptyPlaceholder: '密码',
retainedLabel: '已保存 SSH 密码',
})}
/>
</Form.Item>
</div>
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
@@ -2573,7 +2639,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasProxyPassword,
emptyPlaceholder: '留空表示无认证',
retainedLabel: '已保存代理密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
@@ -2611,7 +2684,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
emptyPlaceholder: '留空表示无认证',
retainedLabel: '已保存隧道密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Input, Modal, Typography } from 'antd';
const { Text } = Typography;
export interface ConnectionPackagePasswordModalProps {
open: boolean;
title: string;
password: string;
error?: string;
confirmLoading?: boolean;
confirmText?: string;
cancelText?: string;
onPasswordChange: (value: string) => void;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConnectionPackagePasswordModal({
open,
title,
password,
error,
confirmLoading,
confirmText = '确认',
cancelText = '取消',
onPasswordChange,
onConfirm,
onCancel,
}: ConnectionPackagePasswordModalProps) {
return (
<Modal
open={open}
title={title}
okText={confirmText}
cancelText={cancelText}
confirmLoading={confirmLoading}
onOk={onConfirm}
onCancel={onCancel}
destroyOnClose={false}
maskClosable={false}
>
<Input.Password
autoFocus
value={password}
placeholder="请输入恢复包密码"
onChange={(event) => onPasswordChange(event.target.value)}
/>
{error ? (
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
{error}
</Text>
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,135 @@
import { Button } from 'antd';
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import type { SecurityUpdateStatus } from '../types';
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface SecurityUpdateBannerProps {
status: SecurityUpdateStatus;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
onOpenDetails: () => void;
onDismiss: () => void;
}
const resolvePrimaryAction = (
status: SecurityUpdateStatus,
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
) => {
switch (status.overallStatus) {
case 'postponed':
return {
label: '立即更新',
onClick: actions.onStart,
};
case 'needs_attention':
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
case 'rolled_back':
return {
label: '重新开始更新',
onClick: actions.onRestart,
};
default:
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
}
};
const resolveSecondaryAction = (
status: SecurityUpdateStatus,
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
) => {
switch (status.overallStatus) {
case 'needs_attention':
return {
label: '重新检查',
onClick: actions.onRetry,
};
case 'rolled_back':
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
default:
return null;
}
};
const SecurityUpdateBanner = ({
status,
darkMode,
overlayTheme,
onStart,
onRetry,
onRestart,
onOpenDetails,
onDismiss,
}: SecurityUpdateBannerProps) => {
const statusMeta = getSecurityUpdateStatusMeta(status);
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
return (
<div
style={{
margin: '12px 12px 0',
padding: '14px 16px',
borderRadius: 16,
border: overlayTheme.sectionBorder,
background: darkMode
? 'linear-gradient(135deg, rgba(255,214,102,0.08) 0%, rgba(255,255,255,0.03) 100%)'
: 'linear-gradient(135deg, rgba(255,235,170,0.72) 0%, rgba(255,255,255,0.92) 100%)',
display: 'flex',
alignItems: 'center',
gap: 16,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
fontSize: 18,
}}
>
<SafetyCertificateOutlined />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{statusMeta.description}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{secondaryAction ? (
<Button onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
) : null}
<Button type="primary" onClick={primaryAction.onClick}>
{primaryAction.label}
</Button>
<Button type="text" icon={<CloseOutlined />} onClick={onDismiss} />
</div>
</div>
);
};
export type { SecurityUpdateBannerProps };
export default SecurityUpdateBanner;

View File

@@ -0,0 +1,107 @@
import { Button, Modal } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { CSSProperties } from 'react';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface SecurityUpdateIntroModalProps {
open: boolean;
loading?: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onStart: () => void;
onPostpone: () => void;
onViewDetails: () => void;
}
const actionButtonStyle: CSSProperties = {
height: 38,
borderRadius: 12,
paddingInline: 18,
fontWeight: 600,
};
const SecurityUpdateIntroModal = ({
open,
loading = false,
darkMode,
overlayTheme,
onStart,
onPostpone,
onViewDetails,
}: SecurityUpdateIntroModalProps) => {
return (
<Modal
title={(
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 38,
height: 38,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 18,
flexShrink: 0,
}}
>
<SafetyCertificateOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
使
</div>
</div>
</div>
)}
open={open}
closable={!loading}
maskClosable={!loading}
keyboard={!loading}
onCancel={onPostpone}
width={560}
styles={{
content: {
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
},
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
footer={[
<Button key="details" type="primary" ghost style={actionButtonStyle} onClick={onViewDetails} disabled={loading}>
</Button>,
<Button key="later" type="primary" ghost style={actionButtonStyle} onClick={onPostpone} disabled={loading}>
</Button>,
<Button key="start" type="primary" style={actionButtonStyle} loading={loading} onClick={onStart}>
</Button>,
]}
>
<div
style={{
padding: '12px 0 6px',
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
lineHeight: 1.8,
fontSize: 14,
}}
>
使
</div>
</Modal>
);
};
export type { SecurityUpdateIntroModalProps };
export default SecurityUpdateIntroModal;

View File

@@ -0,0 +1,67 @@
import { Modal, Spin } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface SecurityUpdateProgressModalProps {
open: boolean;
stageText: string;
detailText?: string;
overlayTheme: OverlayWorkbenchTheme;
}
const SecurityUpdateProgressModal = ({
open,
stageText,
detailText,
overlayTheme,
}: SecurityUpdateProgressModalProps) => {
return (
<Modal
open={open}
closable={false}
maskClosable={false}
keyboard={false}
footer={null}
width={420}
centered
styles={{
content: {
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
},
header: { display: 'none' },
body: { padding: 28 },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
<div
style={{
width: 52,
height: 52,
borderRadius: 18,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 22,
}}
>
<SafetyCertificateOutlined />
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
{stageText}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
</div>
<Spin size="large" />
</div>
</Modal>
);
};
export type { SecurityUpdateProgressModalProps };
export default SecurityUpdateProgressModal;

View File

@@ -0,0 +1,247 @@
import { Button, Empty, Modal, Tag } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
getSecurityUpdateIssueActionMeta,
getSecurityUpdateIssueSeverityMeta,
getSecurityUpdateItemStatusMeta,
getSecurityUpdateStatusMeta,
sortSecurityUpdateIssues,
} from '../utils/securityUpdatePresentation';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface SecurityUpdateSettingsModalProps {
open: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
status: SecurityUpdateStatus;
onClose: () => void;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
onIssueAction: (issue: SecurityUpdateIssue) => void;
}
const sectionStyle = (overlayTheme: OverlayWorkbenchTheme) => ({
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
padding: 16,
});
const SecurityUpdateSettingsModal = ({
open,
darkMode,
overlayTheme,
status,
onClose,
onStart,
onRetry,
onRestart,
onIssueAction,
}: SecurityUpdateSettingsModalProps) => {
const statusMeta = getSecurityUpdateStatusMeta(status);
const sortedIssues = sortSecurityUpdateIssues(status.issues);
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
const showRetry = status.overallStatus === 'needs_attention';
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
return (
<Modal
title={(
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 38,
height: 38,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 18,
flexShrink: 0,
}}
>
<SafetyCertificateOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
</div>
</div>
</div>
)}
open={open}
onCancel={onClose}
footer={[
showRetry ? (
<Button key="retry" onClick={onRetry}>
</Button>
) : null,
showRestart ? (
<Button key="restart" onClick={onRestart}>
</Button>
) : null,
showStart ? (
<Button key="start" type="primary" onClick={onStart}>
</Button>
) : null,
<Button key="close" onClick={onClose}>
</Button>,
]}
width={760}
styles={{
content: {
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
},
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
>
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
<div style={sectionStyle(overlayTheme)}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
{statusMeta.label}
</div>
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{statusMeta.description}
</div>
</div>
<Tag color={
statusMeta.tone === 'success'
? 'success'
: statusMeta.tone === 'error'
? 'error'
: statusMeta.tone === 'processing'
? 'processing'
: statusMeta.tone === 'warning'
? 'warning'
: 'default'
}>
{statusMeta.label}
</Tag>
</div>
</div>
<div style={sectionStyle(overlayTheme)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
{[
{ label: '总计', value: status.summary.total },
{ label: '已更新', value: status.summary.updated },
{ label: '待处理', value: status.summary.pending },
{ label: '已跳过', value: status.summary.skipped },
{ label: '失败', value: status.summary.failed },
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.75)',
padding: '12px 10px',
}}
>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
</div>
))}
</div>
</div>
<div style={sectionStyle(overlayTheme)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
{sortedIssues.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前没有待处理项"
/>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{sortedIssues.map((issue) => {
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
return (
<div
key={issue.id}
style={{
borderRadius: 12,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.78)',
padding: 14,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
{issue.title || issue.message || issue.id}
</div>
<Tag color={itemStatusMeta.color}>
{itemStatusMeta.label}
</Tag>
<Tag color={issueSeverityMeta.color}>
{issueSeverityMeta.label}
</Tag>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
</div>
</div>
<Button
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
onClick={() => onIssueAction(issue)}
>
{actionMeta.label}
</Button>
</div>
);
})}
</div>
)}
</div>
{status.backupPath ? (
<div style={sectionStyle(overlayTheme)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
</div>
{status.lastError ? (
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
{status.lastError}
</div>
) : null}
</div>
) : null}
</div>
</Modal>
);
};
export type { SecurityUpdateSettingsModalProps };
export default SecurityUpdateSettingsModal;

View File

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./App', () => ({
default: () => null,
}));
const createRootMock = vi.fn(() => ({
render: vi.fn(),
}));
vi.mock('react-dom/client', () => ({
default: {
createRoot: createRootMock,
},
createRoot: createRootMock,
}));
const dayjsLocaleMock = vi.fn();
vi.mock('dayjs', () => ({
default: Object.assign(() => null, {
locale: dayjsLocaleMock,
}),
}));
vi.mock('dayjs/locale/zh-cn', () => ({}));
const loaderConfigMock = vi.fn();
vi.mock('@monaco-editor/react', () => ({
loader: {
config: loaderConfigMock,
},
}));
const defineThemeMock = vi.fn();
vi.mock('monaco-editor', () => ({
editor: {
defineTheme: defineThemeMock,
},
}));
vi.mock('monaco-editor/esm/nls.messages.zh-cn', () => ({}));
const importMain = async () => {
await import('./main');
return (globalThis as typeof globalThis & {
window: {
go?: {
app?: {
App?: {
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string) => Promise<unknown>;
ExportConnectionsPackage: () => Promise<{ success: boolean; message?: string }>;
};
};
};
};
}).window.go?.app?.App;
};
describe('main browser mock', () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
getElementById: vi.fn(() => ({})),
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.resetModules();
});
it('returns explicit browser-mode messages for import picker and package export', async () => {
const app = await importMain();
expect(app).toBeDefined();
await expect(app!.ImportConfigFile()).resolves.toEqual({
success: false,
message: '已取消',
});
await expect(app!.ExportConnectionsPackage()).resolves.toEqual({
success: false,
message: '浏览器 mock 不支持恢复包导出',
});
});
it('rejects non-array payloads instead of treating them as successful imports', async () => {
const app = await importMain();
await expect(app!.ImportConnectionsPayload('{"version":1}')).rejects.toThrow(
'浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组',
);
});
});

View File

@@ -121,7 +121,19 @@ if (typeof window !== 'undefined' && !(window as any).go) {
CheckForUpdates: async () => ({ success: false }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string) => {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((item) => saveMockConnection(item));
}
} catch {
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
}
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
},
ExportConnectionsPackage: async () => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),

View File

@@ -91,4 +91,52 @@ describe('store appearance persistence', () => {
expect(appearance.showDataTableVerticalBorders).toBe(true);
expect(appearance.dataTableColumnWidthMode).toBe('compact');
});
it('does not clear persisted legacy connections during hydration migration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().connections).toHaveLength(1);
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
});
});

View File

@@ -553,6 +553,34 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
return result;
};
const hasLegacyConnectionSecrets = (connections: SavedConnection[]): boolean => {
return connections.some((connection) => {
const config = connection?.config && typeof connection.config === 'object'
? connection.config as unknown as Record<string, unknown>
: {};
const ssh = config.ssh && typeof config.ssh === 'object'
? config.ssh as Record<string, unknown>
: {};
const proxy = config.proxy && typeof config.proxy === 'object'
? config.proxy as Record<string, unknown>
: {};
const httpTunnel = config.httpTunnel && typeof config.httpTunnel === 'object'
? config.httpTunnel as Record<string, unknown>
: {};
return (
toTrimmedString(config.password) !== ''
|| toTrimmedString(ssh.password) !== ''
|| toTrimmedString(proxy.password) !== ''
|| toTrimmedString(httpTunnel.password) !== ''
|| toTrimmedString(config.mysqlReplicaPassword) !== ''
|| toTrimmedString(config.mongoReplicaPassword) !== ''
|| toTrimmedString(config.uri) !== ''
|| toTrimmedString(config.dsn) !== ''
);
});
};
const sanitizeTheme = (value: unknown): 'light' | 'dark' => (value === 'dark' ? 'dark' : 'light');
const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'lower' } => {
@@ -1242,7 +1270,7 @@ export const useStore = create<AppState>()(
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
nextState.connections = [];
nextState.connections = sanitizeConnections(state.connections);
if (version < 5) {
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
} else {
@@ -1254,7 +1282,7 @@ export const useStore = create<AppState>()(
nextState.uiScale = sanitizeUiScale(state.uiScale);
nextState.fontSize = sanitizeFontSize(state.fontSize);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false });
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
@@ -1281,7 +1309,7 @@ export const useStore = create<AppState>()(
return {
...currentState,
...state,
connections: currentState.connections,
connections: sanitizeConnections(state.connections),
connectionTags: sanitizeConnectionTags(state.connectionTags),
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
@@ -1289,7 +1317,7 @@ export const useStore = create<AppState>()(
uiScale: sanitizeUiScale(state.uiScale),
fontSize: sanitizeFontSize(state.fontSize),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }),
globalProxy: sanitizeGlobalProxy(state.globalProxy),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
@@ -1309,30 +1337,39 @@ export const useStore = create<AppState>()(
aiChatSessions: [],
};
},
partialize: (state) => ({
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
partialize: (state) => {
const partialState: Partial<AppState> = {
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: toTrimmedString(state.globalProxy.password) !== ''
? { ...state.globalProxy }
: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
};
if (hasLegacyConnectionSecrets(state.connections)) {
partialState.connections = state.connections;
}
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
}), // Don't persist logs
return partialState as AppState;
}, // Don't persist logs
}
)
);

View File

@@ -262,4 +262,70 @@ export interface AISafetyResult {
warningMessage?: string;
}
export type SecurityUpdateOverallStatus =
| 'not_detected'
| 'pending'
| 'postponed'
| 'in_progress'
| 'needs_attention'
| 'completed'
| 'rolled_back';
export type SecurityUpdateIssueScope = 'connection' | 'global_proxy' | 'ai_provider' | 'system';
export type SecurityUpdateIssueSeverity = 'high' | 'medium' | 'low';
export type SecurityUpdateItemStatus = 'pending' | 'updated' | 'needs_attention' | 'skipped' | 'failed';
export type SecurityUpdateIssueReasonCode =
| 'migration_required'
| 'secret_missing'
| 'field_invalid'
| 'write_conflict'
| 'validation_failed'
| 'environment_blocked';
export type SecurityUpdateIssueAction =
| 'open_connection'
| 'open_proxy_settings'
| 'open_ai_settings'
| 'retry_update'
| 'view_details';
export interface SecurityUpdateSummary {
total: number;
updated: number;
pending: number;
skipped: number;
failed: number;
}
export interface SecurityUpdateIssue {
id: string;
scope?: SecurityUpdateIssueScope;
refId?: string;
title?: string;
severity?: SecurityUpdateIssueSeverity;
status?: SecurityUpdateItemStatus;
reasonCode?: SecurityUpdateIssueReasonCode;
action?: SecurityUpdateIssueAction;
message?: string;
}
export interface SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: SecurityUpdateOverallStatus;
sourceType?: 'current_app_saved_config';
reminderVisible?: boolean;
canStart?: boolean;
canPostpone?: boolean;
canRetry?: boolean;
backupAvailable?: boolean;
backupPath?: string;
startedAt?: string;
updatedAt?: string;
completedAt?: string;
postponedAt?: string;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
lastError?: string;
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import {
detectConnectionImportKind,
normalizeConnectionPackagePassword,
} from './connectionExport';
describe('connectionExport', () => {
it('detects encrypted packages by gonavi envelope kind', () => {
expect(detectConnectionImportKind(JSON.stringify({
schemaVersion: 1,
kind: 'gonavi_connection_package',
cipher: 'AES-256-GCM',
kdf: {
name: 'Argon2id',
memoryKiB: 65536,
timeCost: 3,
parallelism: 4,
salt: 'c2FsdA==',
},
nonce: 'bm9uY2Utbm9uY2U=',
payload: 'encrypted-data',
}))).toBe('encrypted-package');
});
it('detects legacy imports from historical json arrays', () => {
expect(detectConnectionImportKind(JSON.stringify([
{
id: 'conn-1',
name: 'Primary',
config: {
type: 'postgres',
},
},
]))).toBe('legacy-json');
});
it('returns invalid for malformed or unsupported content', () => {
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
kind: 'gonavi_connection_package',
payload: 'encrypted-data',
}))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify([
{
foo: 'bar',
},
]))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
kind: 'other_package',
payload: 'encrypted-data',
}))).toBe('invalid');
expect(detectConnectionImportKind('null')).toBe('invalid');
});
it('trims package passwords before use', () => {
expect(normalizeConnectionPackagePassword(' secret-pass ')).toBe('secret-pass');
expect(normalizeConnectionPackagePassword('\n\t \t')).toBe('');
});
});

View File

@@ -0,0 +1,78 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'encrypted-package' | 'legacy-json' | 'invalid';
type JsonObject = Record<string, unknown>;
const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package';
const isJsonObject = (value: unknown): value is JsonObject => (
typeof value === 'object' && value !== null && !Array.isArray(value)
);
const isConnectionPackageKDF = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.name === 'string'
&& typeof value.memoryKiB === 'number'
&& typeof value.timeCost === 'number'
&& typeof value.parallelism === 'number'
&& typeof value.salt === 'string'
);
const isConnectionPackageEnvelope = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.schemaVersion === 'number'
&& value.kind === CONNECTION_PACKAGE_KIND
&& typeof value.cipher === 'string'
&& isConnectionPackageKDF(value.kdf)
&& typeof value.nonce === 'string'
&& typeof value.payload === 'string'
);
const isLegacyConnectionConfig = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.type === 'string'
);
const isLegacyConnectionItem = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.id === 'string'
&& typeof value.name === 'string'
&& isLegacyConnectionConfig(value.config)
);
const parseConnectionImportRaw = (raw: unknown): unknown => {
if (typeof raw !== 'string') {
return raw;
}
try {
return JSON.parse(raw);
} catch {
return undefined;
}
};
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
const parsed = parseConnectionImportRaw(raw);
if (Array.isArray(parsed) && parsed.every((item) => isLegacyConnectionItem(item))) {
return 'legacy-json';
}
if (isConnectionPackageEnvelope(parsed)) {
return 'encrypted-package';
}
return 'invalid';
};
export const normalizeConnectionPackagePassword = (value: string): string => value.trim();
const legacyExportRemovedError = (): never => {
throw new Error('Legacy connection JSON export has been removed. Use the recovery package flow instead.');
};
export const sanitizeConnectionConfigForExport = (_config: ConnectionConfig): never => legacyExportRemovedError();
export const buildExportableConnections = (_connections: SavedConnection[]): never => legacyExportRemovedError();

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
} from './connectionModalPresentation';
describe('connectionModalPresentation', () => {
it('shows an explicit stored-secret placeholder instead of an empty-looking password field', () => {
expect(getStoredSecretPlaceholder({
hasStoredSecret: true,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})).toBe('••••••(留空表示继续沿用已保存密码)');
});
it('keeps the original placeholder when no stored secret exists', () => {
expect(getStoredSecretPlaceholder({
hasStoredSecret: false,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})).toBe('密码');
});
it('maps missing saved-connection errors to a secret-specific hint', () => {
expect(normalizeConnectionSecretErrorMessage('saved connection not found: conn-1')).toBe(
'未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
);
});
it('preserves existing user-facing messages', () => {
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
});
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: 'saved connection not found: conn-1',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
shouldToast: true,
});
});
it('keeps required-field validation failures inline without an extra toast', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'validation',
reason: '',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 请先完善必填项后再测试连接',
shouldToast: false,
});
});
});

View File

@@ -0,0 +1,78 @@
type StoredSecretPlaceholderOptions = {
hasStoredSecret?: boolean;
emptyPlaceholder: string;
retainedLabel: string;
};
type ConnectionTestFailureKind =
| 'validation'
| 'runtime'
| 'driver_unavailable'
| 'secret_blocked';
type ConnectionTestFailureFeedback = {
message: string;
shouldToast: boolean;
};
const normalizeText = (value: unknown, fallback = ''): string => {
const text = String(value ?? '').trim();
if (!text || text === 'undefined' || text === 'null') {
return fallback;
}
return text;
};
export const getStoredSecretPlaceholder = ({
hasStoredSecret,
emptyPlaceholder,
retainedLabel,
}: StoredSecretPlaceholderOptions): string => (
hasStoredSecret
? `••••••(留空表示继续沿用${retainedLabel}`
: emptyPlaceholder
);
export const normalizeConnectionSecretErrorMessage = (
value: unknown,
fallback = '',
): string => {
const text = normalizeText(value, fallback);
const lower = text.toLowerCase();
if (lower.includes('saved connection not found:')) {
return '未找到当前连接对应的已保存密文,请重新填写密码并保存后再试';
}
if (lower.includes('secret store unavailable')) {
return '系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试';
}
return text;
};
export const resolveConnectionTestFailureFeedback = ({
kind,
reason,
fallback,
}: {
kind: ConnectionTestFailureKind;
reason: unknown;
fallback: string;
}): ConnectionTestFailureFeedback => {
if (kind === 'validation') {
return {
message: '测试失败: 请先完善必填项后再测试连接',
shouldToast: false,
};
}
return {
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
shouldToast: true,
};
};
export type {
ConnectionTestFailureFeedback,
ConnectionTestFailureKind,
};

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
import {
hasLegacyMigratableSensitiveItems,
readLegacyPersistedSecrets,
stripLegacyPersistedConnectionById,
stripLegacyPersistedSecrets,
} from './legacyConnectionStorage';
describe('legacy connection storage', () => {
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
@@ -37,7 +42,7 @@ describe('legacy connection storage', () => {
expect(result.globalProxy?.password).toBe('proxy-secret');
});
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
it('clears legacy connection and proxy source data after cleanup', () => {
const payload = JSON.stringify({
state: {
connections: [
@@ -69,7 +74,110 @@ describe('legacy connection storage', () => {
const parsed = JSON.parse(sanitized);
expect(parsed.state.connections).toEqual([]);
expect(parsed.state.globalProxy.password).toBeUndefined();
expect(parsed.state.globalProxy.hasPassword).toBe(true);
expect(parsed.state.globalProxy).toBeUndefined();
});
it('treats a meaningful legacy global proxy as migratable even when it has no password', () => {
const payload = JSON.stringify({
state: {
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
},
},
});
expect(hasLegacyMigratableSensitiveItems(payload)).toBe(true);
});
it('detects migratable sensitive items before cleanup and clears the signal after cleanup', () => {
const payload = JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
expect(hasLegacyMigratableSensitiveItems(payload)).toBe(true);
expect(hasLegacyMigratableSensitiveItems(stripLegacyPersistedSecrets(payload))).toBe(false);
});
it('removes only the repaired legacy connection while preserving other source data', () => {
const payload = JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
{
id: 'conn-2',
name: 'Replica',
config: {
id: 'conn-2',
type: 'mysql',
host: 'replica.local',
port: 3306,
user: 'root',
password: 'replica-secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const sanitized = stripLegacyPersistedConnectionById(payload, 'conn-1');
const parsed = JSON.parse(sanitized);
expect(parsed.state.connections).toEqual([
expect.objectContaining({
id: 'conn-2',
config: expect.objectContaining({
password: 'replica-secret',
}),
}),
]);
expect(parsed.state.globalProxy).toEqual(expect.objectContaining({
password: 'proxy-secret',
}));
});
});

View File

@@ -79,6 +79,11 @@ export function readLegacyPersistedSecrets(payload: string | null | undefined):
};
}
export function hasLegacyMigratableSensitiveItems(payload: string | null | undefined): boolean {
const legacy = readLegacyPersistedSecrets(payload);
return legacy.connections.length > 0 || legacy.globalProxy !== null;
}
export function stripLegacyPersistedSecrets(payload: string | null | undefined): string {
if (!payload || typeof payload !== 'string') {
return '';
@@ -96,15 +101,42 @@ export function stripLegacyPersistedSecrets(payload: string | null | undefined):
: parsed;
state.connections = [];
if (state.globalProxy && typeof state.globalProxy === 'object') {
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
const password = toTrimmedString(proxy.password);
delete proxy.password;
if (password !== '') {
proxy.hasPassword = true;
}
state.globalProxy = proxy;
if (state.globalProxy !== undefined) {
delete state.globalProxy;
}
return JSON.stringify(parsed);
}
export function stripLegacyPersistedConnectionById(
payload: string | null | undefined,
connectionId: string,
): string {
if (!payload || typeof payload !== 'string') {
return '';
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch {
return payload;
}
const state = parsed.state && typeof parsed.state === 'object'
? parsed.state as Record<string, unknown>
: parsed;
const targetId = toTrimmedString(connectionId);
if (!targetId || !Array.isArray(state.connections)) {
return payload;
}
state.connections = state.connections.filter((item) => {
if (!item || typeof item !== 'object') {
return true;
}
return toTrimmedString((item as { id?: unknown }).id) !== targetId;
});
return JSON.stringify(parsed);
}

View File

@@ -0,0 +1,466 @@
import { describe, expect, it, vi } from 'vitest';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
bootstrapSecureConfig,
finalizeSecurityUpdateStatus,
mergeSecurityUpdateStatusWithLegacySource,
startSecurityUpdateFromBootstrap,
} from './secureConfigBootstrap';
import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage';
const legacyPayload = JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const createMemoryStorage = () => {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
removeItem: (key: string) => {
data.delete(key);
},
};
};
const createBaseArgs = (storage = createMemoryStorage()) => {
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
storage.setItem(LEGACY_PERSIST_KEY, legacyPayload);
return {
storage,
replaceConnections,
replaceGlobalProxy,
};
};
describe('secureConfigBootstrap', () => {
it('builds legacy pending summary and issue list before the first round starts', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.status.summary).toEqual({
total: 2,
updated: 0,
pending: 2,
skipped: 0,
failed: 0,
});
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
scope: 'connection',
refId: 'legacy-1',
action: 'open_connection',
}),
expect.objectContaining({
scope: 'global_proxy',
action: 'open_proxy_settings',
}),
]));
});
it('shows intro when legacy sensitive items exist and backend status is pending', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.shouldShowIntro).toBe(true);
expect(result.shouldShowBanner).toBe(false);
expect(args.replaceConnections).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
);
});
it('keeps banner flow without intro when backend status is postponed', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'postponed',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.shouldShowIntro).toBe(false);
expect(result.shouldShowBanner).toBe(true);
});
it('keeps legacy pending summary and issues when a pre-start round is postponed', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'postponed',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('postponed');
expect(result.status.summary.total).toBe(2);
expect(result.status.summary.pending).toBe(2);
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ scope: 'global_proxy' }),
]));
});
it('merges backend pending issues with legacy source items before the first round starts', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 1, updated: 0, pending: 1, skipped: 0, failed: 0 },
issues: [
{
id: 'ai-provider-openai-main',
scope: 'ai_provider',
refId: 'openai-main',
title: 'OpenAI',
severity: 'medium',
status: 'pending',
reasonCode: 'secret_missing',
action: 'open_ai_settings',
message: 'AI 提供商配置仍需完成安全更新',
},
],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.status.summary).toEqual({
total: 3,
updated: 0,
pending: 3,
skipped: 0,
failed: 0,
});
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ scope: 'ai_provider', refId: 'openai-main' }),
expect.objectContaining({ scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ scope: 'global_proxy' }),
]));
});
it('keeps banner flow without intro when backend status is rolled_back', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'rolled_back',
summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 },
issues: [],
}),
},
});
expect(result.shouldShowIntro).toBe(false);
expect(result.shouldShowBanner).toBe(true);
});
it('loads backend secure config directly when no legacy source exists', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
const result = await bootstrapSecureConfig({
storage,
replaceConnections,
replaceGlobalProxy,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'secure-1',
name: 'Secure',
config: {
id: 'secure-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
},
]),
},
});
expect(result.status.overallStatus).toBe('not_detected');
expect(replaceConnections).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
);
});
it('shows intro when backend status is pending even without legacy local source', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
const result = await bootstrapSecureConfig({
storage,
replaceConnections,
replaceGlobalProxy,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 1, updated: 0, pending: 1, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.shouldShowIntro).toBe(true);
expect(result.shouldShowBanner).toBe(false);
});
it('falls back to legacy visible config when StartSecurityUpdate throws', async () => {
const args = createBaseArgs();
const result = await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockRejectedValue(new Error('boom')),
},
});
expect(result.status).toBeNull();
expect(result.error?.message).toContain('boom');
expect(args.replaceConnections).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
);
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
});
it('starts security update even when rawPayload is empty but backend supports AI-only update', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
const StartSecurityUpdate = vi.fn().mockResolvedValue({
overallStatus: 'completed',
summary: { total: 1, updated: 1, pending: 0, skipped: 0, failed: 0 },
issues: [],
});
const result = await startSecurityUpdateFromBootstrap({
storage,
replaceConnections,
replaceGlobalProxy,
backend: {
StartSecurityUpdate,
},
});
expect(result.error).toBeNull();
expect(result.status?.overallStatus).toBe('completed');
expect(StartSecurityUpdate).toHaveBeenCalledWith({
sourceType: 'current_app_saved_config',
rawPayload: '',
options: {
allowPartial: true,
writeBackup: true,
},
});
});
it('keeps source-side secrets when update ends in needs_attention', async () => {
const args = createBaseArgs();
const result = await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockResolvedValue({
overallStatus: 'needs_attention',
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
issues: [{ id: 'ai-1' }],
}),
GetSavedConnections: vi.fn().mockResolvedValue([]),
},
});
expect(result.status?.overallStatus).toBe('needs_attention');
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
});
it('cleans source-side secrets only after completed update and backend refresh', async () => {
const args = createBaseArgs();
const result = await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockResolvedValue({
overallStatus: 'completed',
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'secure-1',
name: 'Secure',
config: {
id: 'secure-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
hasPrimaryPassword: true,
},
]),
GetGlobalProxyConfig: vi.fn().mockResolvedValue({
success: true,
data: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
hasPassword: true,
},
}),
},
});
expect(result.status?.overallStatus).toBe('completed');
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
expect(args.replaceConnections).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
);
});
it('refreshes backend config and strips source-side secrets when a later round finishes as completed', async () => {
const args = createBaseArgs();
const status = await finalizeSecurityUpdateStatus({
...args,
backend: {
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'secure-1',
name: 'Secure',
config: {
id: 'secure-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
hasPrimaryPassword: true,
},
]),
GetGlobalProxyConfig: vi.fn().mockResolvedValue({
success: true,
data: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
hasPassword: true,
},
}),
},
}, {
overallStatus: 'completed',
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
issues: [],
});
expect(status.overallStatus).toBe('completed');
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
expect(args.replaceConnections).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
);
});
it('reduces legacy pending issues after a single connection is repaired before the first round starts', () => {
const nextPayload = stripLegacyPersistedConnectionById(legacyPayload, 'legacy-1');
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}, nextPayload);
expect(status.overallStatus).toBe('pending');
expect(status.summary).toEqual({
total: 1,
updated: 0,
pending: 1,
skipped: 0,
failed: 0,
});
expect(status.issues).toEqual([
expect.objectContaining({
scope: 'global_proxy',
action: 'open_proxy_settings',
}),
]);
});
});

View File

@@ -0,0 +1,351 @@
import {
GlobalProxyConfig,
SavedConnection,
SecurityUpdateIssue,
SecurityUpdateStatus,
SecurityUpdateSummary,
} from '../types';
import { createGlobalProxyDraft } from './globalProxyDraft';
import {
LEGACY_PERSIST_KEY,
hasLegacyMigratableSensitiveItems,
readLegacyPersistedSecrets,
stripLegacyPersistedSecrets,
} from './legacyConnectionStorage';
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
type BackendGlobalProxyResult = {
success?: boolean;
data?: Partial<GlobalProxyConfig>;
};
type SecurityUpdateBackend = {
GetSecurityUpdateStatus?: () => Promise<Partial<SecurityUpdateStatus> | undefined>;
StartSecurityUpdate?: (request: {
sourceType: 'current_app_saved_config';
rawPayload: string;
options?: {
allowPartial?: boolean;
writeBackup?: boolean;
};
}) => Promise<Partial<SecurityUpdateStatus> | undefined>;
GetSavedConnections?: () => Promise<SavedConnection[]>;
GetGlobalProxyConfig?: () => Promise<BackendGlobalProxyResult | undefined>;
};
type SecureConfigBootstrapArgs = {
backend?: SecurityUpdateBackend;
storage?: StorageLike;
replaceConnections: (connections: SavedConnection[]) => void;
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void;
};
type SecureConfigBootstrapResult = {
status: SecurityUpdateStatus;
rawPayload: string | null;
hasLegacySensitiveItems: boolean;
shouldShowIntro: boolean;
shouldShowBanner: boolean;
};
type StartSecurityUpdateResult = {
status: SecurityUpdateStatus | null;
error: Error | null;
};
const defaultSummary = () => ({
total: 0,
updated: 0,
pending: 0,
skipped: 0,
failed: 0,
});
const hasMeaningfulSummary = (summary: SecurityUpdateSummary): boolean => (
summary.total > 0
|| summary.updated > 0
|| summary.pending > 0
|| summary.skipped > 0
|| summary.failed > 0
);
const buildLegacyPendingDetails = (rawPayload: string | null): {
hasLegacyItems: boolean;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
} => {
const legacy = readLegacyPersistedSecrets(rawPayload);
const issues: SecurityUpdateIssue[] = legacy.connections.map((connection) => ({
id: `legacy-connection-${connection.id}`,
scope: 'connection',
refId: connection.id,
title: connection.name || connection.id,
severity: 'medium',
status: 'pending',
reasonCode: 'migration_required',
action: 'open_connection',
message: '该连接仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
}));
if (legacy.globalProxy) {
issues.push({
id: 'legacy-global-proxy-default',
scope: 'global_proxy',
title: '全局代理',
severity: 'medium',
status: 'pending',
reasonCode: 'migration_required',
action: 'open_proxy_settings',
message: '全局代理仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
});
}
return {
hasLegacyItems: issues.length > 0,
summary: {
total: issues.length,
updated: 0,
pending: issues.length,
skipped: 0,
failed: 0,
},
issues,
};
};
const mergeSecurityUpdateIssues = (
baseIssues: SecurityUpdateIssue[],
legacyIssues: SecurityUpdateIssue[],
): {
issues: SecurityUpdateIssue[];
addedCount: number;
} => {
const issueIds = new Set(baseIssues.map((issue) => issue.id));
const additions = legacyIssues.filter((issue) => !issueIds.has(issue.id));
return {
issues: [...baseIssues, ...additions],
addedCount: additions.length,
};
};
export const mergeSecurityUpdateStatusWithLegacySource = (
status: Partial<SecurityUpdateStatus> | undefined,
rawPayload: string | null,
): SecurityUpdateStatus => {
const base: SecurityUpdateStatus = {
...defaultStatus(),
...status,
summary: {
...defaultSummary(),
...(status?.summary ?? {}),
},
issues: Array.isArray(status?.issues) ? status.issues : [],
};
const legacy = buildLegacyPendingDetails(rawPayload);
if (!legacy.hasLegacyItems) {
return base;
}
if (base.overallStatus === 'not_detected') {
return {
...base,
overallStatus: 'pending',
reminderVisible: true,
canStart: true,
canPostpone: true,
summary: legacy.summary,
issues: legacy.issues,
};
}
if (base.overallStatus === 'pending' || base.overallStatus === 'postponed') {
const mergedIssues = mergeSecurityUpdateIssues(base.issues, legacy.issues);
const summary = hasMeaningfulSummary(base.summary)
? {
total: base.summary.total + mergedIssues.addedCount,
updated: base.summary.updated,
pending: base.summary.pending + mergedIssues.addedCount,
skipped: base.summary.skipped,
failed: base.summary.failed,
}
: legacy.summary;
return {
...base,
summary,
issues: mergedIssues.issues,
canStart: true,
canPostpone: true,
reminderVisible: base.overallStatus === 'pending' ? true : base.reminderVisible,
};
}
return base;
};
const defaultStatus = (): SecurityUpdateStatus => ({
overallStatus: 'not_detected',
summary: defaultSummary(),
issues: [],
});
const resolveStorage = (storage?: StorageLike): StorageLike | undefined => {
if (storage) {
return storage;
}
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
};
const applyLegacyVisibleConfig = (
rawPayload: string | null,
replaceConnections: (connections: SavedConnection[]) => void,
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void,
) => {
const legacy = readLegacyPersistedSecrets(rawPayload);
if (legacy.connections.length > 0) {
replaceConnections(legacy.connections);
}
if (legacy.globalProxy) {
replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy));
}
};
const refreshVisibleConfigFromBackend = async (
backend: SecurityUpdateBackend | undefined,
replaceConnections: (connections: SavedConnection[]) => void,
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void,
allowEmptyConnections: boolean,
) => {
if (typeof backend?.GetSavedConnections === 'function') {
try {
const connections = await backend.GetSavedConnections();
if (Array.isArray(connections) && (allowEmptyConnections || connections.length > 0)) {
replaceConnections(connections);
}
} catch {
// Keep current visible state as fallback.
}
}
if (typeof backend?.GetGlobalProxyConfig === 'function') {
try {
const proxyResult = await backend.GetGlobalProxyConfig();
if (proxyResult?.success && proxyResult.data) {
replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data));
}
} catch {
// Keep current visible state as fallback.
}
}
};
const cleanupLegacySourceIfCompleted = (
storage: StorageLike | undefined,
rawPayload: string | null,
status: SecurityUpdateStatus,
) => {
if (!storage || !rawPayload || status.overallStatus !== 'completed') {
return;
}
const sanitizedPayload = stripLegacyPersistedSecrets(rawPayload);
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
storage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
};
export async function finalizeSecurityUpdateStatus(
args: SecureConfigBootstrapArgs,
rawStatus: Partial<SecurityUpdateStatus> | undefined,
): Promise<SecurityUpdateStatus> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
}
return status;
}
export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Promise<SecureConfigBootstrapResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload);
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
const backendStatus = typeof args.backend?.GetSecurityUpdateStatus === 'function'
? await args.backend.GetSecurityUpdateStatus()
: undefined;
const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload);
if (!hasLegacySensitiveItems) {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
} else if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
}
return {
status,
rawPayload,
hasLegacySensitiveItems,
shouldShowIntro: status.overallStatus === 'pending',
shouldShowBanner: ['postponed', 'rolled_back', 'needs_attention'].includes(status.overallStatus),
};
}
export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstrapArgs): Promise<StartSecurityUpdateResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const startPayload = rawPayload ?? '';
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
if (typeof args.backend?.StartSecurityUpdate !== 'function') {
return {
status: null,
error: new Error('安全更新能力不可用'),
};
}
try {
const rawStatus = await args.backend.StartSecurityUpdate({
sourceType: 'current_app_saved_config',
rawPayload: startPayload,
options: {
allowPartial: true,
writeBackup: true,
},
});
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
}
return { status, error: null };
} catch (error) {
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
return {
status: null,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
export type {
BackendGlobalProxyResult,
SecurityUpdateBackend,
SecureConfigBootstrapArgs,
SecureConfigBootstrapResult,
StartSecurityUpdateResult,
};

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
getSecurityUpdateIssueSeverityMeta,
getSecurityUpdateItemStatusMeta,
getSecurityUpdateIssueActionMeta,
getSecurityUpdateStatusMeta,
resolveSecurityUpdateEntryVisibility,
sortSecurityUpdateIssues,
} from './securityUpdatePresentation';
const createStatus = (overallStatus: SecurityUpdateStatus['overallStatus']): SecurityUpdateStatus => ({
overallStatus,
summary: {
total: 0,
updated: 0,
pending: 0,
skipped: 0,
failed: 0,
},
issues: [],
});
describe('securityUpdatePresentation', () => {
it('sorts issues by severity from high to low', () => {
const issues: SecurityUpdateIssue[] = [
{ id: 'medium-1', severity: 'medium' },
{ id: 'low-1', severity: 'low' },
{ id: 'high-1', severity: 'high' },
{ id: 'medium-2', severity: 'medium' },
];
expect(sortSecurityUpdateIssues(issues).map((issue) => issue.id)).toEqual([
'high-1',
'medium-1',
'medium-2',
'low-1',
]);
});
it('maps needs_attention, rolled_back and completed to stable display labels', () => {
expect(getSecurityUpdateStatusMeta(createStatus('needs_attention')).label).toBe('待处理');
expect(getSecurityUpdateStatusMeta(createStatus('rolled_back')).label).toBe('已回退');
expect(getSecurityUpdateStatusMeta(createStatus('completed')).label).toBe('已完成');
});
it('resolves intro, banner and detail entry visibility for key overall states', () => {
expect(resolveSecurityUpdateEntryVisibility(createStatus('pending'))).toEqual({
showIntro: true,
showBanner: false,
showDetailEntry: true,
});
expect(resolveSecurityUpdateEntryVisibility(createStatus('postponed'))).toEqual({
showIntro: false,
showBanner: true,
showDetailEntry: true,
});
expect(resolveSecurityUpdateEntryVisibility(createStatus('rolled_back'))).toEqual({
showIntro: false,
showBanner: true,
showDetailEntry: true,
});
});
it('maps issue scope actions to existing repair entry labels', () => {
expect(getSecurityUpdateIssueActionMeta({ id: 'conn', scope: 'connection', action: 'open_connection' }).label).toBe('打开连接');
expect(getSecurityUpdateIssueActionMeta({ id: 'proxy', scope: 'global_proxy', action: 'open_proxy_settings' }).label).toBe('代理设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'ai', scope: 'ai_provider', action: 'open_ai_settings' }).label).toBe('AI 设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'system', scope: 'system', action: 'view_details' }).label).toBe('查看详情');
});
it('maps item status to explicit Chinese labels instead of reusing severity wording', () => {
expect(getSecurityUpdateItemStatusMeta('needs_attention')).toEqual({
label: '待处理',
color: 'warning',
});
expect(getSecurityUpdateItemStatusMeta('updated')).toEqual({
label: '已更新',
color: 'success',
});
});
it('maps issue severity to dedicated risk labels', () => {
expect(getSecurityUpdateIssueSeverityMeta('medium')).toEqual({
label: '中风险',
color: 'warning',
});
expect(getSecurityUpdateIssueSeverityMeta('high')).toEqual({
label: '高风险',
color: 'error',
});
});
});

View File

@@ -0,0 +1,210 @@
import type {
SecurityUpdateIssue,
SecurityUpdateIssueAction,
SecurityUpdateIssueSeverity,
SecurityUpdateItemStatus,
SecurityUpdateStatus,
} from '../types';
type SecurityUpdateTone = 'default' | 'warning' | 'processing' | 'success' | 'error';
type SecurityUpdateStatusMeta = {
label: string;
description: string;
tone: SecurityUpdateTone;
};
type SecurityUpdateEntryVisibility = {
showIntro: boolean;
showBanner: boolean;
showDetailEntry: boolean;
};
type SecurityUpdateIssueActionMeta = {
label: string;
emphasis: 'primary' | 'default';
};
type SecurityUpdateBadgeMeta = {
label: string;
color: SecurityUpdateTone;
};
const severityWeight: Record<SecurityUpdateIssueSeverity, number> = {
high: 0,
medium: 1,
low: 2,
};
const actionMetaMap: Record<SecurityUpdateIssueAction, SecurityUpdateIssueActionMeta> = {
open_connection: {
label: '打开连接',
emphasis: 'primary',
},
open_proxy_settings: {
label: '代理设置',
emphasis: 'primary',
},
open_ai_settings: {
label: 'AI 设置',
emphasis: 'primary',
},
retry_update: {
label: '重新检查',
emphasis: 'primary',
},
view_details: {
label: '查看详情',
emphasis: 'default',
},
};
const itemStatusMetaMap: Record<SecurityUpdateItemStatus, SecurityUpdateBadgeMeta> = {
pending: {
label: '待更新',
color: 'processing',
},
updated: {
label: '已更新',
color: 'success',
},
needs_attention: {
label: '待处理',
color: 'warning',
},
skipped: {
label: '已跳过',
color: 'default',
},
failed: {
label: '失败',
color: 'error',
},
};
const issueSeverityMetaMap: Record<SecurityUpdateIssueSeverity, SecurityUpdateBadgeMeta> = {
high: {
label: '高风险',
color: 'error',
},
medium: {
label: '中风险',
color: 'warning',
},
low: {
label: '低风险',
color: 'default',
},
};
export function sortSecurityUpdateIssues(issues: SecurityUpdateIssue[]): SecurityUpdateIssue[] {
return [...issues].sort((left, right) => {
const leftWeight = severityWeight[left.severity ?? 'low'];
const rightWeight = severityWeight[right.severity ?? 'low'];
if (leftWeight !== rightWeight) {
return leftWeight - rightWeight;
}
return left.id.localeCompare(right.id);
});
}
export function getSecurityUpdateStatusMeta(status: SecurityUpdateStatus): SecurityUpdateStatusMeta {
switch (status.overallStatus) {
case 'pending':
return {
label: '待更新',
description: '检测到可进行的安全更新,你可以现在开始或稍后继续。',
tone: 'warning',
};
case 'postponed':
return {
label: '待更新',
description: '本次安全更新已延后,当前可用配置会继续保留。',
tone: 'warning',
};
case 'in_progress':
return {
label: '更新中',
description: '正在检查并更新已保存配置的安全存储。',
tone: 'processing',
};
case 'needs_attention':
return {
label: '待处理',
description: '更新尚未完成,有少量配置需要你处理。',
tone: 'warning',
};
case 'completed':
return {
label: '已完成',
description: '已保存配置已完成安全更新。',
tone: 'success',
};
case 'rolled_back':
return {
label: '已回退',
description: '本次更新未完成,系统已保留当前可用配置。',
tone: 'error',
};
case 'not_detected':
default:
return {
label: '未检测到',
description: '当前没有需要处理的安全更新。',
tone: 'default',
};
}
}
export function resolveSecurityUpdateEntryVisibility(status: SecurityUpdateStatus): SecurityUpdateEntryVisibility {
switch (status.overallStatus) {
case 'pending':
return {
showIntro: true,
showBanner: false,
showDetailEntry: true,
};
case 'postponed':
case 'needs_attention':
case 'rolled_back':
return {
showIntro: false,
showBanner: true,
showDetailEntry: true,
};
case 'completed':
case 'in_progress':
return {
showIntro: false,
showBanner: false,
showDetailEntry: true,
};
case 'not_detected':
default:
return {
showIntro: false,
showBanner: false,
showDetailEntry: false,
};
}
}
export function getSecurityUpdateIssueActionMeta(issue: Partial<SecurityUpdateIssue>): SecurityUpdateIssueActionMeta {
return actionMetaMap[issue.action ?? 'view_details'] ?? actionMetaMap.view_details;
}
export function getSecurityUpdateItemStatusMeta(status?: SecurityUpdateItemStatus): SecurityUpdateBadgeMeta {
return itemStatusMetaMap[status ?? 'pending'] ?? itemStatusMetaMap.pending;
}
export function getSecurityUpdateIssueSeverityMeta(severity?: SecurityUpdateIssueSeverity): SecurityUpdateBadgeMeta {
return issueSeverityMetaMap[severity ?? 'low'] ?? issueSeverityMetaMap.low;
}
export type {
SecurityUpdateBadgeMeta,
SecurityUpdateEntryVisibility,
SecurityUpdateIssueActionMeta,
SecurityUpdateStatusMeta,
SecurityUpdateTone,
};

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, SecurityUpdateIssue } from '../types';
import {
resolveSecurityUpdateRepairEntry,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
} from './securityUpdateRepairFlow';
const createConnection = (id: string): SavedConnection => ({
id,
name: `连接-${id}`,
config: {
id,
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
});
describe('securityUpdateRepairFlow', () => {
it('opens the matching connection and preserves the return source for security update repairs', () => {
const target = createConnection('conn-1');
const issue: SecurityUpdateIssue = {
id: 'issue-1',
action: 'open_connection',
refId: 'conn-1',
};
expect(resolveSecurityUpdateRepairEntry(issue, [target])).toEqual({
type: 'connection',
connection: target,
repairSource: 'connection',
});
});
it('returns a user-facing warning when the target connection no longer exists', () => {
const issue: SecurityUpdateIssue = {
id: 'issue-1',
action: 'open_connection',
refId: 'missing-conn',
};
expect(resolveSecurityUpdateRepairEntry(issue, [createConnection('conn-1')])).toEqual({
type: 'warning',
message: '未找到对应连接,请先重新检查最新状态',
});
});
it('maps proxy, ai and retry actions to the expected repair entry', () => {
expect(resolveSecurityUpdateRepairEntry({ id: 'proxy', action: 'open_proxy_settings' }, [])).toEqual({
type: 'proxy',
repairSource: 'proxy',
});
expect(resolveSecurityUpdateRepairEntry({ id: 'ai', action: 'open_ai_settings', refId: 'provider-1' }, [])).toEqual({
type: 'ai',
providerId: 'provider-1',
repairSource: 'ai',
});
expect(resolveSecurityUpdateRepairEntry({ id: 'retry', action: 'retry_update' }, [])).toEqual({
type: 'retry',
});
});
it('reopens security update details after closing a repair entry opened from that page', () => {
expect(shouldReopenSecurityUpdateDetails('connection')).toBe(true);
expect(shouldReopenSecurityUpdateDetails('proxy')).toBe(true);
expect(shouldReopenSecurityUpdateDetails('ai')).toBe(true);
expect(shouldReopenSecurityUpdateDetails(null)).toBe(false);
});
it('retries the current round automatically after saving a connection from the repair flow', () => {
expect(shouldRetrySecurityUpdateAfterRepairSave('connection')).toBe(true);
expect(shouldRetrySecurityUpdateAfterRepairSave('proxy')).toBe(false);
expect(shouldRetrySecurityUpdateAfterRepairSave('ai')).toBe(false);
expect(shouldRetrySecurityUpdateAfterRepairSave(null)).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
import type { SavedConnection, SecurityUpdateIssue } from '../types';
export type SecurityUpdateRepairSource = 'connection' | 'proxy' | 'ai';
export type SecurityUpdateRepairEntry =
| {
type: 'connection';
connection: SavedConnection;
repairSource: 'connection';
}
| {
type: 'proxy';
repairSource: 'proxy';
}
| {
type: 'ai';
providerId?: string;
repairSource: 'ai';
}
| {
type: 'retry';
}
| {
type: 'details';
}
| {
type: 'warning';
message: string;
};
export const resolveSecurityUpdateRepairEntry = (
issue: SecurityUpdateIssue,
connections: SavedConnection[],
): SecurityUpdateRepairEntry => {
if (issue.action === 'open_connection') {
const target = connections.find((connection) => connection.id === issue.refId);
if (!target) {
return {
type: 'warning',
message: '未找到对应连接,请先重新检查最新状态',
};
}
return {
type: 'connection',
connection: target,
repairSource: 'connection',
};
}
if (issue.action === 'open_proxy_settings') {
return {
type: 'proxy',
repairSource: 'proxy',
};
}
if (issue.action === 'open_ai_settings') {
return {
type: 'ai',
providerId: issue.refId || undefined,
repairSource: 'ai',
};
}
if (issue.action === 'retry_update') {
return {
type: 'retry',
};
}
return {
type: 'details',
};
};
export const shouldReopenSecurityUpdateDetails = (
repairSource: SecurityUpdateRepairSource | null | undefined,
): boolean => repairSource === 'connection' || repairSource === 'proxy' || repairSource === 'ai';
export const shouldRetrySecurityUpdateAfterRepairSave = (
repairSource: SecurityUpdateRepairSource | null | undefined,
): boolean => repairSource === 'connection';

View File

@@ -2,6 +2,7 @@
// This file is automatically generated. DO NOT EDIT
import {connection} from '../models';
import {sync} from '../models';
import {app} from '../models';
import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
@@ -54,6 +55,8 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr
export function DeleteConnection(arg1:string):Promise<void>;
export function DismissSecurityUpdateReminder():Promise<app.SecurityUpdateStatus>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DownloadUpdate():Promise<connection.QueryResult>;
@@ -70,6 +73,8 @@ export function DuplicateConnection(arg1:string):Promise<connection.SavedConnect
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportConnectionsPackage(arg1:string):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
@@ -96,8 +101,12 @@ export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
export function GetSavedConnections():Promise<Array<connection.SavedConnectionView>>;
export function GetSecurityUpdateStatus():Promise<app.SecurityUpdateStatus>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<Array<connection.SavedConnectionView>>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
@@ -190,6 +199,10 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
export function RestartSecurityUpdate(arg1:app.RestartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<connection.SavedConnectionView>;
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
@@ -208,6 +221,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -102,6 +102,10 @@ export function DeleteConnection(arg1) {
return window['go']['app']['App']['DeleteConnection'](arg1);
}
export function DismissSecurityUpdateReminder() {
return window['go']['app']['App']['DismissSecurityUpdateReminder']();
}
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
}
@@ -134,6 +138,10 @@ export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
}
export function ExportConnectionsPackage(arg1) {
return window['go']['app']['App']['ExportConnectionsPackage'](arg1);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
@@ -186,10 +194,18 @@ export function GetSavedConnections() {
return window['go']['app']['App']['GetSavedConnections']();
}
export function GetSecurityUpdateStatus() {
return window['go']['app']['App']['GetSecurityUpdateStatus']();
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
export function ImportConnectionsPayload(arg1, arg2) {
return window['go']['app']['App']['ImportConnectionsPayload'](arg1, arg2);
}
export function ImportData(arg1, arg2, arg3) {
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
}
@@ -207,7 +223,7 @@ export function ImportLegacyGlobalProxy(arg1) {
}
export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
}
export function InstallUpdateAndRestart() {
@@ -374,6 +390,14 @@ export function ResolveDriverRepositoryURL(arg1) {
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
}
export function RestartSecurityUpdate(arg1) {
return window['go']['app']['App']['RestartSecurityUpdate'](arg1);
}
export function RetrySecurityUpdateCurrentRound(arg1) {
return window['go']['app']['App']['RetrySecurityUpdateCurrentRound'](arg1);
}
export function SaveConnection(arg1) {
return window['go']['app']['App']['SaveConnection'](arg1);
}
@@ -410,6 +434,10 @@ export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
export function StartSecurityUpdate(arg1) {
return window['go']['app']['App']['StartSecurityUpdate'](arg1);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -179,6 +179,219 @@ export namespace ai {
}
export namespace app {
export class SecurityUpdateOptions {
allowPartial?: boolean;
writeBackup?: boolean;
static createFrom(source: any = {}) {
return new SecurityUpdateOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.allowPartial = source["allowPartial"];
this.writeBackup = source["writeBackup"];
}
}
export class RestartSecurityUpdateRequest {
migrationId?: string;
sourceType: string;
rawPayload?: string;
options?: SecurityUpdateOptions;
static createFrom(source: any = {}) {
return new RestartSecurityUpdateRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.migrationId = source["migrationId"];
this.sourceType = source["sourceType"];
this.rawPayload = source["rawPayload"];
this.options = this.convertValues(source["options"], SecurityUpdateOptions);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RetrySecurityUpdateRequest {
migrationId?: string;
static createFrom(source: any = {}) {
return new RetrySecurityUpdateRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.migrationId = source["migrationId"];
}
}
export class SecurityUpdateIssue {
id: string;
scope: string;
refId?: string;
title: string;
severity: string;
status: string;
reasonCode: string;
action: string;
message: string;
static createFrom(source: any = {}) {
return new SecurityUpdateIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.scope = source["scope"];
this.refId = source["refId"];
this.title = source["title"];
this.severity = source["severity"];
this.status = source["status"];
this.reasonCode = source["reasonCode"];
this.action = source["action"];
this.message = source["message"];
}
}
export class SecurityUpdateSummary {
total: number;
updated: number;
pending: number;
skipped: number;
failed: number;
static createFrom(source: any = {}) {
return new SecurityUpdateSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
this.updated = source["updated"];
this.pending = source["pending"];
this.skipped = source["skipped"];
this.failed = source["failed"];
}
}
export class SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: string;
sourceType?: string;
reminderVisible: boolean;
canStart: boolean;
canPostpone: boolean;
canRetry: boolean;
backupAvailable: boolean;
backupPath?: string;
startedAt?: string;
updatedAt?: string;
completedAt?: string;
postponedAt?: string;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
lastError?: string;
static createFrom(source: any = {}) {
return new SecurityUpdateStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.schemaVersion = source["schemaVersion"];
this.migrationId = source["migrationId"];
this.overallStatus = source["overallStatus"];
this.sourceType = source["sourceType"];
this.reminderVisible = source["reminderVisible"];
this.canStart = source["canStart"];
this.canPostpone = source["canPostpone"];
this.canRetry = source["canRetry"];
this.backupAvailable = source["backupAvailable"];
this.backupPath = source["backupPath"];
this.startedAt = source["startedAt"];
this.updatedAt = source["updatedAt"];
this.completedAt = source["completedAt"];
this.postponedAt = source["postponedAt"];
this.summary = this.convertValues(source["summary"], SecurityUpdateSummary);
this.issues = this.convertValues(source["issues"], SecurityUpdateIssue);
this.lastError = source["lastError"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StartSecurityUpdateRequest {
sourceType: string;
rawPayload?: string;
options?: SecurityUpdateOptions;
static createFrom(source: any = {}) {
return new StartSecurityUpdateRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sourceType = source["sourceType"];
this.rawPayload = source["rawPayload"];
this.options = this.convertValues(source["options"], SecurityUpdateOptions);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace connection {
export class UpdateRow {

6
go.mod
View File

@@ -26,6 +26,12 @@ require (
modernc.org/sqlite v1.44.3
)
require (
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect

13
go.sum
View File

@@ -38,6 +38,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -126,6 +127,9 @@ github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -174,7 +178,6 @@ github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
@@ -183,6 +186,7 @@ github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -200,6 +204,9 @@ github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTK
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
@@ -356,9 +363,9 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,262 @@
package aiservice
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/secretstore"
)
const (
aiConfigSchemaVersion = 2
aiConfigFileName = "ai_config.json"
)
type aiConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
}
type ProviderConfigStoreSnapshot struct {
Providers []ai.ProviderConfig
ActiveProvider string
SafetyLevel ai.SQLPermissionLevel
ContextLevel ai.ContextLevel
}
type ProviderConfigStoreInspection struct {
Snapshot ProviderConfigStoreSnapshot
ProvidersNeedingMigration []string
}
type ProviderConfigStore struct {
configDir string
secretStore secretstore.SecretStore
}
func NewProviderConfigStore(configDir string, store secretstore.SecretStore) *ProviderConfigStore {
if strings.TrimSpace(configDir) == "" {
configDir = resolveConfigDir()
}
if store == nil {
store = secretstore.NewUnavailableStore("secret store unavailable")
}
return &ProviderConfigStore{
configDir: configDir,
secretStore: store,
}
}
func newProviderConfigStore(configDir string, store secretstore.SecretStore) *ProviderConfigStore {
return NewProviderConfigStore(configDir, store)
}
func (s *ProviderConfigStore) configPath() string {
return filepath.Join(s.configDir, aiConfigFileName)
}
func (s *ProviderConfigStore) Load() (ProviderConfigStoreSnapshot, error) {
cfg, snapshot, err := s.readStoredSnapshot()
if err != nil {
return snapshot, err
}
shouldRewrite := cfg.SchemaVersion != aiConfigSchemaVersion
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
for _, providerConfig := range snapshot.Providers {
runtimeConfig, rewritten, loadErr := s.loadStoredProviderConfig(providerConfig)
if loadErr != nil {
return snapshot, fmt.Errorf("加载 AI Provider secret 失败(provider=%s): %w", providerConfig.ID, loadErr)
}
if rewritten {
shouldRewrite = true
}
providers = append(providers, runtimeConfig)
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
snapshot.Providers = providers
if shouldRewrite {
if err := s.Save(snapshot); err != nil {
return snapshot, fmt.Errorf("重写 AI 配置失败: %w", err)
}
}
return snapshot, nil
}
func (s *ProviderConfigStore) LoadRuntime() (ProviderConfigStoreSnapshot, error) {
_, snapshot, err := s.readStoredSnapshot()
if err != nil {
return snapshot, err
}
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
for _, providerConfig := range snapshot.Providers {
runtimeConfig, loadErr := s.loadRuntimeProviderConfig(providerConfig)
if loadErr != nil {
logger.Error(loadErr, "加载 AI Provider secret 失败provider=%s", providerConfig.ID)
}
providers = append(providers, runtimeConfig)
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
snapshot.Providers = providers
return snapshot, nil
}
func (s *ProviderConfigStore) Inspect() (ProviderConfigStoreInspection, error) {
_, snapshot, err := s.readStoredSnapshot()
inspection := ProviderConfigStoreInspection{
Snapshot: snapshot,
ProvidersNeedingMigration: []string{},
}
if err != nil {
return inspection, err
}
for _, providerConfig := range snapshot.Providers {
if providerNeedsMigration(providerConfig) {
inspection.ProvidersNeedingMigration = append(inspection.ProvidersNeedingMigration, providerConfig.ID)
}
}
return inspection, nil
}
func (s *ProviderConfigStore) Save(snapshot ProviderConfigStoreSnapshot) error {
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
for _, providerConfig := range snapshot.Providers {
runtimeConfig := normalizeProviderConfig(providerConfig)
meta, bundle := splitProviderSecrets(runtimeConfig)
if bundle.hasAny() {
storedMeta, err := persistProviderSecretBundle(s.secretStore, meta, bundle)
if err != nil {
return fmt.Errorf("保存 Provider secret 失败: %w", err)
}
meta = storedMeta
}
providers = append(providers, providerMetadataView(meta))
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
cfg := aiConfig{
SchemaVersion: aiConfigSchemaVersion,
Providers: providers,
ActiveProvider: snapshot.ActiveProvider,
SafetyLevel: string(snapshot.SafetyLevel),
ContextLevel: string(snapshot.ContextLevel),
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("序列化 AI 配置失败: %w", err)
}
if err := os.MkdirAll(s.configDir, 0o755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err)
}
if err := os.WriteFile(s.configPath(), data, 0o644); err != nil {
return fmt.Errorf("写入 AI 配置失败: %w", err)
}
return nil
}
func (s *ProviderConfigStore) readStoredSnapshot() (aiConfig, ProviderConfigStoreSnapshot, error) {
snapshot := ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
}
data, err := os.ReadFile(s.configPath())
if err != nil {
if os.IsNotExist(err) {
return aiConfig{}, snapshot, nil
}
return aiConfig{}, snapshot, fmt.Errorf("读取 AI 配置失败: %w", err)
}
var cfg aiConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return aiConfig{}, snapshot, fmt.Errorf("加载 AI 配置失败: %w", err)
}
snapshot.ActiveProvider = cfg.ActiveProvider
switch ai.SQLPermissionLevel(cfg.SafetyLevel) {
case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull:
snapshot.SafetyLevel = ai.SQLPermissionLevel(cfg.SafetyLevel)
}
switch ai.ContextLevel(cfg.ContextLevel) {
case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults:
snapshot.ContextLevel = ai.ContextLevel(cfg.ContextLevel)
}
providers := make([]ai.ProviderConfig, 0, len(cfg.Providers))
for _, providerConfig := range cfg.Providers {
providers = append(providers, normalizeProviderConfig(providerConfig))
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
snapshot.Providers = providers
return cfg, snapshot, nil
}
func (s *ProviderConfigStore) loadStoredProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, bool, error) {
meta, bundle := splitProviderSecrets(config)
if bundle.hasAny() {
storedMeta, err := persistProviderSecretBundle(s.secretStore, meta, bundle)
if err != nil {
return meta, false, err
}
return mergeProviderSecrets(storedMeta, bundle), true, nil
}
if !meta.HasSecret {
return meta, false, nil
}
resolved, err := resolveProviderConfigSecrets(s.secretStore, meta)
if err != nil {
if os.IsNotExist(err) {
return meta, false, nil
}
return meta, false, err
}
return resolved, false, nil
}
func (s *ProviderConfigStore) loadRuntimeProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, error) {
meta, bundle := splitProviderSecrets(config)
if bundle.hasAny() {
return mergeProviderSecrets(meta, bundle), nil
}
if !meta.HasSecret {
return meta, nil
}
resolved, err := resolveProviderConfigSecrets(s.secretStore, meta)
if err != nil {
return meta, err
}
return resolved, nil
}
func providerNeedsMigration(config ai.ProviderConfig) bool {
_, bundle := splitProviderSecrets(normalizeProviderConfig(config))
return bundle.hasAny()
}

View File

@@ -0,0 +1,206 @@
package aiservice
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/secretstore"
)
func TestProviderConfigStoreLoadMigratesPlaintextProviderSecrets(t *testing.T) {
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
legacy := aiConfig{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "openai",
Name: "OpenAI",
APIKey: "sk-test",
BaseURL: "https://api.openai.com/v1",
Headers: map[string]string{
"Authorization": "Bearer test",
"X-Team": "platform",
},
},
},
}
data, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatalf("MarshalIndent returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(configStore.configDir, aiConfigFileName), data, 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(snapshot.Providers) != 1 {
t.Fatalf("expected 1 provider, got %d", len(snapshot.Providers))
}
if snapshot.Providers[0].APIKey != "sk-test" {
t.Fatalf("expected runtime provider to restore apiKey, got %q", snapshot.Providers[0].APIKey)
}
if snapshot.Providers[0].Headers["Authorization"] != "Bearer test" {
t.Fatalf("expected runtime provider to restore sensitive header, got %#v", snapshot.Providers[0].Headers)
}
stored, err := store.Get(snapshot.Providers[0].SecretRef)
if err != nil {
t.Fatalf("expected migrated provider secret bundle, got %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected migrated apiKey in store, got %q", bundle.APIKey)
}
rewritten, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(rewritten)
if strings.Contains(text, "sk-test") {
t.Fatalf("expected rewritten config to be secretless, got %s", text)
}
if strings.Contains(text, "Bearer test") {
t.Fatalf("expected rewritten config to remove sensitive headers, got %s", text)
}
}
func TestProviderConfigStoreSavePersistsSecretlessMetadata(t *testing.T) {
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "openai",
Name: "OpenAI",
APIKey: "sk-test",
BaseURL: "https://api.openai.com/v1",
Headers: map[string]string{
"Authorization": "Bearer test",
"X-Team": "platform",
},
},
},
ActiveProvider: "openai-main",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(configData)
if strings.Contains(text, "sk-test") {
t.Fatalf("expected config file to be secretless, got %s", text)
}
if strings.Contains(text, "Bearer test") {
t.Fatalf("expected config file to remove sensitive headers, got %s", text)
}
ref, err := secretstore.BuildRef(providerSecretKind, "openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
stored, err := store.Get(ref)
if err != nil {
t.Fatalf("expected provider secret bundle in store, got %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected stored apiKey, got %q", bundle.APIKey)
}
if bundle.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("expected stored sensitive header, got %#v", bundle.SensitiveHeaders)
}
}
func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
store := newFakeProviderSecretStore()
configStore := newProviderConfigStore(t.TempDir(), store)
ref, err := secretstore.BuildRef(providerSecretKind, "openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
payload, err := json.Marshal(providerSecretBundle{
APIKey: "sk-existing",
SensitiveHeaders: map[string]string{
"Authorization": "Bearer existing",
},
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if err := store.Put(ref, payload); err != nil {
t.Fatalf("Put returned error: %v", err)
}
err = configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "openai",
Name: "OpenAI",
HasSecret: true,
SecretRef: ref,
BaseURL: "https://gateway.openai.com/v1",
Headers: map[string]string{
"X-Team": "platform",
},
},
},
ActiveProvider: "openai-main",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
stored, err := store.Get(ref)
if err != nil {
t.Fatalf("expected existing provider secret bundle to remain available, got %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if bundle.APIKey != "sk-existing" {
t.Fatalf("expected existing apiKey to be kept, got %q", bundle.APIKey)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(snapshot.Providers) != 1 {
t.Fatalf("expected 1 provider after reload, got %d", len(snapshot.Providers))
}
if snapshot.Providers[0].APIKey != "sk-existing" {
t.Fatalf("expected reload to restore existing apiKey, got %q", snapshot.Providers[0].APIKey)
}
if snapshot.Providers[0].Headers["Authorization"] != "Bearer existing" {
t.Fatalf("expected reload to restore existing sensitive header, got %#v", snapshot.Providers[0].Headers)
}
}

View File

@@ -120,17 +120,17 @@ func mergeProviderSecrets(cfg ai.ProviderConfig, bundle providerSecretBundle) ai
return merged
}
func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
func persistProviderSecretBundle(store secretstore.SecretStore, meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
meta, _ = splitProviderSecrets(meta)
if !bundle.hasAny() {
meta.HasSecret = false
meta.SecretRef = ""
return meta, nil
}
if s.secretStore == nil {
if store == nil {
return meta, fmt.Errorf("secret store unavailable")
}
if err := s.secretStore.HealthCheck(); err != nil {
if err := store.HealthCheck(); err != nil {
return meta, err
}
@@ -147,7 +147,7 @@ func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle pro
if err != nil {
return meta, fmt.Errorf("序列化 provider secret bundle 失败: %w", err)
}
if err := s.secretStore.Put(ref, payload); err != nil {
if err := store.Put(ref, payload); err != nil {
return meta, err
}
@@ -156,7 +156,7 @@ func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle pro
return meta, nil
}
func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
func resolveProviderConfigSecrets(store secretstore.SecretStore, cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
cfg = normalizeProviderConfig(cfg)
meta, bundle := splitProviderSecrets(cfg)
if bundle.hasAny() {
@@ -165,7 +165,7 @@ func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.Provid
if !meta.HasSecret {
return meta, nil
}
if s.secretStore == nil {
if store == nil {
return meta, fmt.Errorf("secret store unavailable")
}
@@ -179,7 +179,7 @@ func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.Provid
meta.SecretRef = ref
}
payload, err := s.secretStore.Get(ref)
payload, err := store.Get(ref)
if err != nil {
return meta, err
}
@@ -191,6 +191,14 @@ func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.Provid
return mergeProviderSecrets(meta, stored), nil
}
func (s *Service) persistProviderSecretBundle(meta ai.ProviderConfig, bundle providerSecretBundle) (ai.ProviderConfig, error) {
return persistProviderSecretBundle(s.secretStore, meta, bundle)
}
func (s *Service) resolveProviderConfigSecrets(cfg ai.ProviderConfig) (ai.ProviderConfig, error) {
return resolveProviderConfigSecrets(s.secretStore, cfg)
}
func providerMetadataView(cfg ai.ProviderConfig) ai.ProviderConfig {
meta, _ := splitProviderSecrets(normalizeProviderConfig(cfg))
return meta

View File

@@ -82,7 +82,7 @@ func TestResolveProviderConfigSecretsRestoresStoredSecretBundle(t *testing.T) {
}
}
func TestLoadConfigMigratesPlaintextProviderSecrets(t *testing.T) {
func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service.configDir = t.TempDir()
@@ -118,24 +118,28 @@ func TestLoadConfigMigratesPlaintextProviderSecrets(t *testing.T) {
t.Fatalf("expected 1 provider, got %d", len(providers))
}
if providers[0].APIKey != "" {
t.Fatalf("expected migrated provider to be secretless, got %q", providers[0].APIKey)
t.Fatalf("expected provider view to stay secretless, got %q", providers[0].APIKey)
}
if !providers[0].HasSecret {
t.Fatal("expected migrated provider to report HasSecret=true")
t.Fatal("expected provider view to report HasSecret=true")
}
stored, err := store.Get(providers[0].SecretRef)
if len(service.providers) != 1 {
t.Fatalf("expected runtime providers to be loaded, got %d", len(service.providers))
}
if service.providers[0].APIKey != "sk-test" {
t.Fatalf("expected runtime provider to keep plaintext apiKey, got %q", service.providers[0].APIKey)
}
if service.providers[0].Headers["Authorization"] != "Bearer test" {
t.Fatalf("expected runtime provider to keep sensitive header, got %#v", service.providers[0].Headers)
}
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
if err != nil {
t.Fatalf("expected secret bundle in store, got error: %v", err)
t.Fatalf("BuildRef returned error: %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected migrated apiKey in store, got %q", bundle.APIKey)
}
if bundle.SensitiveHeaders["Authorization"] != "Bearer test" {
t.Fatalf("expected migrated sensitive header in store, got %#v", bundle.SensitiveHeaders)
if _, err := store.Get(ref); !os.IsNotExist(err) {
t.Fatalf("expected startup load to avoid secret-store migration, got %v", err)
}
rewritten, err := os.ReadFile(configPath)
@@ -143,11 +147,124 @@ func TestLoadConfigMigratesPlaintextProviderSecrets(t *testing.T) {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(rewritten)
if strings.Contains(text, "sk-test") {
t.Fatalf("expected rewritten config to remove api key, got %s", text)
if !strings.Contains(text, "sk-test") {
t.Fatalf("expected config file to remain unchanged, got %s", text)
}
if strings.Contains(text, "Bearer test") {
t.Fatalf("expected rewritten config to remove sensitive header, got %s", text)
if !strings.Contains(text, "Bearer test") {
t.Fatalf("expected config file to keep sensitive header, got %s", text)
}
}
func TestAISaveProviderKeepsLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service.configDir = t.TempDir()
legacy := aiConfig{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "custom",
Name: "OpenAI",
APIKey: "sk-test",
BaseURL: "",
Headers: map[string]string{
"Authorization": "Bearer test",
"X-Team": "db",
},
},
},
}
data, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatalf("MarshalIndent returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(service.configDir, aiConfigFileName), data, 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
service.loadConfig()
if err := service.AISaveProvider(ai.ProviderConfig{
ID: "openai-main",
Type: "custom",
Name: "OpenAI Updated",
BaseURL: "",
HasSecret: true,
Headers: map[string]string{
"X-Team": "platform",
},
}); err != nil {
t.Fatalf("AISaveProvider returned error: %v", err)
}
if service.providers[0].APIKey != "sk-test" {
t.Fatalf("expected runtime provider to keep legacy plaintext apiKey, got %q", service.providers[0].APIKey)
}
if service.providers[0].Headers["Authorization"] != "Bearer test" {
t.Fatalf("expected runtime provider to keep legacy sensitive header, got %#v", service.providers[0].Headers)
}
ref, err := secretstore.BuildRef("ai-provider", "openai-main")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
stored, err := store.Get(ref)
if err != nil {
t.Fatalf("expected save to persist provider secret bundle, got %v", err)
}
var bundle providerSecretBundle
if err := json.Unmarshal(stored, &bundle); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if bundle.APIKey != "sk-test" {
t.Fatalf("expected persisted apiKey, got %q", bundle.APIKey)
}
}
func TestAITestProviderUsesLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
store := newFakeProviderSecretStore()
service := NewServiceWithSecretStore(store)
service.configDir = t.TempDir()
legacy := aiConfig{
Providers: []ai.ProviderConfig{
{
ID: "openai-main",
Type: "custom",
Name: "OpenAI",
APIKey: "sk-test",
BaseURL: "",
Headers: map[string]string{
"Authorization": "Bearer test",
"X-Team": "db",
},
},
},
}
data, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatalf("MarshalIndent returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(service.configDir, aiConfigFileName), data, 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
service.loadConfig()
result := service.AITestProvider(ai.ProviderConfig{
ID: "openai-main",
Type: "custom",
Name: "OpenAI",
BaseURL: "",
HasSecret: true,
Headers: map[string]string{
"X-Team": "db",
},
})
if success, _ := result["success"].(bool); !success {
t.Fatalf("expected test provider to use in-memory legacy secret, got %#v", result)
}
}

View File

@@ -183,11 +183,16 @@ func (s *Service) AISaveProvider(config ai.ProviderConfig) error {
case found && (config.HasSecret || existing.HasSecret):
meta.SecretRef = existing.SecretRef
meta.HasSecret = config.HasSecret || existing.HasSecret
resolved, err := s.resolveProviderConfigSecrets(meta)
if err != nil {
return fmt.Errorf("读取已保存 Provider secret 失败: %w", err)
meta, existingBundle := applyExistingRuntimeProviderSecrets(meta, existing)
if existingBundle.hasAny() {
runtimeConfig = mergeProviderSecrets(meta, existingBundle)
} else {
resolved, err := s.resolveProviderConfigSecrets(meta)
if err != nil {
return fmt.Errorf("读取已保存 Provider secret 失败: %w", err)
}
runtimeConfig = resolved
}
runtimeConfig = resolved
default:
runtimeConfig = meta
}
@@ -257,22 +262,47 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{
}
if strings.TrimSpace(config.APIKey) == "" && (config.HasSecret || strings.TrimSpace(config.SecretRef) != "") {
s.mu.RLock()
var existing ai.ProviderConfig
found := false
if strings.TrimSpace(config.SecretRef) == "" {
for _, providerConfig := range s.providers {
if providerConfig.ID == config.ID {
existing = providerConfig
found = true
config.SecretRef = providerConfig.SecretRef
config.HasSecret = config.HasSecret || providerConfig.HasSecret
break
}
}
} else {
for _, providerConfig := range s.providers {
if providerConfig.ID == config.ID {
existing = providerConfig
found = true
break
}
}
}
s.mu.RUnlock()
resolved, err := s.resolveProviderConfigSecrets(config)
if err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("连接测试失败: %s", err.Error())}
if found {
config, existingBundle := applyExistingRuntimeProviderSecrets(config, existing)
if existingBundle.hasAny() {
config = mergeProviderSecrets(config, existingBundle)
} else {
resolved, err := s.resolveProviderConfigSecrets(config)
if err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("连接测试失败: %s", err.Error())}
}
config = resolved
}
} else {
resolved, err := s.resolveProviderConfigSecrets(config)
if err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("连接测试失败: %s", err.Error())}
}
config = resolved
}
config = resolved
}
config = normalizeProviderConfig(config)
@@ -462,6 +492,15 @@ func normalizeProviderConfig(config ai.ProviderConfig) ai.ProviderConfig {
return config
}
func applyExistingRuntimeProviderSecrets(meta ai.ProviderConfig, existing ai.ProviderConfig) (ai.ProviderConfig, providerSecretBundle) {
existingMeta, existingBundle := splitProviderSecrets(normalizeProviderConfig(existing))
if strings.TrimSpace(meta.SecretRef) == "" {
meta.SecretRef = strings.TrimSpace(existingMeta.SecretRef)
}
meta.HasSecret = meta.HasSecret || existingMeta.HasSecret || existingBundle.hasAny()
return meta, existingBundle
}
func resolveModelsURL(config ai.ProviderConfig) string {
config = normalizeProviderConfig(config)
providerType := normalizedProviderType(config)
@@ -919,117 +958,27 @@ func (s *Service) getActiveProvider() (provider.Provider, error) {
// --- 配置持久化 ---
const aiConfigSchemaVersion = 2
type aiConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
}
func (s *Service) loadRuntimeProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, bool, error) {
meta, bundle := splitProviderSecrets(config)
if bundle.hasAny() {
storedMeta, err := s.persistProviderSecretBundle(meta, bundle)
if err != nil {
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, err
}
return mergeProviderSecrets(storedMeta, bundle), true, nil
}
resolved, err := s.resolveProviderConfigSecrets(meta)
if err != nil {
return meta, false, err
}
return resolved, false, nil
}
func (s *Service) loadConfig() {
path := filepath.Join(s.configDir, "ai_config.json")
data, err := os.ReadFile(path)
snapshot, err := NewProviderConfigStore(s.configDir, s.secretStore).LoadRuntime()
if err != nil {
return // 首次启动,无配置文件
}
var cfg aiConfig
if err := json.Unmarshal(data, &cfg); err != nil {
logger.Error(err, "加载 AI 配置失败")
return
}
providers := make([]ai.ProviderConfig, 0, len(cfg.Providers))
shouldRewrite := cfg.SchemaVersion != aiConfigSchemaVersion
for _, providerConfig := range cfg.Providers {
runtimeConfig, rewritten, err := s.loadRuntimeProviderConfig(normalizeProviderConfig(providerConfig))
if err != nil {
logger.Error(err, "加载 AI Provider secret 失败provider=%s", providerConfig.ID)
}
if rewritten {
shouldRewrite = true
}
providers = append(providers, runtimeConfig)
}
if providers == nil {
providers = make([]ai.ProviderConfig, 0)
}
s.providers = providers
s.activeProvider = cfg.ActiveProvider
switch ai.SQLPermissionLevel(cfg.SafetyLevel) {
case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull:
s.safetyLevel = ai.SQLPermissionLevel(cfg.SafetyLevel)
default:
s.safetyLevel = ai.PermissionReadOnly
}
s.providers = snapshot.Providers
s.activeProvider = snapshot.ActiveProvider
s.safetyLevel = snapshot.SafetyLevel
s.guard.SetPermissionLevel(s.safetyLevel)
switch ai.ContextLevel(cfg.ContextLevel) {
case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults:
s.contextLevel = ai.ContextLevel(cfg.ContextLevel)
default:
s.contextLevel = ai.ContextSchemaOnly
}
if shouldRewrite {
if err := s.saveConfig(); err != nil {
logger.Error(err, "重写 AI 配置失败")
}
}
s.contextLevel = snapshot.ContextLevel
}
func (s *Service) saveConfig() error {
providers := make([]ai.ProviderConfig, len(s.providers))
for i := range s.providers {
providers[i] = providerMetadataView(s.providers[i])
}
cfg := aiConfig{
SchemaVersion: aiConfigSchemaVersion,
Providers: providers,
return NewProviderConfigStore(s.configDir, s.secretStore).Save(ProviderConfigStoreSnapshot{
Providers: s.providers,
ActiveProvider: s.activeProvider,
SafetyLevel: string(s.safetyLevel),
ContextLevel: string(s.contextLevel),
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("序列化 AI 配置失败: %w", err)
}
if err := os.MkdirAll(s.configDir, 0o755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err)
}
path := filepath.Join(s.configDir, "ai_config.json")
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("写入 AI 配置失败: %w", err)
}
return nil
SafetyLevel: s.safetyLevel,
ContextLevel: s.contextLevel,
})
}
// --- 会话文件持久化 ---

View File

@@ -0,0 +1,228 @@
package app
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"golang.org/x/crypto/argon2"
)
const (
connectionPackageAES256KeyBytes = 32
connectionPackageSaltBytes = 16
connectionPackageNonceBytes = 12
)
type connectionPackageAAD struct {
SchemaVersion int `json:"schemaVersion"`
Kind string `json:"kind"`
Cipher string `json:"cipher"`
KDF connectionPackageKDFSpec `json:"kdf"`
Nonce string `json:"nonce"`
}
func encryptConnectionPackage(payload connectionPackagePayload, password string) (connectionPackageFile, error) {
normalizedPassword := normalizeConnectionPackagePassword(password)
if normalizedPassword == "" {
return connectionPackageFile{}, errConnectionPackagePasswordRequired
}
plain, err := json.Marshal(payload)
if err != nil {
return connectionPackageFile{}, err
}
salt := make([]byte, connectionPackageSaltBytes)
if _, err := rand.Read(salt); err != nil {
return connectionPackageFile{}, err
}
nonce := make([]byte, connectionPackageNonceBytes)
if _, err := rand.Read(nonce); err != nil {
return connectionPackageFile{}, err
}
file := connectionPackageFile{
SchemaVersion: connectionPackageSchemaVersion,
Kind: connectionPackageKind,
Cipher: connectionPackageCipher,
KDF: defaultConnectionPackageKDFSpec(),
Nonce: base64.StdEncoding.EncodeToString(nonce),
}
file.KDF.Salt = base64.StdEncoding.EncodeToString(salt)
key, err := deriveConnectionPackageKey(normalizedPassword, file.KDF)
if err != nil {
return connectionPackageFile{}, err
}
aad, err := marshalConnectionPackageAAD(file)
if err != nil {
return connectionPackageFile{}, err
}
aead, err := newConnectionPackageAEAD(key)
if err != nil {
return connectionPackageFile{}, err
}
ciphertext := aead.Seal(nil, nonce, plain, aad)
file.Payload = base64.StdEncoding.EncodeToString(ciphertext)
return file, nil
}
func decryptConnectionPackage(file connectionPackageFile, password string) (connectionPackagePayload, error) {
normalizedPassword := normalizeConnectionPackagePassword(password)
if normalizedPassword == "" {
return connectionPackagePayload{}, errConnectionPackagePasswordRequired
}
if err := validateConnectionPackageFileHeader(file); err != nil {
return connectionPackagePayload{}, err
}
plain, err := decryptConnectionPackagePlaintext(file, normalizedPassword)
if err != nil {
return connectionPackagePayload{}, errConnectionPackageDecryptFailed
}
var payload connectionPackagePayload
if err := json.Unmarshal(plain, &payload); err != nil {
return connectionPackagePayload{}, errConnectionPackageDecryptFailed
}
return payload, nil
}
func isConnectionPackageEnvelope(raw string) bool {
file, err := decodeConnectionPackageEnvelope(raw)
if err != nil {
return false
}
return file.Kind == connectionPackageKind
}
func encodeConnectionPackageEnvelope(file connectionPackageFile) (string, error) {
raw, err := json.Marshal(file)
if err != nil {
return "", err
}
return string(raw), nil
}
func decodeConnectionPackageEnvelope(raw string) (connectionPackageFile, error) {
var file connectionPackageFile
if err := json.Unmarshal([]byte(raw), &file); err != nil {
return connectionPackageFile{}, err
}
return file, nil
}
func decryptConnectionPackagePlaintext(file connectionPackageFile, password string) ([]byte, error) {
if err := validateConnectionPackageFileHeader(file); err != nil {
return nil, err
}
nonce, err := base64.StdEncoding.DecodeString(file.Nonce)
if err != nil || len(nonce) != connectionPackageNonceBytes {
return nil, errors.New("invalid nonce")
}
ciphertext, err := base64.StdEncoding.DecodeString(file.Payload)
if err != nil || len(ciphertext) == 0 {
return nil, errors.New("invalid payload")
}
key, err := deriveConnectionPackageKey(password, file.KDF)
if err != nil {
return nil, err
}
aad, err := marshalConnectionPackageAAD(file)
if err != nil {
return nil, err
}
aead, err := newConnectionPackageAEAD(key)
if err != nil {
return nil, err
}
plain, err := aead.Open(nil, nonce, ciphertext, aad)
if err != nil {
return nil, err
}
return plain, nil
}
func deriveConnectionPackageKey(password string, spec connectionPackageKDFSpec) ([]byte, error) {
if password == "" {
return nil, errConnectionPackagePasswordRequired
}
if err := validateConnectionPackageKDFSpec(spec); err != nil {
return nil, err
}
salt, err := base64.StdEncoding.DecodeString(spec.Salt)
if err != nil || len(salt) == 0 {
return nil, errors.New("invalid salt")
}
key := argon2.IDKey(
[]byte(password),
salt,
spec.TimeCost,
spec.MemoryKiB,
spec.Parallelism,
connectionPackageAES256KeyBytes,
)
return key, nil
}
func marshalConnectionPackageAAD(file connectionPackageFile) ([]byte, error) {
aad := connectionPackageAAD{
SchemaVersion: file.SchemaVersion,
Kind: file.Kind,
Cipher: file.Cipher,
KDF: file.KDF,
Nonce: file.Nonce,
}
return json.Marshal(aad)
}
func newConnectionPackageAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func validateConnectionPackageFileHeader(file connectionPackageFile) error {
switch {
case file.SchemaVersion != connectionPackageSchemaVersion:
return errConnectionPackageUnsupported
case strings.TrimSpace(file.Kind) != connectionPackageKind:
return errConnectionPackageUnsupported
case strings.TrimSpace(file.Cipher) != connectionPackageCipher:
return errConnectionPackageUnsupported
case validateConnectionPackageKDFSpec(file.KDF) != nil:
return errConnectionPackageUnsupported
default:
return nil
}
}
func validateConnectionPackageKDFSpec(spec connectionPackageKDFSpec) error {
switch {
case strings.TrimSpace(spec.Name) != connectionPackageKDFName:
return errConnectionPackageUnsupported
case spec.MemoryKiB == 0 || spec.TimeCost == 0 || spec.Parallelism == 0:
return errConnectionPackageUnsupported
case spec.MemoryKiB > connectionPackageKDFMaxMemoryKiB:
return errConnectionPackageUnsupported
case spec.TimeCost > connectionPackageKDFMaxTimeCost:
return errConnectionPackageUnsupported
case spec.Parallelism > connectionPackageKDFMaxParallelism:
return errConnectionPackageUnsupported
default:
return nil
}
}

View File

@@ -0,0 +1,224 @@
package app
import (
"encoding/json"
"errors"
"reflect"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestConnectionPackageCryptoRoundTrip(t *testing.T) {
payload := connectionPackagePayload{
ExportedAt: "2026-04-10T12:00:00+08:00",
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "local-mysql",
IncludeDatabases: []string{"app"},
IconType: "database",
IconColor: "#2f855a",
Config: connection.ConnectionConfig{
Type: "mysql",
Host: "127.0.0.1",
Port: 3306,
User: "root",
Database: "app",
},
},
},
}
file, err := encryptConnectionPackage(payload, "strong-password")
if err != nil {
t.Fatalf("encryptConnectionPackage returned error: %v", err)
}
raw, err := json.Marshal(file)
if err != nil {
t.Fatalf("json.Marshal envelope returned error: %v", err)
}
if !isConnectionPackageEnvelope(string(raw)) {
t.Fatalf("isConnectionPackageEnvelope should return true for valid envelope")
}
var decoded connectionPackageFile
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("json.Unmarshal envelope returned error: %v", err)
}
got, err := decryptConnectionPackage(decoded, "strong-password")
if err != nil {
t.Fatalf("decryptConnectionPackage returned error: %v", err)
}
if !reflect.DeepEqual(got, payload) {
t.Fatalf("round-trip mismatch: got=%+v want=%+v", got, payload)
}
}
func TestConnectionPackageDecryptWrongPasswordReturnsUnifiedError(t *testing.T) {
payload := connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "test",
Config: connection.ConnectionConfig{
Type: "mysql",
},
},
},
}
file, err := encryptConnectionPackage(payload, "correct-password")
if err != nil {
t.Fatalf("encryptConnectionPackage returned error: %v", err)
}
_, err = decryptConnectionPackage(file, "wrong-password")
if !errors.Is(err, errConnectionPackageDecryptFailed) {
t.Fatalf("wrong password should return unified error, got: %v", err)
}
}
func TestConnectionPackageDecryptTamperedHeaderFailsAADValidation(t *testing.T) {
payload := connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "test",
Config: connection.ConnectionConfig{
Type: "mysql",
},
},
},
}
file, err := encryptConnectionPackage(payload, "correct-password")
if err != nil {
t.Fatalf("encryptConnectionPackage returned error: %v", err)
}
t.Run("cipher", func(t *testing.T) {
tampered := file
tampered.Nonce = "AAAAAAAAAAAAAAAA"
_, err := decryptConnectionPackage(tampered, "correct-password")
if !errors.Is(err, errConnectionPackageDecryptFailed) {
t.Fatalf("tampered nonce should fail with unified error, got: %v", err)
}
})
t.Run("kdf-salt", func(t *testing.T) {
tampered := file
tampered.KDF.Salt = "AAAAAAAAAAAAAAAAAAAAAA=="
_, err := decryptConnectionPackage(tampered, "correct-password")
if !errors.Is(err, errConnectionPackageDecryptFailed) {
t.Fatalf("tampered kdf salt should fail with unified error, got: %v", err)
}
})
}
func TestConnectionPackagePasswordRequired(t *testing.T) {
payload := connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "test",
Config: connection.ConnectionConfig{
Type: "mysql",
},
},
},
}
_, err := encryptConnectionPackage(payload, " ")
if !errors.Is(err, errConnectionPackagePasswordRequired) {
t.Fatalf("encryptConnectionPackage should return password required error, got: %v", err)
}
_, err = decryptConnectionPackage(connectionPackageFile{}, " ")
if !errors.Is(err, errConnectionPackagePasswordRequired) {
t.Fatalf("decryptConnectionPackage should return password required error, got: %v", err)
}
}
func TestConnectionPackageDecryptUnsupportedHeaderReturnsUnsupportedError(t *testing.T) {
payload := connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "test",
Config: connection.ConnectionConfig{
Type: "mysql",
},
},
},
}
file, err := encryptConnectionPackage(payload, "correct-password")
if err != nil {
t.Fatalf("encryptConnectionPackage returned error: %v", err)
}
t.Run("schemaVersion", func(t *testing.T) {
tampered := file
tampered.SchemaVersion = tampered.SchemaVersion + 1
_, err := decryptConnectionPackage(tampered, "correct-password")
if !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("unsupported schemaVersion should return unsupported error, got: %v", err)
}
})
t.Run("kind", func(t *testing.T) {
tampered := file
tampered.Kind = "other_connection_package"
_, err := decryptConnectionPackage(tampered, "correct-password")
if !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("unsupported kind should return unsupported error, got: %v", err)
}
})
t.Run("cipher", func(t *testing.T) {
tampered := file
tampered.Cipher = "AES-128-GCM"
_, err := decryptConnectionPackage(tampered, "correct-password")
if !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("unsupported cipher should return unsupported error, got: %v", err)
}
})
t.Run("kdf-name", func(t *testing.T) {
tampered := file
tampered.KDF.Name = "PBKDF2"
_, err := decryptConnectionPackage(tampered, "correct-password")
if !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("unsupported kdf name should return unsupported error, got: %v", err)
}
})
}
func TestValidateConnectionPackageKDFSpecRejectsOversizedParams(t *testing.T) {
t.Run("memory", func(t *testing.T) {
spec := defaultConnectionPackageKDFSpec()
spec.MemoryKiB = connectionPackageKDFMaxMemoryKiB + 1
if err := validateConnectionPackageKDFSpec(spec); !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("oversized memory should return unsupported error, got: %v", err)
}
})
t.Run("timeCost", func(t *testing.T) {
spec := defaultConnectionPackageKDFSpec()
spec.TimeCost = connectionPackageKDFMaxTimeCost + 1
if err := validateConnectionPackageKDFSpec(spec); !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("oversized timeCost should return unsupported error, got: %v", err)
}
})
t.Run("parallelism", func(t *testing.T) {
spec := defaultConnectionPackageKDFSpec()
spec.Parallelism = connectionPackageKDFMaxParallelism + 1
if err := validateConnectionPackageKDFSpec(spec); !errors.Is(err, errConnectionPackageUnsupported) {
t.Fatalf("oversized parallelism should return unsupported error, got: %v", err)
}
})
}

View File

@@ -0,0 +1,229 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle) connectionPackageItem {
return connectionPackageItem{
ID: view.ID,
Name: view.Name,
IncludeDatabases: cloneStringSlice(view.IncludeDatabases),
IncludeRedisDatabases: cloneIntSlice(view.IncludeRedisDatabases),
IconType: view.IconType,
IconColor: view.IconColor,
Config: view.Config,
Secrets: bundle,
}
}
func (a *App) buildConnectionPackagePayload() (connectionPackagePayload, error) {
repo := a.savedConnectionRepository()
items, err := repo.List()
if err != nil {
return connectionPackagePayload{}, err
}
connections := make([]connectionPackageItem, 0, len(items))
for _, item := range items {
bundle, bundleErr := repo.loadSecretBundle(item)
if bundleErr != nil {
return connectionPackagePayload{}, bundleErr
}
connections = append(connections, newConnectionPackageItem(item, bundle))
}
return connectionPackagePayload{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Connections: connections,
}, nil
}
func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connection.SavedConnectionInput {
id := strings.TrimSpace(item.ID)
if id == "" {
id = strings.TrimSpace(item.Config.ID)
}
config := item.Config
config.ID = id
config.SavePassword = false
secrets := item.Secrets
config.Password = secrets.Password
config.SSH.Password = secrets.SSHPassword
config.Proxy.Password = secrets.ProxyPassword
config.HTTPTunnel.Password = secrets.HTTPTunnelPassword
config.MySQLReplicaPassword = secrets.MySQLReplicaPassword
config.MongoReplicaPassword = secrets.MongoReplicaPassword
config.URI = secrets.OpaqueURI
config.DSN = secrets.OpaqueDSN
return connection.SavedConnectionInput{
ID: id,
Name: item.Name,
Config: config,
IncludeDatabases: cloneStringSlice(item.IncludeDatabases),
IncludeRedisDatabases: cloneIntSlice(item.IncludeRedisDatabases),
IconType: item.IconType,
IconColor: item.IconColor,
// 连接恢复包以最新导入文件为准;载荷中缺失的密文字段需要显式清空旧值。
ClearPrimaryPassword: strings.TrimSpace(secrets.Password) == "",
ClearSSHPassword: strings.TrimSpace(secrets.SSHPassword) == "",
ClearProxyPassword: strings.TrimSpace(secrets.ProxyPassword) == "",
ClearHTTPTunnelPassword: strings.TrimSpace(secrets.HTTPTunnelPassword) == "",
ClearMySQLReplicaPassword: strings.TrimSpace(secrets.MySQLReplicaPassword) == "",
ClearMongoReplicaPassword: strings.TrimSpace(secrets.MongoReplicaPassword) == "",
ClearOpaqueURI: strings.TrimSpace(secrets.OpaqueURI) == "",
ClearOpaqueDSN: strings.TrimSpace(secrets.OpaqueDSN) == "",
}
}
func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ([]connection.SavedConnectionView, error) {
repo := a.savedConnectionRepository()
rollbackSnapshot, err := captureConnectionPackageImportRollbackSnapshot(a, payload)
if err != nil {
return nil, err
}
result := make([]connection.SavedConnectionView, 0, len(payload.Connections))
for _, item := range payload.Connections {
view, err := repo.Save(newSavedConnectionInputFromPackageItem(item))
if err != nil {
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
return nil, errors.Join(err, fmt.Errorf("restore connection package rollback: %w", rollbackErr))
}
return nil, err
}
result = append(result, view)
}
return result, nil
}
func (a *App) ImportConnectionsPayload(raw string, password string) ([]connection.SavedConnectionView, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, errConnectionPackageUnsupported
}
if isConnectionPackageEnvelope(trimmed) {
var file connectionPackageFile
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
return nil, errConnectionPackageUnsupported
}
payload, err := decryptConnectionPackage(file, password)
if err != nil {
return nil, err
}
return a.importConnectionPackagePayload(payload)
}
var legacy []connection.LegacySavedConnection
if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil {
return nil, errConnectionPackageUnsupported
}
return a.ImportLegacyConnections(legacy)
}
type connectionPackageImportRollbackSnapshot struct {
connectionsFileExists bool
connectionsFileData []byte
connectionSecrets map[string]securityUpdateSecretSnapshot
connectionCleanupRefs []string
}
func captureConnectionPackageImportRollbackSnapshot(a *App, payload connectionPackagePayload) (connectionPackageImportRollbackSnapshot, error) {
snapshot := connectionPackageImportRollbackSnapshot{
connectionSecrets: make(map[string]securityUpdateSecretSnapshot),
}
repo := a.savedConnectionRepository()
connectionFileData, connectionFileExists, err := readOptionalFile(repo.connectionsPath())
if err != nil {
return snapshot, err
}
snapshot.connectionsFileExists = connectionFileExists
snapshot.connectionsFileData = connectionFileData
existingConnections, err := repo.load()
if err != nil {
return snapshot, err
}
existingConnectionsByID := make(map[string]connection.SavedConnectionView, len(existingConnections))
for _, item := range existingConnections {
existingConnectionsByID[item.ID] = item
}
cleanupSet := make(map[string]struct{})
seenIDs := make(map[string]struct{})
for _, item := range payload.Connections {
input := newSavedConnectionInputFromPackageItem(item)
connectionID := strings.TrimSpace(input.ID)
if connectionID == "" {
continue
}
if _, alreadySeen := seenIDs[connectionID]; alreadySeen {
continue
}
seenIDs[connectionID] = struct{}{}
defaultRef, refErr := secretstore.BuildRef(savedConnectionSecretKind, connectionID)
if refErr == nil {
cleanupSet[defaultRef] = struct{}{}
}
existing, ok := existingConnectionsByID[connectionID]
if !ok || !savedConnectionViewHasSecrets(existing) {
continue
}
ref := strings.TrimSpace(existing.SecretRef)
if ref == "" {
ref = defaultRef
}
if ref == "" {
continue
}
secretSnapshot, captureErr := captureSecurityUpdateSecretSnapshot(a.secretStore, ref)
if captureErr != nil {
return snapshot, captureErr
}
snapshot.connectionSecrets[ref] = secretSnapshot
cleanupSet[ref] = struct{}{}
}
snapshot.connectionCleanupRefs = make([]string, 0, len(cleanupSet))
for ref := range cleanupSet {
snapshot.connectionCleanupRefs = append(snapshot.connectionCleanupRefs, ref)
}
return snapshot, nil
}
func (s connectionPackageImportRollbackSnapshot) restore(a *App) error {
repo := a.savedConnectionRepository()
if err := restoreOptionalFile(repo.connectionsPath(), s.connectionsFileExists, s.connectionsFileData); err != nil {
return err
}
for ref, secretSnapshot := range s.connectionSecrets {
if err := restoreSecurityUpdateSecretSnapshot(a.secretStore, ref, secretSnapshot); err != nil {
return err
}
}
for _, ref := range s.connectionCleanupRefs {
if _, alreadyRestored := s.connectionSecrets[ref]; alreadyRestored {
continue
}
if err := deleteSecurityUpdateSecretRef(a.secretStore, ref); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,529 @@
package app
import (
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
func TestBuildConnectionPackagePayloadIncludesSecretBundles(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "db-secret",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.local",
Port: 22,
User: "ops",
Password: "ssh-secret",
},
URI: "postgres://postgres:db-secret@db.local/app",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
payload, err := app.buildConnectionPackagePayload()
if err != nil {
t.Fatalf("buildConnectionPackagePayload returned error: %v", err)
}
if _, parseErr := time.Parse(time.RFC3339, payload.ExportedAt); parseErr != nil {
t.Fatalf("expected RFC3339 exportedAt, got %q", payload.ExportedAt)
}
if len(payload.Connections) != 1 {
t.Fatalf("expected 1 connection in payload, got %d", len(payload.Connections))
}
item := payload.Connections[0]
if item.ID != "conn-1" {
t.Fatalf("expected ID=conn-1, got %q", item.ID)
}
if item.Config.Password != "" {
t.Fatalf("payload metadata must stay secretless, got password=%q", item.Config.Password)
}
if item.Config.SSH.Password != "" {
t.Fatalf("payload metadata must stay secretless for SSH, got %q", item.Config.SSH.Password)
}
if item.Config.URI != "" {
t.Fatalf("payload metadata must stay secretless for URI, got %q", item.Config.URI)
}
if item.Secrets.Password != "db-secret" {
t.Fatalf("expected bundled primary password, got %q", item.Secrets.Password)
}
if item.Secrets.SSHPassword != "ssh-secret" {
t.Fatalf("expected bundled SSH password, got %q", item.Secrets.SSHPassword)
}
if item.Secrets.OpaqueURI != "postgres://postgres:db-secret@db.local/app" {
t.Fatalf("expected bundled URI secret, got %q", item.Secrets.OpaqueURI)
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if strings.Contains(string(raw), "secretRef") {
t.Fatalf("payload must not contain secretRef, got %s", string(raw))
}
}
func TestImportConnectionPackagePayloadOverwritesExistingSecrets(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.old.local",
Port: 5432,
User: "postgres",
Password: "old-primary",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.old.local",
Port: 22,
User: "ops",
Password: "old-ssh",
},
URI: "postgres://old",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
imported, err := app.importConnectionPackagePayload(connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "Imported",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.new.local",
Port: 5432,
User: "postgres",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.new.local",
Port: 22,
User: "ops",
},
},
Secrets: connectionSecretBundle{
Password: "new-primary",
},
},
},
})
if err != nil {
t.Fatalf("importConnectionPackagePayload returned error: %v", err)
}
if len(imported) != 1 {
t.Fatalf("expected 1 imported item, got %d", len(imported))
}
if imported[0].Name != "Imported" {
t.Fatalf("expected imported name, got %q", imported[0].Name)
}
if !imported[0].HasPrimaryPassword {
t.Fatal("expected primary password to be present after overwrite")
}
if imported[0].HasSSHPassword {
t.Fatal("expected SSH password to be cleared by package overwrite")
}
if imported[0].HasOpaqueURI {
t.Fatal("expected URI secret to be cleared by package overwrite")
}
resolved, err := app.resolveConnectionSecrets(imported[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "new-primary" {
t.Fatalf("expected primary password to be overwritten, got %q", resolved.Password)
}
if resolved.SSH.Password != "" {
t.Fatalf("expected SSH password to be cleared, got %q", resolved.SSH.Password)
}
if resolved.URI != "" {
t.Fatalf("expected URI secret to be cleared, got %q", resolved.URI)
}
}
func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.importConnectionPackagePayload(connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-dup",
Name: "First",
Config: connection.ConnectionConfig{
ID: "conn-dup",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
Secrets: connectionSecretBundle{Password: "first-secret"},
},
{
ID: "conn-dup",
Name: "Second",
Config: connection.ConnectionConfig{
ID: "conn-dup",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
Secrets: connectionSecretBundle{Password: "second-secret"},
},
},
})
if err != nil {
t.Fatalf("importConnectionPackagePayload returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected 1 saved item after duplicate id overwrite, got %d", len(saved))
}
if saved[0].Name != "Second" {
t.Fatalf("expected latest item to win, got %q", saved[0].Name)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "second-secret" {
t.Fatalf("expected latest secret to win, got %q", resolved.Password)
}
}
func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "conn-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
store := newFailOnPutSecretStore(failRef)
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
_, err = app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Existing",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.old.local",
Port: 5432,
User: "postgres",
Password: "old-primary",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
imported, err := app.importConnectionPackagePayload(connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "Imported Existing",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.new.local",
Port: 5432,
User: "postgres",
},
Secrets: connectionSecretBundle{Password: "new-primary"},
},
{
ID: "conn-2",
Name: "Imported New",
Config: connection.ConnectionConfig{
ID: "conn-2",
Type: "mysql",
Host: "db.second.local",
Port: 3306,
User: "root",
},
Secrets: connectionSecretBundle{Password: "second-primary"},
},
},
})
if err == nil {
t.Fatal("expected importConnectionPackagePayload to return error")
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected rollback to restore exactly 1 connection, got %d", len(saved))
}
if saved[0].ID != "conn-1" || saved[0].Name != "Existing" {
t.Fatalf("expected rollback to restore original connection metadata, got %#v", saved[0])
}
if saved[0].Config.Host != "db.old.local" {
t.Fatalf("expected rollback to restore original host, got %q", saved[0].Config.Host)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "old-primary" {
t.Fatalf("expected rollback to restore original primary password, got %q", resolved.Password)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {
t.Fatalf("expected rollback to remove partially imported secret ref, got err=%v", err)
}
}
func TestImportConnectionsPayloadLegacyJSONKeepsExistingSecretWhenMissing(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "legacy-1",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "legacy-secret",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
raw, err := json.Marshal([]connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy Updated",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported) != 1 {
t.Fatalf("expected 1 imported item, got %d", len(imported))
}
if imported[0].Name != "Legacy Updated" {
t.Fatalf("expected legacy metadata to be overwritten, got %q", imported[0].Name)
}
resolved, err := app.resolveConnectionSecrets(imported[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "legacy-secret" {
t.Fatalf("expected legacy import to preserve existing secret, got %q", resolved.Password)
}
}
func TestImportConnectionsPayloadEnvelopeRequiresPassword(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
raw := `{
"schemaVersion": 1,
"kind": "gonavi_connection_package",
"cipher": "AES-256-GCM",
"kdf": {
"name": "Argon2id",
"memoryKiB": 65536,
"timeCost": 3,
"parallelism": 4,
"salt": "salt"
},
"nonce": "nonce",
"payload": "payload"
}`
_, err := app.ImportConnectionsPayload(raw, "")
if !errors.Is(err, errConnectionPackagePasswordRequired) {
t.Fatalf("expected errConnectionPackagePasswordRequired, got %v", err)
}
}
func TestImportConnectionsPayloadEnvelopeImportsAndOverwritesSecrets(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Existing",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.old.local",
Port: 5432,
User: "postgres",
Password: "old-primary",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.old.local",
Port: 22,
User: "ops",
Password: "old-ssh",
},
URI: "postgres://old",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
file, err := encryptConnectionPackage(connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-1",
Name: "Imported",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.new.local",
Port: 5432,
User: "postgres",
},
Secrets: connectionSecretBundle{
Password: "new-primary",
},
},
},
}, "package-password")
if err != nil {
t.Fatalf("encryptConnectionPackage returned error: %v", err)
}
raw, err := json.Marshal(file)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
imported, err := app.ImportConnectionsPayload(string(raw), "package-password")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported) != 1 {
t.Fatalf("expected 1 imported item, got %d", len(imported))
}
if imported[0].Name != "Imported" {
t.Fatalf("expected imported name, got %q", imported[0].Name)
}
if !imported[0].HasPrimaryPassword {
t.Fatal("expected primary password after envelope import")
}
if imported[0].HasSSHPassword {
t.Fatal("expected missing SSH password in package to clear old secret")
}
if imported[0].HasOpaqueURI {
t.Fatal("expected missing URI in package to clear old secret")
}
resolved, err := app.resolveConnectionSecrets(imported[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "new-primary" {
t.Fatalf("expected primary password to be overwritten, got %q", resolved.Password)
}
if resolved.SSH.Password != "" {
t.Fatalf("expected SSH password to be cleared, got %q", resolved.SSH.Password)
}
if resolved.URI != "" {
t.Fatalf("expected URI secret to be cleared, got %q", resolved.URI)
}
}
func TestNormalizeConnectionPackageExportFilenameAddsExtension(t *testing.T) {
filename := normalizeConnectionPackageExportFilename(`C:\tmp\connections`)
if !strings.HasSuffix(filename, connectionPackageExtension) {
t.Fatalf("expected filename to end with %q, got %q", connectionPackageExtension, filename)
}
alreadyExtended := normalizeConnectionPackageExportFilename(`C:\tmp\connections` + connectionPackageExtension)
if alreadyExtended != `C:\tmp\connections`+connectionPackageExtension {
t.Fatalf("expected existing extension to be preserved, got %q", alreadyExtended)
}
}
type failOnPutSecretStore struct {
base *fakeAppSecretStore
failRef string
}
func newFailOnPutSecretStore(failRef string) *failOnPutSecretStore {
return &failOnPutSecretStore{
base: newFakeAppSecretStore(),
failRef: failRef,
}
}
func (s *failOnPutSecretStore) Put(ref string, payload []byte) error {
if ref == s.failRef {
return errors.New("injected put failure")
}
return s.base.Put(ref, payload)
}
func (s *failOnPutSecretStore) Get(ref string) ([]byte, error) {
return s.base.Get(ref)
}
func (s *failOnPutSecretStore) Delete(ref string) error {
return s.base.Delete(ref)
}
func (s *failOnPutSecretStore) HealthCheck() error {
return s.base.HealthCheck()
}

View File

@@ -0,0 +1,77 @@
package app
import (
"errors"
"strings"
"GoNavi-Wails/internal/connection"
)
const (
connectionPackageSchemaVersion = 1
connectionPackageKind = "gonavi_connection_package"
connectionPackageCipher = "AES-256-GCM"
connectionPackageKDFName = "Argon2id"
connectionPackageExtension = ".gonavi-conn"
connectionPackageKDFDefaultMemoryKiB = 65536
connectionPackageKDFDefaultTimeCost = 3
connectionPackageKDFDefaultParallelism = 4
connectionPackageKDFMaxMemoryKiB = 262144
connectionPackageKDFMaxTimeCost = 10
connectionPackageKDFMaxParallelism = 16
)
var (
errConnectionPackagePasswordRequired = errors.New("恢复包密码不能为空")
errConnectionPackageDecryptFailed = errors.New("文件密码错误或文件已损坏")
errConnectionPackageUnsupported = errors.New("不支持的连接恢复包格式")
errConnectionPackageNotImplemented = errors.New("connection package not implemented")
)
type connectionPackageFile struct {
SchemaVersion int `json:"schemaVersion"`
Kind string `json:"kind"`
Cipher string `json:"cipher"`
KDF connectionPackageKDFSpec `json:"kdf"`
Nonce string `json:"nonce"`
Payload string `json:"payload"`
}
type connectionPackageKDFSpec struct {
Name string `json:"name"`
MemoryKiB uint32 `json:"memoryKiB"`
TimeCost uint32 `json:"timeCost"`
Parallelism uint8 `json:"parallelism"`
Salt string `json:"salt"`
}
type connectionPackagePayload struct {
ExportedAt string `json:"exportedAt,omitempty"`
Connections []connectionPackageItem `json:"connections"`
}
type connectionPackageItem struct {
ID string `json:"id"`
Name string `json:"name"`
IncludeDatabases []string `json:"includeDatabases,omitempty"`
IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"`
IconType string `json:"iconType,omitempty"`
IconColor string `json:"iconColor,omitempty"`
Config connection.ConnectionConfig `json:"config"`
Secrets connectionSecretBundle `json:"secrets,omitempty"`
}
func defaultConnectionPackageKDFSpec() connectionPackageKDFSpec {
return connectionPackageKDFSpec{
Name: connectionPackageKDFName,
MemoryKiB: connectionPackageKDFDefaultMemoryKiB,
TimeCost: connectionPackageKDFDefaultTimeCost,
Parallelism: connectionPackageKDFDefaultParallelism,
}
}
func normalizeConnectionPackagePassword(password string) string {
return strings.TrimSpace(password)
}

View File

@@ -1,6 +1,7 @@
package app
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
@@ -14,7 +15,7 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
repo := newSavedConnectionRepository(a.configDir, a.secretStore)
view, err := repo.Find(config.ID)
if err != nil {
return config, err
return config, normalizeConnectionSecretResolutionError(config, err)
}
base := config
@@ -23,13 +24,32 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
}
bundle, err := repo.loadSecretBundle(view)
if err != nil {
return base, err
return base, normalizeConnectionSecretResolutionError(base, err)
}
resolved := mergeConnectionSecretBundleIntoConfig(base, bundle)
resolved.ID = view.ID
return resolved, nil
}
func normalizeConnectionSecretResolutionError(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil
}
lower := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(lower, "saved connection not found:"):
if connectionMetadataLooksEmpty(config) {
return fmt.Errorf("未找到已保存连接,可能已被删除,请刷新后重试")
}
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码并保存后再试")
case strings.Contains(lower, "secret store unavailable"):
return fmt.Errorf("系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试")
default:
return err
}
}
func connectionMetadataLooksEmpty(config connection.ConnectionConfig) bool {
return strings.TrimSpace(config.Type) == "" &&
strings.TrimSpace(config.Host) == "" &&

View File

@@ -1,6 +1,7 @@
package app
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -40,3 +41,23 @@ func TestResolveConnectionConfigByIDLoadsSecretsFromStore(t *testing.T) {
t.Fatalf("expected restored DSN, got %q", resolved.DSN)
}
}
func TestResolveConnectionSecretsReturnsFriendlyMessageWhenSavedSecretSourceIsMissing(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
_, err := app.resolveConnectionSecrets(connection.ConnectionConfig{
ID: "conn-missing",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
})
if err == nil {
t.Fatal("expected resolveConnectionSecrets to fail for a missing saved connection")
}
if !strings.Contains(err.Error(), "已保存密文") {
t.Fatalf("expected a secret-specific error message, got %q", err.Error())
}
}

View File

@@ -263,6 +263,10 @@ func (a *App) ImportConfigFile() connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Config File",
Filters: []runtime.FileFilter{
{
DisplayName: "GoNavi Connection Package (*.gonavi-conn)",
Pattern: "*.gonavi-conn",
},
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
@@ -286,6 +290,53 @@ func (a *App) ImportConfigFile() connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
func (a *App) ExportConnectionsPackage(password string) connection.QueryResult {
payload, err := a.buildConnectionPackagePayload()
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export Connections",
DefaultFilename: "connections" + connectionPackageExtension,
Filters: []runtime.FileFilter{
{
DisplayName: "GoNavi Connection Package (*.gonavi-conn)",
Pattern: "*.gonavi-conn",
},
},
})
if err != nil || strings.TrimSpace(filename) == "" {
return connection.QueryResult{Success: false, Message: "已取消"}
}
filename = normalizeConnectionPackageExportFilename(filename)
pkg, err := encryptConnectionPackage(payload, password)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
content, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := os.WriteFile(filename, content, 0o644); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
func normalizeConnectionPackageExportFilename(filename string) string {
trimmed := strings.TrimSpace(filename)
if trimmed == "" {
return ""
}
if strings.EqualFold(filepath.Ext(trimmed), connectionPackageExtension) {
return trimmed
}
return trimmed + connectionPackageExtension
}
func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
defaultDir := strings.TrimSpace(currentPath)
if defaultDir == "" {

View File

@@ -185,3 +185,89 @@ func TestSaveGlobalProxyReturnsSecretlessView(t *testing.T) {
t.Fatal("expected hasPassword=true")
}
}
func TestImportLegacyConnectionsIsIdempotentForSameID(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
legacy := connection.LegacySavedConnection{
ID: "legacy-1",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "secret-1",
},
}
if _, err := app.ImportLegacyConnections([]connection.LegacySavedConnection{legacy}); err != nil {
t.Fatalf("first ImportLegacyConnections returned error: %v", err)
}
if _, err := app.ImportLegacyConnections([]connection.LegacySavedConnection{legacy}); err != nil {
t.Fatalf("second ImportLegacyConnections returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected a single saved connection after repeated import, got %d", len(saved))
}
}
func TestImportLegacyConnectionsKeepsExistingSecretWhenReimportOmitsPassword(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
if _, err := app.ImportLegacyConnections([]connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "secret-1",
},
},
}); err != nil {
t.Fatalf("initial ImportLegacyConnections returned error: %v", err)
}
if _, err := app.ImportLegacyConnections([]connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Legacy Updated",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
},
},
}); err != nil {
t.Fatalf("update ImportLegacyConnections returned error: %v", err)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected 1 saved connection, got %d", len(saved))
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "secret-1" {
t.Fatalf("expected original password to be preserved, got %q", resolved.Password)
}
}

View File

@@ -0,0 +1,561 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"GoNavi-Wails/internal/ai"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
type securityUpdateNormalizedPreview struct {
SourceType SecurityUpdateSourceType `json:"sourceType"`
ConnectionIDs []string `json:"connectionIds"`
HasGlobalProxy bool `json:"hasGlobalProxy"`
AIProviderIDs []string `json:"aiProviderIds"`
AIProvidersNeedingAttention []string `json:"aiProvidersNeedingAttention,omitempty"`
}
func (a *App) GetSecurityUpdateStatus() (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.LoadMarker()
if err != nil {
if os.IsNotExist(err) {
inspection, inspectErr := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Inspect()
if inspectErr != nil {
return SecurityUpdateStatus{}, inspectErr
}
if len(inspection.ProvidersNeedingMigration) > 0 {
return buildSecurityUpdatePendingStatusFromInspection(inspection, SecurityUpdateOverallStatusPending), nil
}
return SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
OverallStatus: SecurityUpdateOverallStatusNotDetected,
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}, nil
}
return SecurityUpdateStatus{}, err
}
return status, nil
}
func (a *App) StartSecurityUpdate(request StartSecurityUpdateRequest) (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.StartRound(request)
if err != nil {
return SecurityUpdateStatus{}, err
}
return a.executeSecurityUpdateRound(repo, status, request.SourceType, request.RawPayload)
}
func (a *App) RetrySecurityUpdateCurrentRound(request RetrySecurityUpdateRequest) (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.RetryRound(request)
if err != nil {
return SecurityUpdateStatus{}, err
}
previewData, err := os.ReadFile(filepath.Join(status.BackupPath, securityUpdateNormalizedPreviewFileName))
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(status, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
_ = repo.WriteResult(failed)
return failed, nil
}
var preview securityUpdateNormalizedPreview
if err := json.Unmarshal(previewData, &preview); err != nil {
failed := newSecurityUpdateSystemFailureStatus(status, SecurityUpdateIssueReasonCodeValidationFailed, err)
_ = repo.WriteResult(failed)
return failed, nil
}
finalStatus, execErr := a.validateSecurityUpdateCurrentAppRound(status, preview)
if execErr != nil {
_ = repo.WriteResult(finalStatus)
return finalStatus, nil
}
if err := repo.WriteResult(finalStatus); err != nil {
return SecurityUpdateStatus{}, err
}
return finalStatus, nil
}
func (a *App) RestartSecurityUpdate(request RestartSecurityUpdateRequest) (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.RestartRound(request)
if err != nil {
return SecurityUpdateStatus{}, err
}
return a.executeSecurityUpdateRound(repo, status, request.SourceType, request.RawPayload)
}
func (a *App) DismissSecurityUpdateReminder() (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
now := nowRFC3339()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.LoadMarker()
if err != nil {
if !os.IsNotExist(err) {
return SecurityUpdateStatus{}, err
}
inspection, inspectErr := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Inspect()
if inspectErr != nil {
return SecurityUpdateStatus{}, inspectErr
}
if len(inspection.ProvidersNeedingMigration) > 0 {
status = buildSecurityUpdatePendingStatusFromInspection(inspection, SecurityUpdateOverallStatusPostponed)
} else {
status = SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}
}
}
status.SchemaVersion = securityUpdateSchemaVersion
if strings.TrimSpace(string(status.SourceType)) == "" {
status.SourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
if status.Issues == nil {
status.Issues = []SecurityUpdateIssue{}
}
if status.OverallStatus == SecurityUpdateOverallStatusCompleted || status.OverallStatus == SecurityUpdateOverallStatusRolledBack {
return status, nil
}
status.OverallStatus = SecurityUpdateOverallStatusPostponed
status.PostponedAt = now
status.UpdatedAt = now
if err := repo.WriteResult(status); err != nil {
return SecurityUpdateStatus{}, err
}
return repo.LoadMarker()
}
func (a *App) executeSecurityUpdateRound(repo *securityUpdateStateRepository, round SecurityUpdateStatus, sourceType SecurityUpdateSourceType, rawPayload string) (SecurityUpdateStatus, error) {
if strings.TrimSpace(string(sourceType)) == "" {
sourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
if sourceType != SecurityUpdateSourceTypeCurrentAppSavedConfig {
failed := newSecurityUpdateSystemFailureStatus(round, SecurityUpdateIssueReasonCodeValidationFailed, fmt.Errorf("unsupported source type: %s", sourceType))
_ = repo.WriteResult(failed)
return failed, nil
}
source, rawParsed, err := parseSecurityUpdateCurrentAppSource(rawPayload)
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(round, SecurityUpdateIssueReasonCodeValidationFailed, err)
_ = repo.WriteResult(failed)
return failed, nil
}
rollbackSnapshot, err := captureSecurityUpdateCurrentAppRollbackSnapshot(a, source)
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(round, securityUpdateFailureReasonForError(err), err)
_ = repo.WriteResult(failed)
return failed, nil
}
if err := securityUpdateWriteJSONFile(filepath.Join(round.BackupPath, securityUpdateSourceCurrentAppFileName), rawParsed); err != nil {
return SecurityUpdateStatus{}, err
}
finalStatus, preview, execErr := a.runSecurityUpdateCurrentAppRound(round, source)
if previewErr := securityUpdateWriteJSONFile(filepath.Join(round.BackupPath, securityUpdateNormalizedPreviewFileName), preview); previewErr != nil {
return a.rollbackSecurityUpdatePersistenceFailure(repo, rollbackSnapshot, finalStatus, previewErr)
}
if execErr != nil {
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(rollbackErr), rollbackErr)
_ = repo.WriteResult(failed)
return failed, nil
}
_ = repo.WriteResult(finalStatus)
return finalStatus, nil
}
if err := repo.WriteResult(finalStatus); err != nil {
return a.rollbackSecurityUpdatePersistenceFailure(repo, rollbackSnapshot, finalStatus, err)
}
return finalStatus, nil
}
func (a *App) rollbackSecurityUpdatePersistenceFailure(
repo *securityUpdateStateRepository,
rollbackSnapshot securityUpdateCurrentAppRollbackSnapshot,
base SecurityUpdateStatus,
cause error,
) (SecurityUpdateStatus, error) {
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
failed := newSecurityUpdateSystemFailureStatus(base, securityUpdateFailureReasonForError(rollbackErr), rollbackErr)
_ = repo.WriteResult(failed)
return failed, nil
}
failed := newSecurityUpdateSystemFailureStatus(base, SecurityUpdateIssueReasonCodeEnvironmentBlocked, cause)
_ = repo.WriteResult(failed)
return failed, nil
}
func (a *App) runSecurityUpdateCurrentAppRound(round SecurityUpdateStatus, source securityUpdateCurrentAppSource) (SecurityUpdateStatus, securityUpdateNormalizedPreview, error) {
finalStatus := newSecurityUpdateRoundBaseStatus(round, SecurityUpdateSourceTypeCurrentAppSavedConfig)
preview := securityUpdateNormalizedPreview{
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
ConnectionIDs: make([]string, 0, len(source.Connections)),
HasGlobalProxy: source.GlobalProxy != nil,
AIProviderIDs: []string{},
}
connectionRepo := a.savedConnectionRepository()
for _, item := range source.Connections {
finalStatus.Summary.Total++
preview.ConnectionIDs = append(preview.ConnectionIDs, item.ID)
if _, err := connectionRepo.Save(connection.SavedConnectionInput(item)); err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, preview, err
}
finalStatus.Summary.Updated++
}
if source.GlobalProxy != nil {
finalStatus.Summary.Total++
if _, err := a.saveGlobalProxy(connection.SaveGlobalProxyInput(*source.GlobalProxy)); err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, preview, err
}
finalStatus.Summary.Updated++
}
providerSnapshot, err := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Load()
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(err), err)
return failed, preview, err
}
for _, provider := range providerSnapshot.Providers {
if !providerParticipatesInSecurityUpdate(provider) {
continue
}
preview.AIProviderIDs = append(preview.AIProviderIDs, provider.ID)
finalStatus.Summary.Total++
if provider.HasSecret && strings.TrimSpace(provider.APIKey) == "" {
finalStatus.OverallStatus = SecurityUpdateOverallStatusNeedsAttention
finalStatus.Summary.Pending++
finalStatus.Issues = append(finalStatus.Issues, SecurityUpdateIssue{
ID: "ai-provider-" + provider.ID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: provider.ID,
Title: provider.Name,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeSecretMissing,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置需要补充后才能完成安全更新",
})
preview.AIProvidersNeedingAttention = append(preview.AIProvidersNeedingAttention, provider.ID)
continue
}
finalStatus.Summary.Updated++
}
if finalStatus.OverallStatus == SecurityUpdateOverallStatusCompleted {
finalStatus.CompletedAt = finalStatus.UpdatedAt
}
return finalStatus, preview, nil
}
func (a *App) validateSecurityUpdateCurrentAppRound(round SecurityUpdateStatus, preview securityUpdateNormalizedPreview) (SecurityUpdateStatus, error) {
if strings.TrimSpace(string(preview.SourceType)) == "" {
preview.SourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
finalStatus := newSecurityUpdateRoundBaseStatus(round, preview.SourceType)
connectionRepo := a.savedConnectionRepository()
for _, id := range preview.ConnectionIDs {
finalStatus.Summary.Total++
savedConnection, err := connectionRepo.Find(id)
if err != nil {
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "connection-" + id,
Scope: SecurityUpdateIssueScopeConnection,
RefID: id,
Title: id,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeValidationFailed,
Action: SecurityUpdateIssueActionOpenConnection,
Message: "连接配置已不存在或仍需重新保存后才能完成安全更新",
},
)
continue
}
if _, err := a.resolveConnectionSecrets(savedConnection.Config); err != nil {
if secretstore.IsUnavailable(err) {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, err
}
reason := SecurityUpdateIssueReasonCodeValidationFailed
message := "连接配置仍需补充后才能完成安全更新"
if os.IsNotExist(err) {
reason = SecurityUpdateIssueReasonCodeSecretMissing
message = "连接密码已丢失,请重新保存后再继续"
}
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "connection-" + id,
Scope: SecurityUpdateIssueScopeConnection,
RefID: id,
Title: savedConnection.Name,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: reason,
Action: SecurityUpdateIssueActionOpenConnection,
Message: message,
},
)
continue
}
finalStatus.Summary.Updated++
}
if preview.HasGlobalProxy {
finalStatus.Summary.Total++
proxyView, err := a.loadStoredGlobalProxyView()
if err != nil {
if !os.IsNotExist(err) {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(err), err)
return failed, err
}
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "global-proxy-default",
Scope: SecurityUpdateIssueScopeGlobalProxy,
Title: "全局代理",
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeValidationFailed,
Action: SecurityUpdateIssueActionOpenProxySettings,
Message: "全局代理配置已不存在或仍需重新保存后才能完成安全更新",
},
)
} else {
if proxyView.HasPassword {
if _, err := a.loadGlobalProxySecretBundle(proxyView); err != nil {
if secretstore.IsUnavailable(err) {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, err
}
reason := SecurityUpdateIssueReasonCodeValidationFailed
message := "全局代理密码仍需补充后才能完成安全更新"
if os.IsNotExist(err) {
reason = SecurityUpdateIssueReasonCodeSecretMissing
message = "全局代理密码已丢失,请重新保存后再继续"
}
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "global-proxy-default",
Scope: SecurityUpdateIssueScopeGlobalProxy,
Title: "全局代理",
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: reason,
Action: SecurityUpdateIssueActionOpenProxySettings,
Message: message,
},
)
goto validateProviders
}
}
finalStatus.Summary.Updated++
}
}
validateProviders:
providerSnapshot, err := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Load()
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(err), err)
return failed, err
}
providersByID := make(map[string]ai.ProviderConfig, len(providerSnapshot.Providers))
for _, provider := range providerSnapshot.Providers {
providersByID[provider.ID] = provider
}
for _, providerID := range preview.AIProviderIDs {
finalStatus.Summary.Total++
provider, ok := providersByID[providerID]
if !ok {
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "ai-provider-" + providerID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: providerID,
Title: providerID,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeValidationFailed,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置已不存在或仍需重新保存后才能完成安全更新",
},
)
continue
}
if provider.HasSecret && strings.TrimSpace(provider.APIKey) == "" {
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "ai-provider-" + provider.ID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: provider.ID,
Title: provider.Name,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeSecretMissing,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置需要补充后才能完成安全更新",
},
)
continue
}
finalStatus.Summary.Updated++
}
if finalStatus.OverallStatus == SecurityUpdateOverallStatusCompleted {
finalStatus.CompletedAt = finalStatus.UpdatedAt
}
return finalStatus, nil
}
func providerParticipatesInSecurityUpdate(provider ai.ProviderConfig) bool {
return provider.HasSecret || strings.TrimSpace(provider.APIKey) != ""
}
func buildSecurityUpdatePendingStatusFromInspection(
inspection aiservice.ProviderConfigStoreInspection,
overallStatus SecurityUpdateOverallStatus,
) SecurityUpdateStatus {
providersByID := make(map[string]ai.ProviderConfig, len(inspection.Snapshot.Providers))
for _, provider := range inspection.Snapshot.Providers {
providersByID[provider.ID] = provider
}
issues := make([]SecurityUpdateIssue, 0, len(inspection.ProvidersNeedingMigration))
for _, providerID := range inspection.ProvidersNeedingMigration {
provider := providersByID[providerID]
title := strings.TrimSpace(provider.Name)
if title == "" {
title = providerID
}
issues = append(issues, SecurityUpdateIssue{
ID: "ai-provider-" + providerID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: providerID,
Title: title,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusPending,
ReasonCode: SecurityUpdateIssueReasonCodeMigrationRequired,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置仍保存在当前应用配置中,完成安全更新后会迁入新的安全存储。",
})
}
return SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
OverallStatus: overallStatus,
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
ReminderVisible: overallStatus == SecurityUpdateOverallStatusPending,
CanStart: overallStatus == SecurityUpdateOverallStatusPending || overallStatus == SecurityUpdateOverallStatusPostponed,
CanPostpone: overallStatus == SecurityUpdateOverallStatusPending || overallStatus == SecurityUpdateOverallStatusPostponed,
Summary: SecurityUpdateSummary{
Total: len(issues),
Pending: len(issues),
},
Issues: issues,
}
}
func newSecurityUpdateRoundBaseStatus(round SecurityUpdateStatus, sourceType SecurityUpdateSourceType) SecurityUpdateStatus {
if strings.TrimSpace(string(sourceType)) == "" {
sourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
return SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: round.MigrationID,
OverallStatus: SecurityUpdateOverallStatusCompleted,
SourceType: sourceType,
BackupAvailable: round.BackupAvailable || strings.TrimSpace(round.BackupPath) != "",
BackupPath: round.BackupPath,
StartedAt: round.StartedAt,
UpdatedAt: nowRFC3339(),
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}
}
func markSecurityUpdateNeedsAttention(status *SecurityUpdateStatus, issue SecurityUpdateIssue) {
status.OverallStatus = SecurityUpdateOverallStatusNeedsAttention
status.Summary.Pending++
status.Issues = append(status.Issues, issue)
}
func securityUpdateFailureReasonForError(err error) SecurityUpdateIssueReasonCode {
if secretstore.IsUnavailable(err) {
return SecurityUpdateIssueReasonCodeEnvironmentBlocked
}
return SecurityUpdateIssueReasonCodeValidationFailed
}
func newSecurityUpdateSystemFailureStatus(base SecurityUpdateStatus, reasonCode SecurityUpdateIssueReasonCode, err error) SecurityUpdateStatus {
status := base
status.SchemaVersion = securityUpdateSchemaVersion
status.OverallStatus = SecurityUpdateOverallStatusRolledBack
status.BackupAvailable = status.BackupAvailable || strings.TrimSpace(status.BackupPath) != ""
status.UpdatedAt = nowRFC3339()
status.CompletedAt = ""
status.LastError = err.Error()
status.Summary.Failed++
status.Issues = []SecurityUpdateIssue{
{
ID: "system-blocked",
Scope: SecurityUpdateIssueScopeSystem,
Title: "安全更新未完成",
Severity: SecurityUpdateIssueSeverityHigh,
Status: SecurityUpdateItemStatusFailed,
ReasonCode: reasonCode,
Action: SecurityUpdateIssueActionViewDetails,
Message: "当前环境无法完成本次安全更新,请稍后重试",
},
}
return status
}

View File

@@ -0,0 +1,942 @@
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)
}
}

View File

@@ -0,0 +1,314 @@
package app
import (
"os"
"path/filepath"
"strings"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
const (
securityUpdateAIConfigFileName = "ai_config.json"
securityUpdateAIProviderSecretKind = "ai-provider"
)
type securityUpdateSecretSnapshot struct {
Exists bool
Payload []byte
}
type securityUpdateCurrentAppRollbackSnapshot struct {
connectionsFileExists bool
connectionsFileData []byte
connectionSecrets map[string]securityUpdateSecretSnapshot
connectionCleanupRefs []string
globalProxyFileExists bool
globalProxyFileData []byte
globalProxySecretRef string
globalProxySecret securityUpdateSecretSnapshot
globalProxyCleanupRef string
aiConfigFileExists bool
aiConfigFileData []byte
aiProviderSecrets map[string]securityUpdateSecretSnapshot
aiProviderCleanupRefs []string
}
func captureSecurityUpdateCurrentAppRollbackSnapshot(a *App, source securityUpdateCurrentAppSource) (securityUpdateCurrentAppRollbackSnapshot, error) {
snapshot := securityUpdateCurrentAppRollbackSnapshot{
connectionSecrets: make(map[string]securityUpdateSecretSnapshot),
aiProviderSecrets: make(map[string]securityUpdateSecretSnapshot),
}
configDir := strings.TrimSpace(a.configDir)
if configDir == "" {
configDir = resolveAppConfigDir()
}
connectionRepo := a.savedConnectionRepository()
connectionFileData, connectionFileExists, err := readOptionalFile(connectionRepo.connectionsPath())
if err != nil {
return snapshot, err
}
snapshot.connectionsFileExists = connectionFileExists
snapshot.connectionsFileData = connectionFileData
existingConnections, err := connectionRepo.load()
if err != nil {
return snapshot, err
}
existingConnectionsByID := make(map[string]connection.SavedConnectionView, len(existingConnections))
for _, item := range existingConnections {
existingConnectionsByID[item.ID] = item
}
connectionCleanupSet := make(map[string]struct{})
for _, item := range source.Connections {
connectionID := strings.TrimSpace(item.ID)
if connectionID == "" {
connectionID = strings.TrimSpace(item.Config.ID)
}
if connectionID == "" {
continue
}
defaultRef, refErr := secretstore.BuildRef(savedConnectionSecretKind, connectionID)
if refErr == nil {
connectionCleanupSet[defaultRef] = struct{}{}
}
existing, ok := existingConnectionsByID[connectionID]
if !ok || !savedConnectionViewHasSecrets(existing) {
continue
}
ref := strings.TrimSpace(existing.SecretRef)
if ref == "" {
ref = defaultRef
}
if ref == "" {
continue
}
secretSnapshot, captureErr := captureSecurityUpdateSecretSnapshot(a.secretStore, ref)
if captureErr != nil {
return snapshot, captureErr
}
snapshot.connectionSecrets[ref] = secretSnapshot
connectionCleanupSet[ref] = struct{}{}
}
snapshot.connectionCleanupRefs = make([]string, 0, len(connectionCleanupSet))
for ref := range connectionCleanupSet {
snapshot.connectionCleanupRefs = append(snapshot.connectionCleanupRefs, ref)
}
if source.GlobalProxy != nil {
globalProxyFileData, globalProxyFileExists, err := readOptionalFile(globalProxyMetadataPath(configDir))
if err != nil {
return snapshot, err
}
snapshot.globalProxyFileExists = globalProxyFileExists
snapshot.globalProxyFileData = globalProxyFileData
defaultProxyRef, refErr := secretstore.BuildRef(globalProxySecretKind, globalProxySecretID)
if refErr == nil {
snapshot.globalProxyCleanupRef = defaultProxyRef
}
existingProxy, err := a.loadStoredGlobalProxyView()
if err != nil {
if !os.IsNotExist(err) {
return snapshot, err
}
} else if existingProxy.HasPassword {
ref := strings.TrimSpace(existingProxy.SecretRef)
if ref == "" {
ref = snapshot.globalProxyCleanupRef
}
if ref != "" {
secretSnapshot, captureErr := captureSecurityUpdateSecretSnapshot(a.secretStore, ref)
if captureErr != nil {
return snapshot, captureErr
}
snapshot.globalProxySecretRef = ref
snapshot.globalProxySecret = secretSnapshot
}
}
}
aiConfigPath := filepath.Join(configDir, securityUpdateAIConfigFileName)
aiConfigFileData, aiConfigFileExists, err := readOptionalFile(aiConfigPath)
if err != nil {
return snapshot, err
}
snapshot.aiConfigFileExists = aiConfigFileExists
snapshot.aiConfigFileData = aiConfigFileData
inspection, err := aiservice.NewProviderConfigStore(configDir, a.secretStore).Inspect()
if err != nil {
return snapshot, err
}
aiProviderCleanupSet := make(map[string]struct{})
for _, provider := range inspection.Snapshot.Providers {
providerID := strings.TrimSpace(provider.ID)
if providerID == "" {
continue
}
ref := strings.TrimSpace(provider.SecretRef)
if ref == "" && (provider.HasSecret || strings.TrimSpace(provider.APIKey) != "" || len(provider.Headers) > 0) {
builtRef, refErr := secretstore.BuildRef(securityUpdateAIProviderSecretKind, providerID)
if refErr == nil {
ref = builtRef
}
}
if ref == "" {
continue
}
secretSnapshot, captureErr := captureSecurityUpdateSecretSnapshot(a.secretStore, ref)
if captureErr != nil {
return snapshot, captureErr
}
snapshot.aiProviderSecrets[ref] = secretSnapshot
aiProviderCleanupSet[ref] = struct{}{}
}
snapshot.aiProviderCleanupRefs = make([]string, 0, len(aiProviderCleanupSet))
for ref := range aiProviderCleanupSet {
snapshot.aiProviderCleanupRefs = append(snapshot.aiProviderCleanupRefs, ref)
}
return snapshot, nil
}
func (s securityUpdateCurrentAppRollbackSnapshot) restore(a *App) error {
configDir := strings.TrimSpace(a.configDir)
if configDir == "" {
configDir = resolveAppConfigDir()
}
connectionRepo := a.savedConnectionRepository()
if err := restoreOptionalFile(connectionRepo.connectionsPath(), s.connectionsFileExists, s.connectionsFileData); err != nil {
return err
}
for ref, secretSnapshot := range s.connectionSecrets {
if err := restoreSecurityUpdateSecretSnapshot(a.secretStore, ref, secretSnapshot); err != nil {
return err
}
}
for _, ref := range s.connectionCleanupRefs {
if _, alreadyRestored := s.connectionSecrets[ref]; alreadyRestored {
continue
}
if err := deleteSecurityUpdateSecretRef(a.secretStore, ref); err != nil {
return err
}
}
if err := restoreOptionalFile(globalProxyMetadataPath(configDir), s.globalProxyFileExists, s.globalProxyFileData); err != nil {
return err
}
if s.globalProxySecretRef != "" {
if err := restoreSecurityUpdateSecretSnapshot(a.secretStore, s.globalProxySecretRef, s.globalProxySecret); err != nil {
return err
}
}
if s.globalProxyCleanupRef != "" && s.globalProxyCleanupRef != s.globalProxySecretRef {
if err := deleteSecurityUpdateSecretRef(a.secretStore, s.globalProxyCleanupRef); err != nil {
return err
}
}
if err := restoreOptionalFile(filepath.Join(configDir, securityUpdateAIConfigFileName), s.aiConfigFileExists, s.aiConfigFileData); err != nil {
return err
}
for ref, secretSnapshot := range s.aiProviderSecrets {
if err := restoreSecurityUpdateSecretSnapshot(a.secretStore, ref, secretSnapshot); err != nil {
return err
}
}
for _, ref := range s.aiProviderCleanupRefs {
if _, alreadyRestored := s.aiProviderSecrets[ref]; alreadyRestored {
continue
}
if err := deleteSecurityUpdateSecretRef(a.secretStore, ref); err != nil {
return err
}
}
if s.globalProxyFileExists {
a.loadPersistedGlobalProxy()
return nil
}
_, err := setGlobalProxyConfig(false, connection.ProxyConfig{})
return err
}
func readOptionalFile(path string) ([]byte, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
return append([]byte(nil), data...), true, nil
}
func restoreOptionalFile(path string, exists bool, data []byte) error {
if !exists {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(path, data, 0o644)
}
func captureSecurityUpdateSecretSnapshot(store secretstore.SecretStore, ref string) (securityUpdateSecretSnapshot, error) {
if store == nil || strings.TrimSpace(ref) == "" {
return securityUpdateSecretSnapshot{}, nil
}
payload, err := store.Get(ref)
if err != nil {
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
return securityUpdateSecretSnapshot{}, nil
}
return securityUpdateSecretSnapshot{}, err
}
return securityUpdateSecretSnapshot{
Exists: true,
Payload: append([]byte(nil), payload...),
}, nil
}
func restoreSecurityUpdateSecretSnapshot(store secretstore.SecretStore, ref string, snapshot securityUpdateSecretSnapshot) error {
if store == nil || strings.TrimSpace(ref) == "" {
return nil
}
if snapshot.Exists {
if err := store.Put(ref, snapshot.Payload); err != nil {
if secretstore.IsUnavailable(err) {
return nil
}
return err
}
return nil
}
return deleteSecurityUpdateSecretRef(store, ref)
}
func deleteSecurityUpdateSecretRef(store secretstore.SecretStore, ref string) error {
if store == nil || strings.TrimSpace(ref) == "" {
return nil
}
if err := store.Delete(ref); err != nil {
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
return nil
}
return err
}
return nil
}

View File

@@ -0,0 +1,85 @@
package app
import (
"encoding/json"
"strings"
"GoNavi-Wails/internal/connection"
)
const (
securityUpdateSourceCurrentAppFileName = "source-current-app.json"
securityUpdateNormalizedPreviewFileName = "normalized-preview.json"
)
type securityUpdateCurrentAppEnvelope struct {
State securityUpdateCurrentAppPayload `json:"state"`
Connections []connection.LegacySavedConnection `json:"connections"`
GlobalProxy *connection.LegacyGlobalProxyInput `json:"globalProxy"`
}
type securityUpdateCurrentAppPayload struct {
Connections []connection.LegacySavedConnection `json:"connections"`
GlobalProxy *connection.LegacyGlobalProxyInput `json:"globalProxy"`
}
type securityUpdateCurrentAppSource struct {
Connections []connection.LegacySavedConnection `json:"connections"`
GlobalProxy *connection.LegacyGlobalProxyInput `json:"globalProxy,omitempty"`
}
func parseSecurityUpdateCurrentAppSource(rawPayload string) (securityUpdateCurrentAppSource, any, error) {
trimmed := strings.TrimSpace(rawPayload)
if trimmed == "" {
return securityUpdateCurrentAppSource{Connections: []connection.LegacySavedConnection{}}, map[string]any{}, nil
}
var raw any
if err := json.Unmarshal([]byte(trimmed), &raw); err != nil {
return securityUpdateCurrentAppSource{}, nil, err
}
var envelope securityUpdateCurrentAppEnvelope
if err := json.Unmarshal([]byte(trimmed), &envelope); err != nil {
return securityUpdateCurrentAppSource{}, nil, err
}
connections := envelope.Connections
globalProxy := envelope.GlobalProxy
if len(envelope.State.Connections) > 0 || envelope.State.GlobalProxy != nil {
connections = envelope.State.Connections
globalProxy = envelope.State.GlobalProxy
}
normalizedConnections := make([]connection.LegacySavedConnection, 0, len(connections))
for _, item := range connections {
if strings.TrimSpace(item.ID) == "" && strings.TrimSpace(item.Config.ID) == "" {
continue
}
if strings.TrimSpace(item.ID) == "" {
item.ID = strings.TrimSpace(item.Config.ID)
}
item.Config.ID = item.ID
normalizedConnections = append(normalizedConnections, item)
}
if globalProxy != nil {
normalizedType := strings.ToLower(strings.TrimSpace(globalProxy.Type))
if normalizedType != "http" {
normalizedType = "socks5"
}
globalProxy.Type = normalizedType
if globalProxy.Port <= 0 || globalProxy.Port > 65535 {
if normalizedType == "http" {
globalProxy.Port = 8080
} else {
globalProxy.Port = 1080
}
}
}
return securityUpdateCurrentAppSource{
Connections: normalizedConnections,
GlobalProxy: globalProxy,
}, raw, nil
}

View File

@@ -0,0 +1,293 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
const (
securityUpdateSchemaVersion = 1
securityUpdateMarkerDirName = "migrations"
securityUpdateMarkerFileName = "config-security-update.json"
securityUpdateBackupRootDirName = "migration-backups"
securityUpdateManifestFileName = "manifest.json"
securityUpdateResultFileName = "result.json"
)
var securityUpdateWriteJSONFile = writeJSONFile
type securityUpdateStateRepository struct {
configDir string
}
type securityUpdateMarker struct {
SchemaVersion int `json:"schemaVersion"`
MigrationID string `json:"migrationId"`
SourceType SecurityUpdateSourceType `json:"sourceType"`
Status SecurityUpdateOverallStatus `json:"status"`
StartedAt string `json:"startedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
CompletedAt string `json:"completedAt,omitempty"`
PostponedAt string `json:"postponedAt,omitempty"`
BackupPath string `json:"backupPath,omitempty"`
BackupSHA256 string `json:"backupSha256,omitempty"`
Summary SecurityUpdateSummary `json:"summary"`
Issues []SecurityUpdateIssue `json:"issues"`
LastError string `json:"lastError,omitempty"`
}
type securityUpdateBackupManifest struct {
SchemaVersion int `json:"schemaVersion"`
MigrationID string `json:"migrationId"`
SourceType SecurityUpdateSourceType `json:"sourceType"`
CreatedAt string `json:"createdAt"`
StartedAt string `json:"startedAt,omitempty"`
BackupPath string `json:"backupPath"`
}
func newSecurityUpdateStateRepository(configDir string) *securityUpdateStateRepository {
if strings.TrimSpace(configDir) == "" {
configDir = resolveAppConfigDir()
}
return &securityUpdateStateRepository{configDir: configDir}
}
func (r *securityUpdateStateRepository) markerPath() string {
return filepath.Join(r.configDir, securityUpdateMarkerDirName, securityUpdateMarkerFileName)
}
func (r *securityUpdateStateRepository) backupRootPath() string {
return filepath.Join(r.configDir, securityUpdateBackupRootDirName)
}
func (r *securityUpdateStateRepository) backupPath(migrationID string) string {
return filepath.Join(r.backupRootPath(), migrationID)
}
func (r *securityUpdateStateRepository) manifestPath(migrationID string) string {
return filepath.Join(r.backupPath(migrationID), securityUpdateManifestFileName)
}
func (r *securityUpdateStateRepository) resultPath(migrationID string) string {
return filepath.Join(r.backupPath(migrationID), securityUpdateResultFileName)
}
func (r *securityUpdateStateRepository) LoadMarker() (SecurityUpdateStatus, error) {
marker, err := r.readMarker()
if err != nil {
return SecurityUpdateStatus{}, err
}
return buildSecurityUpdateStatus(marker), nil
}
func (r *securityUpdateStateRepository) StartRound(request StartSecurityUpdateRequest) (SecurityUpdateStatus, error) {
marker := r.newRoundMarker(request.SourceType)
if err := r.initializeRoundArtifacts(marker); err != nil {
return SecurityUpdateStatus{}, err
}
status := buildSecurityUpdateStatus(marker)
if err := r.WriteResult(status); err != nil {
return SecurityUpdateStatus{}, err
}
return status, nil
}
func (r *securityUpdateStateRepository) RetryRound(request RetrySecurityUpdateRequest) (SecurityUpdateStatus, error) {
marker, err := r.readMarker()
if err != nil {
return SecurityUpdateStatus{}, err
}
if requestedID := strings.TrimSpace(request.MigrationID); requestedID != "" && requestedID != marker.MigrationID {
return SecurityUpdateStatus{}, fmt.Errorf("migration ID mismatch: current=%s requested=%s", marker.MigrationID, requestedID)
}
if marker.Status != SecurityUpdateOverallStatusNeedsAttention {
return SecurityUpdateStatus{}, fmt.Errorf(
"retry current round requires status %s: current=%s",
SecurityUpdateOverallStatusNeedsAttention,
marker.Status,
)
}
marker.Status = SecurityUpdateOverallStatusInProgress
marker.UpdatedAt = nowRFC3339()
if marker.BackupPath == "" {
marker.BackupPath = r.backupPath(marker.MigrationID)
}
if err := os.MkdirAll(marker.BackupPath, 0o755); err != nil {
return SecurityUpdateStatus{}, err
}
status := buildSecurityUpdateStatus(marker)
if err := r.WriteResult(status); err != nil {
return SecurityUpdateStatus{}, err
}
return status, nil
}
func (r *securityUpdateStateRepository) RestartRound(request RestartSecurityUpdateRequest) (SecurityUpdateStatus, error) {
marker := r.newRoundMarker(request.SourceType)
if err := r.initializeRoundArtifacts(marker); err != nil {
return SecurityUpdateStatus{}, err
}
status := buildSecurityUpdateStatus(marker)
if err := r.WriteResult(status); err != nil {
return SecurityUpdateStatus{}, err
}
return status, nil
}
func (r *securityUpdateStateRepository) WriteResult(status SecurityUpdateStatus) error {
marker := markerFromStatus(status)
if err := r.writeMarker(marker); err != nil {
return err
}
if strings.TrimSpace(marker.BackupPath) == "" {
return nil
}
if err := os.MkdirAll(marker.BackupPath, 0o755); err != nil {
return err
}
return securityUpdateWriteJSONFile(r.resultPath(marker.MigrationID), buildSecurityUpdateStatus(marker))
}
func (r *securityUpdateStateRepository) newRoundMarker(sourceType SecurityUpdateSourceType) securityUpdateMarker {
now := nowRFC3339()
if strings.TrimSpace(string(sourceType)) == "" {
sourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
migrationID := uuid.NewString()
return securityUpdateMarker{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: migrationID,
SourceType: sourceType,
Status: SecurityUpdateOverallStatusInProgress,
StartedAt: now,
UpdatedAt: now,
BackupPath: r.backupPath(migrationID),
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}
}
func (r *securityUpdateStateRepository) initializeRoundArtifacts(marker securityUpdateMarker) error {
if err := os.MkdirAll(marker.BackupPath, 0o755); err != nil {
return err
}
manifest := securityUpdateBackupManifest{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: marker.MigrationID,
SourceType: marker.SourceType,
CreatedAt: marker.UpdatedAt,
StartedAt: marker.StartedAt,
BackupPath: marker.BackupPath,
}
if err := securityUpdateWriteJSONFile(r.manifestPath(marker.MigrationID), manifest); err != nil {
return err
}
return r.writeMarker(marker)
}
func (r *securityUpdateStateRepository) readMarker() (securityUpdateMarker, error) {
data, err := os.ReadFile(r.markerPath())
if err != nil {
return securityUpdateMarker{}, err
}
var marker securityUpdateMarker
if err := json.Unmarshal(data, &marker); err != nil {
return securityUpdateMarker{}, err
}
if marker.Issues == nil {
marker.Issues = []SecurityUpdateIssue{}
}
return marker, nil
}
func (r *securityUpdateStateRepository) writeMarker(marker securityUpdateMarker) error {
if err := os.MkdirAll(filepath.Dir(r.markerPath()), 0o755); err != nil {
return err
}
return securityUpdateWriteJSONFile(r.markerPath(), marker)
}
func buildSecurityUpdateStatus(marker securityUpdateMarker) SecurityUpdateStatus {
status := SecurityUpdateStatus{
SchemaVersion: marker.SchemaVersion,
MigrationID: marker.MigrationID,
OverallStatus: marker.Status,
SourceType: marker.SourceType,
BackupAvailable: strings.TrimSpace(marker.BackupPath) != "",
BackupPath: marker.BackupPath,
StartedAt: marker.StartedAt,
UpdatedAt: marker.UpdatedAt,
CompletedAt: marker.CompletedAt,
PostponedAt: marker.PostponedAt,
Summary: marker.Summary,
Issues: marker.Issues,
LastError: marker.LastError,
}
if status.Issues == nil {
status.Issues = []SecurityUpdateIssue{}
}
switch status.OverallStatus {
case SecurityUpdateOverallStatusPending:
status.ReminderVisible = true
status.CanStart = true
status.CanPostpone = true
case SecurityUpdateOverallStatusPostponed:
status.CanStart = true
case SecurityUpdateOverallStatusNeedsAttention:
status.CanRetry = true
status.CanStart = true
case SecurityUpdateOverallStatusRolledBack:
status.CanStart = true
case SecurityUpdateOverallStatusCompleted:
status.BackupAvailable = strings.TrimSpace(status.BackupPath) != ""
}
return status
}
func markerFromStatus(status SecurityUpdateStatus) securityUpdateMarker {
marker := securityUpdateMarker{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: strings.TrimSpace(status.MigrationID),
SourceType: status.SourceType,
Status: status.OverallStatus,
StartedAt: status.StartedAt,
UpdatedAt: status.UpdatedAt,
CompletedAt: status.CompletedAt,
PostponedAt: status.PostponedAt,
BackupPath: status.BackupPath,
Summary: status.Summary,
Issues: status.Issues,
LastError: status.LastError,
}
if marker.SchemaVersion == 0 {
marker.SchemaVersion = securityUpdateSchemaVersion
}
if marker.Issues == nil {
marker.Issues = []SecurityUpdateIssue{}
}
if marker.BackupPath == "" && marker.MigrationID != "" {
marker.BackupPath = filepath.Join(resolveAppConfigDir(), securityUpdateBackupRootDirName, marker.MigrationID)
}
if marker.UpdatedAt == "" {
marker.UpdatedAt = nowRFC3339()
}
return marker
}
func writeJSONFile(path string, payload any) error {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func nowRFC3339() string {
return time.Now().UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,226 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestSecurityUpdateStateStartRoundCreatesMarkerAndManifest(t *testing.T) {
repo := newSecurityUpdateStateRepository(t.TempDir())
status, err := repo.StartRound(StartSecurityUpdateRequest{
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
})
if err != nil {
t.Fatalf("StartRound returned error: %v", err)
}
if status.MigrationID == "" {
t.Fatal("expected migration ID to be created")
}
if status.SourceType != SecurityUpdateSourceTypeCurrentAppSavedConfig {
t.Fatalf("expected source type %q, got %q", SecurityUpdateSourceTypeCurrentAppSavedConfig, status.SourceType)
}
if status.OverallStatus != SecurityUpdateOverallStatusInProgress {
t.Fatalf("expected overall status %q, got %q", SecurityUpdateOverallStatusInProgress, status.OverallStatus)
}
if !status.BackupAvailable {
t.Fatal("expected backupAvailable=true")
}
markerPath := filepath.Join(repo.configDir, securityUpdateMarkerDirName, securityUpdateMarkerFileName)
if _, err := os.Stat(markerPath); err != nil {
t.Fatalf("expected marker file at %q: %v", markerPath, err)
}
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("ReadFile marker failed: %v", err)
}
var marker securityUpdateMarker
if err := json.Unmarshal(data, &marker); err != nil {
t.Fatalf("Unmarshal marker failed: %v", err)
}
if marker.MigrationID != status.MigrationID {
t.Fatalf("expected marker migration ID %q, got %q", status.MigrationID, marker.MigrationID)
}
manifestPath := filepath.Join(repo.configDir, securityUpdateBackupRootDirName, status.MigrationID, securityUpdateManifestFileName)
if _, err := os.Stat(manifestPath); err != nil {
t.Fatalf("expected manifest file at %q: %v", manifestPath, err)
}
}
func TestSecurityUpdateStateRetryRoundReusesCurrentMigrationID(t *testing.T) {
repo := newSecurityUpdateStateRepository(t.TempDir())
initial, err := repo.StartRound(StartSecurityUpdateRequest{
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
})
if err != nil {
t.Fatalf("StartRound returned error: %v", err)
}
initial.OverallStatus = SecurityUpdateOverallStatusNeedsAttention
initial.UpdatedAt = nowRFC3339()
initial.Summary = SecurityUpdateSummary{
Total: 1,
Pending: 1,
}
initial.Issues = []SecurityUpdateIssue{
{
ID: "connection-legacy-1",
Scope: SecurityUpdateIssueScopeConnection,
RefID: "legacy-1",
Title: "Legacy",
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeSecretMissing,
Action: SecurityUpdateIssueActionOpenConnection,
Message: "连接密码已丢失,请重新保存后再继续",
},
}
if err := repo.WriteResult(initial); err != nil {
t.Fatalf("WriteResult returned error: %v", err)
}
retried, err := repo.RetryRound(RetrySecurityUpdateRequest{
MigrationID: initial.MigrationID,
})
if err != nil {
t.Fatalf("RetryRound returned error: %v", err)
}
if retried.MigrationID != initial.MigrationID {
t.Fatalf("expected retry to reuse migration ID %q, got %q", initial.MigrationID, retried.MigrationID)
}
entries, err := os.ReadDir(filepath.Join(repo.configDir, securityUpdateBackupRootDirName))
if err != nil {
t.Fatalf("ReadDir backup root failed: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected retry to keep a single backup directory, got %d", len(entries))
}
}
func TestSecurityUpdateStateRetryRoundRejectsRolledBackRound(t *testing.T) {
repo := newSecurityUpdateStateRepository(t.TempDir())
marker := securityUpdateMarker{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: "migration-1",
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
Status: SecurityUpdateOverallStatusRolledBack,
StartedAt: "2026-04-09T00:00:00Z",
UpdatedAt: "2026-04-09T00:05:00Z",
BackupPath: repo.backupPath("migration-1"),
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.writeMarker(marker); err != nil {
t.Fatalf("writeMarker returned error: %v", err)
}
if _, err := repo.RetryRound(RetrySecurityUpdateRequest{MigrationID: marker.MigrationID}); err == nil {
t.Fatal("expected RetryRound to reject rolled_back round")
}
current, err := repo.LoadMarker()
if err != nil {
t.Fatalf("LoadMarker returned error: %v", err)
}
if current.OverallStatus != SecurityUpdateOverallStatusRolledBack {
t.Fatalf("expected marker to remain rolled_back, got %q", current.OverallStatus)
}
}
func TestBuildSecurityUpdateStatusDoesNotAllowRetryAfterRollback(t *testing.T) {
status := buildSecurityUpdateStatus(securityUpdateMarker{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: "migration-1",
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
Status: SecurityUpdateOverallStatusRolledBack,
StartedAt: "2026-04-09T00:00:00Z",
UpdatedAt: "2026-04-09T00:05:00Z",
BackupPath: filepath.Join("backup", "migration-1"),
Summary: SecurityUpdateSummary{
Total: 1,
Failed: 1,
},
Issues: []SecurityUpdateIssue{
{
ID: "system-blocked",
Scope: SecurityUpdateIssueScopeSystem,
Title: "安全更新未完成",
Severity: SecurityUpdateIssueSeverityHigh,
Status: SecurityUpdateItemStatusFailed,
ReasonCode: SecurityUpdateIssueReasonCodeEnvironmentBlocked,
Action: SecurityUpdateIssueActionViewDetails,
Message: "当前环境无法完成本次安全更新,请稍后重试",
},
},
})
if status.CanRetry {
t.Fatal("expected rolled_back status to require restart instead of retry")
}
if !status.CanStart {
t.Fatal("expected rolled_back status to allow starting a new round")
}
}
func TestSecurityUpdateStateRestartRoundCreatesNewMigrationID(t *testing.T) {
repo := newSecurityUpdateStateRepository(t.TempDir())
initial, err := repo.StartRound(StartSecurityUpdateRequest{
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
})
if err != nil {
t.Fatalf("StartRound returned error: %v", err)
}
restarted, err := repo.RestartRound(RestartSecurityUpdateRequest{
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
})
if err != nil {
t.Fatalf("RestartRound returned error: %v", err)
}
if restarted.MigrationID == initial.MigrationID {
t.Fatal("expected restart to create a new migration ID")
}
entries, err := os.ReadDir(filepath.Join(repo.configDir, securityUpdateBackupRootDirName))
if err != nil {
t.Fatalf("ReadDir backup root failed: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected restart to create a second backup directory, got %d", len(entries))
}
current, err := repo.LoadMarker()
if err != nil {
t.Fatalf("LoadMarker returned error: %v", err)
}
if current.MigrationID != restarted.MigrationID {
t.Fatalf("expected marker to point to latest migration ID %q, got %q", restarted.MigrationID, current.MigrationID)
}
}

View File

@@ -0,0 +1,129 @@
package app
type SecurityUpdateSourceType string
const (
SecurityUpdateSourceTypeCurrentAppSavedConfig SecurityUpdateSourceType = "current_app_saved_config"
)
type SecurityUpdateOverallStatus string
const (
SecurityUpdateOverallStatusNotDetected SecurityUpdateOverallStatus = "not_detected"
SecurityUpdateOverallStatusPending SecurityUpdateOverallStatus = "pending"
SecurityUpdateOverallStatusPostponed SecurityUpdateOverallStatus = "postponed"
SecurityUpdateOverallStatusInProgress SecurityUpdateOverallStatus = "in_progress"
SecurityUpdateOverallStatusNeedsAttention SecurityUpdateOverallStatus = "needs_attention"
SecurityUpdateOverallStatusCompleted SecurityUpdateOverallStatus = "completed"
SecurityUpdateOverallStatusRolledBack SecurityUpdateOverallStatus = "rolled_back"
)
type SecurityUpdateIssueScope string
const (
SecurityUpdateIssueScopeConnection SecurityUpdateIssueScope = "connection"
SecurityUpdateIssueScopeGlobalProxy SecurityUpdateIssueScope = "global_proxy"
SecurityUpdateIssueScopeAIProvider SecurityUpdateIssueScope = "ai_provider"
SecurityUpdateIssueScopeSystem SecurityUpdateIssueScope = "system"
)
type SecurityUpdateIssueSeverity string
const (
SecurityUpdateIssueSeverityHigh SecurityUpdateIssueSeverity = "high"
SecurityUpdateIssueSeverityMedium SecurityUpdateIssueSeverity = "medium"
SecurityUpdateIssueSeverityLow SecurityUpdateIssueSeverity = "low"
)
type SecurityUpdateItemStatus string
const (
SecurityUpdateItemStatusPending SecurityUpdateItemStatus = "pending"
SecurityUpdateItemStatusUpdated SecurityUpdateItemStatus = "updated"
SecurityUpdateItemStatusNeedsAttention SecurityUpdateItemStatus = "needs_attention"
SecurityUpdateItemStatusSkipped SecurityUpdateItemStatus = "skipped"
SecurityUpdateItemStatusFailed SecurityUpdateItemStatus = "failed"
)
type SecurityUpdateIssueReasonCode string
const (
SecurityUpdateIssueReasonCodeMigrationRequired SecurityUpdateIssueReasonCode = "migration_required"
SecurityUpdateIssueReasonCodeSecretMissing SecurityUpdateIssueReasonCode = "secret_missing"
SecurityUpdateIssueReasonCodeFieldInvalid SecurityUpdateIssueReasonCode = "field_invalid"
SecurityUpdateIssueReasonCodeWriteConflict SecurityUpdateIssueReasonCode = "write_conflict"
SecurityUpdateIssueReasonCodeValidationFailed SecurityUpdateIssueReasonCode = "validation_failed"
SecurityUpdateIssueReasonCodeEnvironmentBlocked SecurityUpdateIssueReasonCode = "environment_blocked"
)
type SecurityUpdateIssueAction string
const (
SecurityUpdateIssueActionOpenConnection SecurityUpdateIssueAction = "open_connection"
SecurityUpdateIssueActionOpenProxySettings SecurityUpdateIssueAction = "open_proxy_settings"
SecurityUpdateIssueActionOpenAISettings SecurityUpdateIssueAction = "open_ai_settings"
SecurityUpdateIssueActionRetryUpdate SecurityUpdateIssueAction = "retry_update"
SecurityUpdateIssueActionViewDetails SecurityUpdateIssueAction = "view_details"
)
type SecurityUpdateSummary struct {
Total int `json:"total"`
Updated int `json:"updated"`
Pending int `json:"pending"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
}
type SecurityUpdateIssue struct {
ID string `json:"id"`
Scope SecurityUpdateIssueScope `json:"scope"`
RefID string `json:"refId,omitempty"`
Title string `json:"title"`
Severity SecurityUpdateIssueSeverity `json:"severity"`
Status SecurityUpdateItemStatus `json:"status"`
ReasonCode SecurityUpdateIssueReasonCode `json:"reasonCode"`
Action SecurityUpdateIssueAction `json:"action"`
Message string `json:"message"`
}
type SecurityUpdateStatus struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
MigrationID string `json:"migrationId,omitempty"`
OverallStatus SecurityUpdateOverallStatus `json:"overallStatus"`
SourceType SecurityUpdateSourceType `json:"sourceType,omitempty"`
ReminderVisible bool `json:"reminderVisible"`
CanStart bool `json:"canStart"`
CanPostpone bool `json:"canPostpone"`
CanRetry bool `json:"canRetry"`
BackupAvailable bool `json:"backupAvailable"`
BackupPath string `json:"backupPath,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
CompletedAt string `json:"completedAt,omitempty"`
PostponedAt string `json:"postponedAt,omitempty"`
Summary SecurityUpdateSummary `json:"summary"`
Issues []SecurityUpdateIssue `json:"issues"`
LastError string `json:"lastError,omitempty"`
}
type SecurityUpdateOptions struct {
AllowPartial bool `json:"allowPartial,omitempty"`
WriteBackup bool `json:"writeBackup,omitempty"`
}
type StartSecurityUpdateRequest struct {
SourceType SecurityUpdateSourceType `json:"sourceType"`
RawPayload string `json:"rawPayload,omitempty"`
Options *SecurityUpdateOptions `json:"options,omitempty"`
}
type RetrySecurityUpdateRequest struct {
MigrationID string `json:"migrationId,omitempty"`
}
type RestartSecurityUpdateRequest struct {
MigrationID string `json:"migrationId,omitempty"`
SourceType SecurityUpdateSourceType `json:"sourceType"`
RawPayload string `json:"rawPayload,omitempty"`
Options *SecurityUpdateOptions `json:"options,omitempty"`
}