mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
✨ feat(security): 完成密文升级与连接恢复包导入导出
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
339
cmd/manualtestseed/main.go
Normal 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")
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
f697e821b4acd5cf614d63d46453e8a4
|
||||
20168ff7047e0ecea00acb73f413f7db
|
||||
@@ -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 } }}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface AISettingsModalProps {
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
56
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
56
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
135
frontend/src/components/SecurityUpdateBanner.tsx
Normal 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;
|
||||
107
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
107
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal 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;
|
||||
67
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
67
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal 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;
|
||||
247
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
247
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal 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;
|
||||
99
frontend/src/main.browserMock.test.ts
Normal file
99
frontend/src/main.browserMock.test.ts
Normal 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 连接数组',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
60
frontend/src/utils/connectionExport.test.ts
Normal file
60
frontend/src/utils/connectionExport.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
78
frontend/src/utils/connectionExport.ts
Normal file
78
frontend/src/utils/connectionExport.ts
Normal 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();
|
||||
57
frontend/src/utils/connectionModalPresentation.test.ts
Normal file
57
frontend/src/utils/connectionModalPresentation.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
78
frontend/src/utils/connectionModalPresentation.ts
Normal file
78
frontend/src/utils/connectionModalPresentation.ts
Normal 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,
|
||||
};
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
466
frontend/src/utils/secureConfigBootstrap.test.ts
Normal file
466
frontend/src/utils/secureConfigBootstrap.test.ts
Normal 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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
351
frontend/src/utils/secureConfigBootstrap.ts
Normal file
351
frontend/src/utils/secureConfigBootstrap.ts
Normal 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,
|
||||
};
|
||||
96
frontend/src/utils/securityUpdatePresentation.test.ts
Normal file
96
frontend/src/utils/securityUpdatePresentation.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
210
frontend/src/utils/securityUpdatePresentation.ts
Normal file
210
frontend/src/utils/securityUpdatePresentation.ts
Normal 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,
|
||||
};
|
||||
79
frontend/src/utils/securityUpdateRepairFlow.test.ts
Normal file
79
frontend/src/utils/securityUpdateRepairFlow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
frontend/src/utils/securityUpdateRepairFlow.ts
Normal file
82
frontend/src/utils/securityUpdateRepairFlow.ts
Normal 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';
|
||||
15
frontend/wailsjs/go/app/App.d.ts
vendored
15
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
6
go.mod
@@ -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
13
go.sum
@@ -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=
|
||||
|
||||
262
internal/ai/service/config_store.go
Normal file
262
internal/ai/service/config_store.go
Normal 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()
|
||||
}
|
||||
206
internal/ai/service/config_store_test.go
Normal file
206
internal/ai/service/config_store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
// --- 会话文件持久化 ---
|
||||
|
||||
228
internal/app/connection_package_crypto.go
Normal file
228
internal/app/connection_package_crypto.go
Normal 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
|
||||
}
|
||||
}
|
||||
224
internal/app/connection_package_crypto_test.go
Normal file
224
internal/app/connection_package_crypto_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
229
internal/app/connection_package_transfer.go
Normal file
229
internal/app/connection_package_transfer.go
Normal 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
|
||||
}
|
||||
529
internal/app/connection_package_transfer_test.go
Normal file
529
internal/app/connection_package_transfer_test.go
Normal 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()
|
||||
}
|
||||
77
internal/app/connection_package_types.go
Normal file
77
internal/app/connection_package_types.go
Normal 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)
|
||||
}
|
||||
@@ -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) == "" &&
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
561
internal/app/security_update_engine.go
Normal file
561
internal/app/security_update_engine.go
Normal 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
|
||||
}
|
||||
942
internal/app/security_update_engine_test.go
Normal file
942
internal/app/security_update_engine_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
314
internal/app/security_update_rollback.go
Normal file
314
internal/app/security_update_rollback.go
Normal 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
|
||||
}
|
||||
85
internal/app/security_update_source_current_app.go
Normal file
85
internal/app/security_update_source_current_app.go
Normal 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
|
||||
}
|
||||
293
internal/app/security_update_state.go
Normal file
293
internal/app/security_update_state.go
Normal 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)
|
||||
}
|
||||
226
internal/app/security_update_state_test.go
Normal file
226
internal/app/security_update_state_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
129
internal/app/security_update_types.go
Normal file
129
internal/app/security_update_types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user