From 82e06bd94d882c7acf18b3ac22b6ffe546a0f2d4 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:53:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(security):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=AF=86=E6=96=87=E5=8D=87=E7=BA=A7=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E4=B8=8E=E5=AE=89=E5=85=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理 - 修复安全更新详情高亮反馈与相关前后端链路 - 补强 keyring 误判边界与安全更新回归测试 --- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/package.json.md5 | 2 +- frontend/src/App.css | 44 +++ frontend/src/App.tsx | 55 ++- .../src/components/SecurityUpdateBanner.tsx | 31 +- .../components/SecurityUpdateIntroModal.tsx | 38 ++- .../SecurityUpdateProgressModal.tsx | 2 + .../SecurityUpdateSettingsModal.tsx | 129 +++++-- frontend/src/main.tsx | 1 + frontend/src/utils/connectionExport.test.ts | 52 +++ frontend/src/utils/connectionExport.ts | 49 +++ .../src/utils/secureConfigBootstrap.test.ts | 173 +++++++++- frontend/src/utils/secureConfigBootstrap.ts | 89 ++++- .../utils/securityUpdateRepairFlow.test.ts | 62 +++- .../src/utils/securityUpdateRepairFlow.ts | 38 ++- .../src/utils/securityUpdateVisuals.test.ts | 88 +++++ frontend/src/utils/securityUpdateVisuals.ts | 65 ++++ frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/connection_package_crypto.go | 15 + .../app/connection_package_crypto_test.go | 73 ++++ internal/app/connection_package_transfer.go | 101 +++++- .../app/connection_package_transfer_test.go | 315 +++++++++++++++++- internal/app/connection_package_types.go | 6 + internal/app/methods_file.go | 23 +- internal/app/methods_file_import_test.go | 33 ++ internal/app/methods_redis.go | 14 +- internal/app/methods_redis_test.go | 258 ++++++++++++++ internal/app/methods_saved_connections.go | 26 +- .../app/methods_saved_connections_test.go | 6 +- internal/app/methods_update.go | 60 +++- internal/app/methods_update_test.go | 160 +++++++++ internal/secretstore/keyring_store.go | 20 +- internal/secretstore/keyring_store_test.go | 91 +++++ 35 files changed, 2021 insertions(+), 110 deletions(-) create mode 100644 frontend/src/utils/securityUpdateVisuals.test.ts create mode 100644 frontend/src/utils/securityUpdateVisuals.ts create mode 100644 internal/app/methods_file_import_test.go create mode 100644 internal/app/methods_redis_test.go create mode 100644 internal/app/methods_update_test.go diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 98891ca..d6c584f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "gonavi-client", - "version": "0.0.1", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gonavi-client", - "version": "0.0.1", + "version": "0.6.5", "dependencies": { "@ant-design/icons": "^5.2.6", "@dnd-kit/core": "^6.3.1", diff --git a/frontend/package.json b/frontend/package.json index 904b7a2..daddbfb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "gonavi-client", "private": true, - "version": "0.0.1", + "version": "0.6.5", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ad6ce0c..5774671 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -20168ff7047e0ecea00acb73f413f7db \ No newline at end of file +8cc5d6401a6ce7dd0f500c66ce8bb4a9 \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 24b8e5b..8927590 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -340,3 +340,47 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check .driver-manager-hscroll-inner { height: 1px; } + +.security-update-action-btn.ant-btn, +.security-update-action-btn.ant-btn-default, +.security-update-action-btn.ant-btn-primary, +.security-update-action-btn.ant-btn-text { + box-shadow: none !important; +} + +.security-update-action-btn.ant-btn:focus, +.security-update-action-btn.ant-btn:focus-visible, +.security-update-action-btn.ant-btn-default:focus, +.security-update-action-btn.ant-btn-default:focus-visible, +.security-update-action-btn.ant-btn-primary:focus, +.security-update-action-btn.ant-btn-primary:focus-visible, +.security-update-action-btn.ant-btn-text:focus, +.security-update-action-btn.ant-btn-text:focus-visible { + outline: none !important; + box-shadow: none !important; +} + +.security-update-banner { + position: relative; + isolation: isolate; +} + +.security-update-result-card { + transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease; +} + +.security-update-result-card-active { + animation: security-update-result-pulse 1.8s ease; +} + +@keyframes security-update-result-pulse { + 0% { + transform: translateY(0); + } + 30% { + transform: translateY(-2px); + } + 100% { + transform: translateY(0); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76d869b..59a200e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,7 +24,11 @@ import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shou import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; import { getConnectionWorkbenchState } from './utils/startupReadiness'; import { toSaveGlobalProxyInput } from './utils/globalProxyDraft'; -import { detectConnectionImportKind, normalizeConnectionPackagePassword } from './utils/connectionExport'; +import { + detectConnectionImportKind, + resolveConnectionPackageExportResult, + normalizeConnectionPackagePassword, +} from './utils/connectionExport'; import { bootstrapSecureConfig, finalizeSecurityUpdateStatus, @@ -41,10 +45,13 @@ import { resolveSecurityUpdateEntryVisibility, } from './utils/securityUpdatePresentation'; import { + hasSecurityUpdateRecentResult, resolveSecurityUpdateRepairEntry, + resolveSecurityUpdateSettingsFocusTarget, shouldReopenSecurityUpdateDetails, shouldRetrySecurityUpdateAfterRepairSave, type SecurityUpdateRepairSource, + type SecurityUpdateSettingsFocusTarget, } from './utils/securityUpdateRepairFlow'; import { SHORTCUT_ACTION_META, @@ -175,6 +182,8 @@ function App() { const [isSecurityUpdateIntroOpen, setIsSecurityUpdateIntroOpen] = useState(false); const [isSecurityUpdateBannerDismissed, setIsSecurityUpdateBannerDismissed] = useState(false); const [isSecurityUpdateSettingsOpen, setIsSecurityUpdateSettingsOpen] = useState(false); + const [securityUpdateSettingsFocusTarget, setSecurityUpdateSettingsFocusTarget] = useState(null); + const [securityUpdateSettingsFocusRequest, setSecurityUpdateSettingsFocusRequest] = useState(0); const [isSecurityUpdateProgressOpen, setIsSecurityUpdateProgressOpen] = useState(false); const [securityUpdateProgressStage, setSecurityUpdateProgressStage] = useState('正在检查已保存配置'); const [securityUpdateRepairSource, setSecurityUpdateRepairSource] = useState(null); @@ -278,6 +287,8 @@ function App() { setIsSecurityUpdateBannerDismissed(false); } if (options?.openSettings) { + setSecurityUpdateSettingsFocusTarget(resolveSecurityUpdateSettingsFocusTarget(nextStatus)); + setSecurityUpdateSettingsFocusRequest((current) => current + 1); setIsSecurityUpdateSettingsOpen(true); } return nextStatus; @@ -820,10 +831,15 @@ function App() { const connections = useStore(state => state.connections); const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); - const handleOpenSecurityUpdateSettings = useCallback(() => { + const openSecurityUpdateSettings = useCallback((focusTarget: SecurityUpdateSettingsFocusTarget | null = null) => { setIsSecurityUpdateIntroOpen(false); + setSecurityUpdateSettingsFocusTarget(focusTarget); + setSecurityUpdateSettingsFocusRequest((current) => current + 1); setIsSecurityUpdateSettingsOpen(true); }, []); + const handleOpenSecurityUpdateSettings = useCallback((focusTarget: SecurityUpdateSettingsFocusTarget | null = null) => { + openSecurityUpdateSettings(focusTarget); + }, [openSecurityUpdateSettings]); const runSecurityUpdateRound = useCallback(async (mode: 'start' | 'retry' | 'restart') => { const backendApp = (window as any).go?.app?.App; const stageText = mode === 'retry' @@ -943,7 +959,7 @@ function App() { securityUpdateStatus.summary, ]); const handleSecurityUpdateIssueAction = useCallback((issue: SecurityUpdateIssue) => { - const repairEntry = resolveSecurityUpdateRepairEntry(issue, connections); + const repairEntry = resolveSecurityUpdateRepairEntry(issue, connections, securityUpdateStatus); if (repairEntry.type === 'warning') { void message.warning(repairEntry.message); return; @@ -973,8 +989,8 @@ function App() { return; } setSecurityUpdateRepairSource(null); - setIsSecurityUpdateSettingsOpen(true); - }, [connections, runSecurityUpdateRound]); + openSecurityUpdateSettings(repairEntry.focusTarget); + }, [connections, openSecurityUpdateSettings, runSecurityUpdateRound, securityUpdateStatus]); const updateCheckInFlightRef = React.useRef(false); const updateDownloadInFlightRef = React.useRef(false); const updateUserDismissedRef = React.useRef(false); @@ -1216,7 +1232,11 @@ function App() { if (!silent) { setAboutUpdateStatus('正在检查更新...'); } - const res = await (window as any).go.app.App.CheckForUpdates(); + const updateAPI = (window as any).go.app.App; + const checkFn = silent && typeof updateAPI.CheckForUpdatesSilently === 'function' + ? updateAPI.CheckForUpdatesSilently + : updateAPI.CheckForUpdates; + const res = await checkFn(); updateCheckInFlightRef.current = false; if (!res?.success) { if (!silent) { @@ -1494,8 +1514,13 @@ function App() { } const res = await backendApp.ExportConnectionsPackage(password); - if (!res?.success) { - throw new Error(res?.message || '导出失败'); + const exportResult = resolveConnectionPackageExportResult(connectionPackageDialog, res); + if (exportResult.kind === 'canceled') { + setConnectionPackageDialog(exportResult.nextDialog); + return; + } + if (exportResult.kind === 'failed') { + throw new Error(exportResult.error); } closeConnectionPackageDialog(); @@ -1695,7 +1720,9 @@ function App() { const rawStatus = typeof backendApp?.GetSecurityUpdateStatus === 'function' ? await backendApp.GetSecurityUpdateStatus() : securityUpdateStatus; - const nextStatus = mergeSecurityUpdateStatusWithLegacySource(rawStatus, nextRawPayload); + const nextStatus = mergeSecurityUpdateStatusWithLegacySource(rawStatus, nextRawPayload, { + previousStatus: securityUpdateStatus, + }); const nextHasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(nextRawPayload); setSecurityUpdateRawPayload(nextRawPayload); @@ -2322,7 +2349,7 @@ function App() { title="拖动调整宽度" /> - + {securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && ( handleOpenSecurityUpdateSettings( + hasSecurityUpdateRecentResult(securityUpdateStatus) ? 'recent_result' : null, + )} onDismiss={() => setIsSecurityUpdateBannerDismissed(true)} /> )} @@ -2474,13 +2503,15 @@ function App() { overlayTheme={overlayTheme} onStart={handleStartSecurityUpdate} onPostpone={handlePostponeSecurityUpdate} - onViewDetails={handleOpenSecurityUpdateSettings} + onViewDetails={() => handleOpenSecurityUpdateSettings()} /> setIsSecurityUpdateSettingsOpen(false)} onStart={handleStartSecurityUpdate} onRetry={handleRetrySecurityUpdate} diff --git a/frontend/src/components/SecurityUpdateBanner.tsx b/frontend/src/components/SecurityUpdateBanner.tsx index b83fc31..ac410b5 100644 --- a/frontend/src/components/SecurityUpdateBanner.tsx +++ b/frontend/src/components/SecurityUpdateBanner.tsx @@ -4,6 +4,12 @@ import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons'; import type { SecurityUpdateStatus } from '../types'; import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import { + SECURITY_UPDATE_ACTION_BUTTON_CLASS, + SECURITY_UPDATE_BANNER_CLASS, + getSecurityUpdateActionButtonStyle, + getSecurityUpdateBannerSurfaceStyle, +} from '../utils/securityUpdateVisuals'; interface SecurityUpdateBannerProps { status: SecurityUpdateStatus; @@ -77,20 +83,20 @@ const SecurityUpdateBanner = ({ const statusMeta = getSecurityUpdateStatusMeta(status); const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails }); const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails }); + const actionButtonStyle = getSecurityUpdateActionButtonStyle(); return (
{secondaryAction ? ( - ) : null} - -
); diff --git a/frontend/src/components/SecurityUpdateIntroModal.tsx b/frontend/src/components/SecurityUpdateIntroModal.tsx index 7123f0c..e02c099 100644 --- a/frontend/src/components/SecurityUpdateIntroModal.tsx +++ b/frontend/src/components/SecurityUpdateIntroModal.tsx @@ -3,6 +3,11 @@ import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { CSSProperties } from 'react'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import { + SECURITY_UPDATE_ACTION_BUTTON_CLASS, + SECURITY_UPDATE_MODAL_CLASS, + getSecurityUpdateActionButtonStyle, +} from '../utils/securityUpdateVisuals'; interface SecurityUpdateIntroModalProps { open: boolean; @@ -15,10 +20,9 @@ interface SecurityUpdateIntroModalProps { } const actionButtonStyle: CSSProperties = { + ...getSecurityUpdateActionButtonStyle(), height: 38, - borderRadius: 12, paddingInline: 18, - fontWeight: 600, }; const SecurityUpdateIntroModal = ({ @@ -32,6 +36,7 @@ const SecurityUpdateIntroModal = ({ }: SecurityUpdateIntroModalProps) => { return (
+ , - , - , ]} diff --git a/frontend/src/components/SecurityUpdateProgressModal.tsx b/frontend/src/components/SecurityUpdateProgressModal.tsx index dec305e..5e3888b 100644 --- a/frontend/src/components/SecurityUpdateProgressModal.tsx +++ b/frontend/src/components/SecurityUpdateProgressModal.tsx @@ -2,6 +2,7 @@ import { Modal, Spin } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import { SECURITY_UPDATE_MODAL_CLASS } from '../utils/securityUpdateVisuals'; interface SecurityUpdateProgressModalProps { open: boolean; @@ -18,6 +19,7 @@ const SecurityUpdateProgressModal = ({ }: SecurityUpdateProgressModalProps) => { return ( void; onStart: () => void; onRetry: () => void; @@ -23,18 +41,27 @@ interface SecurityUpdateSettingsModalProps { onIssueAction: (issue: SecurityUpdateIssue) => void; } -const sectionStyle = (overlayTheme: OverlayWorkbenchTheme) => ({ +const sectionStyle = ( + overlayTheme: OverlayWorkbenchTheme, + options?: { emphasized?: boolean }, +) => ({ borderRadius: 14, - border: overlayTheme.sectionBorder, - background: overlayTheme.sectionBg, padding: 16, + ...getSecurityUpdateSectionSurfaceStyle(overlayTheme, options), }); +const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = { + target: null, + pulseKey: null, +}; + const SecurityUpdateSettingsModal = ({ open, darkMode, overlayTheme, status, + focusTarget = null, + focusRequest = 0, onClose, onStart, onRetry, @@ -43,12 +70,53 @@ const SecurityUpdateSettingsModal = ({ }: SecurityUpdateSettingsModalProps) => { const statusMeta = getSecurityUpdateStatusMeta(status); const sortedIssues = sortSecurityUpdateIssues(status.issues); + const showRecentResult = hasSecurityUpdateRecentResult(status); const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed'; const showRetry = status.overallStatus === 'needs_attention'; const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back'; + const actionButtonStyle = getSecurityUpdateActionButtonStyle(); + const [activeFocus, setActiveFocus] = useState(EMPTY_FOCUS_STATE); + const statusSectionRef = useRef(null); + const recentResultRef = useRef(null); + + useEffect(() => { + const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest); + if (!nextFocus.target || !nextFocus.pulseKey) { + setActiveFocus(EMPTY_FOCUS_STATE); + return undefined; + } + + const targetNode = nextFocus.target === 'recent_result' + ? recentResultRef.current + : statusSectionRef.current; + if (!targetNode) { + return undefined; + } + + setActiveFocus(EMPTY_FOCUS_STATE); + const animationFrame = window.requestAnimationFrame(() => { + targetNode.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + targetNode.focus({ preventScroll: true }); + setActiveFocus(nextFocus); + }); + const highlightTimer = window.setTimeout(() => { + setActiveFocus((current) => ( + current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current + )); + }, 1800); + + return () => { + window.cancelAnimationFrame(animationFrame); + window.clearTimeout(highlightTimer); + }; + }, [focusRequest, focusTarget, open]); return (
+ ) : null, showRestart ? ( - ) : null, showStart ? ( - ) : null, - , ]} width={760} styles={{ - content: { - background: overlayTheme.shellBg, - border: overlayTheme.shellBorder, - boxShadow: overlayTheme.shellShadow, - backdropFilter: overlayTheme.shellBackdropFilter, - }, + content: getSecurityUpdateShellSurfaceStyle(overlayTheme), header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, }} >
-
+
@@ -153,8 +226,9 @@ const SecurityUpdateSettingsModal = ({
@@ -184,9 +258,8 @@ const SecurityUpdateSettingsModal = ({
- {status.backupPath ? ( -
+ {showRecentResult ? ( +
最近一次结果
-
- 备份位置:{status.backupPath} -
+ {status.backupPath ? ( +
+ 备份位置:{status.backupPath} +
+ ) : null} {status.lastError ? (
最近错误:{status.lastError} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bb2e2ac..fea428b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -119,6 +119,7 @@ if (typeof window !== 'undefined' && !(window as any).go) { DeleteQuery: async () => null, GetAppInfo: async () => ({}), CheckForUpdates: async () => ({ success: false }), + CheckForUpdatesSilently: async () => ({ success: false }), OpenDownloadedUpdateDirectory: async () => ({ success: false }), InstallUpdateAndRestart: async () => ({ success: false }), ImportConfigFile: async () => ({ success: false, message: '已取消' }), diff --git a/frontend/src/utils/connectionExport.test.ts b/frontend/src/utils/connectionExport.test.ts index 5b1c53e..d4f9720 100644 --- a/frontend/src/utils/connectionExport.test.ts +++ b/frontend/src/utils/connectionExport.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { detectConnectionImportKind, + isConnectionPackageExportCanceled, + resolveConnectionPackageExportResult, normalizeConnectionPackagePassword, } from './connectionExport'; @@ -57,4 +59,54 @@ describe('connectionExport', () => { expect(normalizeConnectionPackagePassword(' secret-pass ')).toBe('secret-pass'); expect(normalizeConnectionPackagePassword('\n\t \t')).toBe(''); }); + + it('treats export cancel as a non-error backend result', () => { + expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true); + expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false); + expect(isConnectionPackageExportCanceled({ success: true, message: '已取消' })).toBe(false); + expect(isConnectionPackageExportCanceled(undefined)).toBe(false); + }); + + it('maps export results to dialog state transitions', () => { + const staleDialog = { + open: true, + mode: 'export' as const, + password: ' secret-pass ', + error: '上一次失败', + confirmLoading: false, + }; + + const canceledResult = resolveConnectionPackageExportResult(staleDialog, { success: false, message: '已取消' }); + expect(canceledResult.kind).toBe('canceled'); + if (canceledResult.kind === 'canceled') { + expect(typeof canceledResult.nextDialog).toBe('function'); + expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({ + open: false, + mode: 'export', + password: 'secret-pass', + error: '更新后的错误', + confirmLoading: true, + })).toEqual({ + open: false, + mode: 'export', + password: 'secret-pass', + error: '', + confirmLoading: false, + }); + } + + expect(resolveConnectionPackageExportResult(staleDialog, { success: true, message: '导出完成' })).toEqual({ + kind: 'succeeded', + }); + + expect(resolveConnectionPackageExportResult(staleDialog, { success: false, message: '磁盘已满' })).toEqual({ + kind: 'failed', + error: '磁盘已满', + }); + + expect(resolveConnectionPackageExportResult(staleDialog, undefined)).toEqual({ + kind: 'failed', + error: '导出失败', + }); + }); }); diff --git a/frontend/src/utils/connectionExport.ts b/frontend/src/utils/connectionExport.ts index 9cec933..13ff987 100644 --- a/frontend/src/utils/connectionExport.ts +++ b/frontend/src/utils/connectionExport.ts @@ -1,10 +1,26 @@ import type { ConnectionConfig, SavedConnection } from '../types'; export type ConnectionImportKind = 'encrypted-package' | 'legacy-json' | 'invalid'; +export type ConnectionPackageDialogSnapshot = { + open: boolean; + mode: 'export' | 'import'; + password: string; + error: string; + confirmLoading: boolean; +}; +export type ConnectionPackageDialogUpdater = ( + current: ConnectionPackageDialogSnapshot, +) => ConnectionPackageDialogSnapshot; + +export type ConnectionPackageExportResult = + | { kind: 'canceled'; nextDialog: ConnectionPackageDialogUpdater } + | { kind: 'succeeded' } + | { kind: 'failed'; error: string }; type JsonObject = Record; const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package'; +const CANCELED_MESSAGE = '已取消'; const isJsonObject = (value: unknown): value is JsonObject => ( typeof value === 'object' && value !== null && !Array.isArray(value) @@ -69,6 +85,39 @@ export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind = export const normalizeConnectionPackagePassword = (value: string): string => value.trim(); +export const isConnectionPackageExportCanceled = (result: unknown): boolean => ( + isJsonObject(result) + && result.success === false + && result.message === CANCELED_MESSAGE +); + +export const resolveConnectionPackageExportResult = ( + _currentDialog: ConnectionPackageDialogSnapshot, + result: unknown, +): ConnectionPackageExportResult => { + if (isConnectionPackageExportCanceled(result)) { + return { + kind: 'canceled', + nextDialog: (current) => ({ + ...current, + confirmLoading: false, + error: '', + }), + }; + } + + if (isJsonObject(result) && result.success === true) { + return { kind: 'succeeded' }; + } + + return { + kind: 'failed', + error: isJsonObject(result) && typeof result.message === 'string' && result.message.trim() + ? result.message + : '导出失败', + }; +}; + const legacyExportRemovedError = (): never => { throw new Error('Legacy connection JSON export has been removed. Use the recovery package flow instead.'); }; diff --git a/frontend/src/utils/secureConfigBootstrap.test.ts b/frontend/src/utils/secureConfigBootstrap.test.ts index 07e13e6..32c9cd5 100644 --- a/frontend/src/utils/secureConfigBootstrap.test.ts +++ b/frontend/src/utils/secureConfigBootstrap.test.ts @@ -220,6 +220,83 @@ describe('secureConfigBootstrap', () => { expect(result.shouldShowBanner).toBe(true); }); + it('merges legacy pending items into rolled_back status without overwriting backend system issues', () => { + const status = mergeSecurityUpdateStatusWithLegacySource({ + overallStatus: 'rolled_back', + summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 }, + issues: [ + { + id: 'system-blocked', + scope: 'system', + title: '系统回滚', + severity: 'high', + status: 'failed', + reasonCode: 'environment_blocked', + action: 'view_details', + message: '后端已回滚本轮更新,需要处理后重试。', + }, + ], + }, legacyPayload); + + expect(status.overallStatus).toBe('rolled_back'); + expect(status.summary).toEqual({ + total: 3, + updated: 0, + pending: 2, + skipped: 0, + failed: 1, + }); + expect(status.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'system-blocked', scope: 'system' }), + expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }), + expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }), + ])); + }); + + it('merges legacy pending items into needs_attention status without overwriting backend system issues', () => { + const status = mergeSecurityUpdateStatusWithLegacySource({ + overallStatus: 'needs_attention', + summary: { total: 2, updated: 1, pending: 0, skipped: 0, failed: 1 }, + issues: [ + { + id: 'system-partial-failure', + scope: 'system', + title: '部分失败', + severity: 'high', + status: 'failed', + reasonCode: 'environment_blocked', + action: 'view_details', + message: '部分项目迁移失败,需要人工处理。', + }, + { + id: 'ai-provider-openai-main', + scope: 'ai_provider', + refId: 'openai-main', + title: 'OpenAI', + severity: 'medium', + status: 'updated', + action: 'open_ai_settings', + message: 'AI 提供商配置已完成安全更新。', + }, + ], + }, legacyPayload); + + expect(status.overallStatus).toBe('needs_attention'); + expect(status.summary).toEqual({ + total: 4, + updated: 1, + pending: 2, + skipped: 0, + failed: 1, + }); + expect(status.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'system-partial-failure', scope: 'system' }), + expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }), + expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }), + expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }), + ])); + }); + it('loads backend secure config directly when no legacy source exists', async () => { const storage = createMemoryStorage(); const replaceConnections = vi.fn(); @@ -440,18 +517,25 @@ describe('secureConfigBootstrap', () => { }); it('reduces legacy pending issues after a single connection is repaired before the first round starts', () => { + const initialStatus = mergeSecurityUpdateStatusWithLegacySource({ + overallStatus: 'not_detected', + summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 }, + issues: [], + }, legacyPayload); 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); + }, nextPayload, { + previousStatus: initialStatus, + }); expect(status.overallStatus).toBe('pending'); expect(status.summary).toEqual({ - total: 1, - updated: 0, + total: 2, + updated: 1, pending: 1, skipped: 0, failed: 0, @@ -463,4 +547,87 @@ describe('secureConfigBootstrap', () => { }), ]); }); + + it('accumulates pre-start repaired progress across multiple connection saves in the same round-free session', () => { + const multiConnectionPayload = JSON.stringify({ + state: { + connections: [ + { + id: 'legacy-1', + name: 'Legacy 1', + config: { + id: 'legacy-1', + type: 'postgres', + host: 'db-1.local', + port: 5432, + user: 'postgres', + password: 'secret-1', + }, + }, + { + id: 'legacy-2', + name: 'Legacy 2', + config: { + id: 'legacy-2', + type: 'postgres', + host: 'db-2.local', + port: 5432, + user: 'postgres', + password: 'secret-2', + }, + }, + { + id: 'legacy-3', + name: 'Legacy 3', + config: { + id: 'legacy-3', + type: 'postgres', + host: 'db-3.local', + port: 5432, + user: 'postgres', + password: 'secret-3', + }, + }, + ], + }, + }); + + const backendStatus = { + overallStatus: 'not_detected' as const, + summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 }, + issues: [], + }; + const initialStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, multiConnectionPayload); + const afterFirstRepairPayload = stripLegacyPersistedConnectionById(multiConnectionPayload, 'legacy-1'); + const afterFirstRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterFirstRepairPayload, { + previousStatus: initialStatus, + }); + const afterSecondRepairPayload = stripLegacyPersistedConnectionById(afterFirstRepairPayload, 'legacy-2'); + + const afterSecondRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterSecondRepairPayload, { + previousStatus: afterFirstRepairStatus, + }); + + expect(afterFirstRepairStatus.summary).toEqual({ + total: 3, + updated: 1, + pending: 2, + skipped: 0, + failed: 0, + }); + expect(afterSecondRepairStatus.summary).toEqual({ + total: 3, + updated: 2, + pending: 1, + skipped: 0, + failed: 0, + }); + expect(afterSecondRepairStatus.issues).toEqual([ + expect.objectContaining({ + id: 'legacy-connection-legacy-3', + scope: 'connection', + refId: 'legacy-3', + }), + ]); + }); }); diff --git a/frontend/src/utils/secureConfigBootstrap.ts b/frontend/src/utils/secureConfigBootstrap.ts index 666178e..f457024 100644 --- a/frontend/src/utils/secureConfigBootstrap.ts +++ b/frontend/src/utils/secureConfigBootstrap.ts @@ -54,6 +54,10 @@ type StartSecurityUpdateResult = { error: Error | null; }; +type MergeSecurityUpdateStatusOptions = { + previousStatus?: Partial | null; +}; + const defaultSummary = () => ({ total: 0, updated: 0, @@ -129,9 +133,56 @@ const mergeSecurityUpdateIssues = ( }; }; +const isLocalLegacyIssue = (issue: Partial | null | undefined): boolean => { + const issueId = String(issue?.id || '').trim(); + return issueId.startsWith('legacy-connection-') || issueId === 'legacy-global-proxy-default'; +}; + +const countLocalLegacyIssues = (issues: SecurityUpdateIssue[]): number => ( + issues.filter((issue) => isLocalLegacyIssue(issue)).length +); + +const deriveLegacySummary = ( + base: SecurityUpdateStatus, + currentLegacyCount: number, + previousStatus?: Partial | null, +): { + summary: SecurityUpdateSummary; + hasContribution: boolean; +} => { + const previousSummary = previousStatus?.summary ?? defaultSummary(); + const previousIssues = Array.isArray(previousStatus?.issues) ? previousStatus.issues : []; + const previousLegacyCount = countLocalLegacyIssues(previousIssues); + const previousLegacyTotal = Math.max( + 0, + previousSummary.total - base.summary.total, + previousSummary.updated - base.summary.updated + previousLegacyCount, + previousLegacyCount, + ); + const previousLegacyUpdated = Math.max( + 0, + Math.min(previousLegacyTotal, previousSummary.updated - base.summary.updated), + ); + const repairedSincePrevious = Math.max(0, previousLegacyCount - currentLegacyCount); + const nextLegacyUpdated = Math.min(previousLegacyTotal, previousLegacyUpdated + repairedSincePrevious); + const nextLegacyTotal = Math.max(previousLegacyTotal, nextLegacyUpdated + currentLegacyCount); + + return { + summary: { + total: base.summary.total + nextLegacyTotal, + updated: base.summary.updated + nextLegacyUpdated, + pending: base.summary.pending + currentLegacyCount, + skipped: base.summary.skipped, + failed: base.summary.failed, + }, + hasContribution: nextLegacyTotal > 0, + }; +}; + export const mergeSecurityUpdateStatusWithLegacySource = ( status: Partial | undefined, rawPayload: string | null, + options?: MergeSecurityUpdateStatusOptions, ): SecurityUpdateStatus => { const base: SecurityUpdateStatus = { ...defaultStatus(), @@ -142,46 +193,51 @@ export const mergeSecurityUpdateStatusWithLegacySource = ( }, issues: Array.isArray(status?.issues) ? status.issues : [], }; + const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue)); const legacy = buildLegacyPendingDetails(rawPayload); - if (!legacy.hasLegacyItems) { + const legacySummary = deriveLegacySummary(base, legacy.issues.length, options?.previousStatus); + + if (!legacySummary.hasContribution) { return base; } + const mergedIssues = mergeSecurityUpdateIssues(baseNonLegacyIssues, legacy.issues).issues; + if (base.overallStatus === 'not_detected') { + if (!legacy.hasLegacyItems) { + return base; + } return { ...base, overallStatus: 'pending', reminderVisible: true, canStart: true, canPostpone: true, - summary: legacy.summary, - issues: legacy.issues, + summary: legacySummary.summary, + issues: mergedIssues, }; } 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, + summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary, + issues: mergedIssues, canStart: true, canPostpone: true, reminderVisible: base.overallStatus === 'pending' ? true : base.reminderVisible, }; } + if (base.overallStatus === 'rolled_back' || base.overallStatus === 'needs_attention') { + return { + ...base, + summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary, + issues: mergedIssues, + }; + } + return base; }; @@ -344,6 +400,7 @@ export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstr export type { BackendGlobalProxyResult, + MergeSecurityUpdateStatusOptions, SecurityUpdateBackend, SecureConfigBootstrapArgs, SecureConfigBootstrapResult, diff --git a/frontend/src/utils/securityUpdateRepairFlow.test.ts b/frontend/src/utils/securityUpdateRepairFlow.test.ts index 3e514bb..0cb57f7 100644 --- a/frontend/src/utils/securityUpdateRepairFlow.test.ts +++ b/frontend/src/utils/securityUpdateRepairFlow.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; -import type { SavedConnection, SecurityUpdateIssue } from '../types'; +import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types'; import { + hasSecurityUpdateRecentResult, + resolveSecurityUpdateFocusState, resolveSecurityUpdateRepairEntry, + resolveSecurityUpdateSettingsFocusTarget, shouldReopenSecurityUpdateDetails, shouldRetrySecurityUpdateAfterRepairSave, } from './securityUpdateRepairFlow'; @@ -19,6 +22,19 @@ const createConnection = (id: string): SavedConnection => ({ }, }); +const createStatus = (overrides: Partial = {}): SecurityUpdateStatus => ({ + overallStatus: 'needs_attention', + summary: { + total: 1, + updated: 0, + pending: 1, + skipped: 0, + failed: 0, + }, + issues: [], + ...overrides, +}); + describe('securityUpdateRepairFlow', () => { it('opens the matching connection and preserves the return source for security update repairs', () => { const target = createConnection('conn-1'); @@ -63,6 +79,50 @@ describe('securityUpdateRepairFlow', () => { }); }); + it('routes view_details actions to the latest result section when a recent result exists', () => { + const status = createStatus({ + backupPath: '/tmp/gonavi-backup.json', + lastError: '写入新密钥失败', + }); + + expect(hasSecurityUpdateRecentResult(status)).toBe(true); + expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('recent_result'); + expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({ + type: 'details', + focusTarget: 'recent_result', + }); + }); + + it('falls back to the status section when no recent result is available yet', () => { + const status = createStatus(); + + expect(hasSecurityUpdateRecentResult(status)).toBe(false); + expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('status'); + expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({ + type: 'details', + focusTarget: 'status', + }); + }); + + it('builds a fresh focus pulse for repeated details clicks and clears it when the modal closes', () => { + expect(resolveSecurityUpdateFocusState(true, 'status', 1)).toEqual({ + target: 'status', + pulseKey: 'status:1', + }); + expect(resolveSecurityUpdateFocusState(true, 'status', 2)).toEqual({ + target: 'status', + pulseKey: 'status:2', + }); + expect(resolveSecurityUpdateFocusState(false, 'status', 2)).toEqual({ + target: null, + pulseKey: null, + }); + expect(resolveSecurityUpdateFocusState(true, null, 3)).toEqual({ + target: null, + pulseKey: null, + }); + }); + it('reopens security update details after closing a repair entry opened from that page', () => { expect(shouldReopenSecurityUpdateDetails('connection')).toBe(true); expect(shouldReopenSecurityUpdateDetails('proxy')).toBe(true); diff --git a/frontend/src/utils/securityUpdateRepairFlow.ts b/frontend/src/utils/securityUpdateRepairFlow.ts index 5df098a..9a6be1e 100644 --- a/frontend/src/utils/securityUpdateRepairFlow.ts +++ b/frontend/src/utils/securityUpdateRepairFlow.ts @@ -1,6 +1,11 @@ -import type { SavedConnection, SecurityUpdateIssue } from '../types'; +import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types'; export type SecurityUpdateRepairSource = 'connection' | 'proxy' | 'ai'; +export type SecurityUpdateSettingsFocusTarget = 'recent_result' | 'status'; +export type SecurityUpdateFocusState = { + target: SecurityUpdateSettingsFocusTarget | null; + pulseKey: string | null; +}; export type SecurityUpdateRepairEntry = | { @@ -22,15 +27,45 @@ export type SecurityUpdateRepairEntry = } | { type: 'details'; + focusTarget: SecurityUpdateSettingsFocusTarget; } | { type: 'warning'; message: string; }; +export const hasSecurityUpdateRecentResult = ( + status?: Pick | null, +): boolean => Boolean(status?.backupPath || status?.lastError); + +export const resolveSecurityUpdateSettingsFocusTarget = ( + status?: Pick | null, +): SecurityUpdateSettingsFocusTarget => ( + hasSecurityUpdateRecentResult(status) ? 'recent_result' : 'status' +); + +export const resolveSecurityUpdateFocusState = ( + open: boolean, + focusTarget: SecurityUpdateSettingsFocusTarget | null | undefined, + focusRequest: number, +): SecurityUpdateFocusState => { + if (!open || !focusTarget) { + return { + target: null, + pulseKey: null, + }; + } + + return { + target: focusTarget, + pulseKey: `${focusTarget}:${focusRequest}`, + }; +}; + export const resolveSecurityUpdateRepairEntry = ( issue: SecurityUpdateIssue, connections: SavedConnection[], + status?: Pick | null, ): SecurityUpdateRepairEntry => { if (issue.action === 'open_connection') { const target = connections.find((connection) => connection.id === issue.refId); @@ -70,6 +105,7 @@ export const resolveSecurityUpdateRepairEntry = ( return { type: 'details', + focusTarget: resolveSecurityUpdateSettingsFocusTarget(status), }; }; diff --git a/frontend/src/utils/securityUpdateVisuals.test.ts b/frontend/src/utils/securityUpdateVisuals.test.ts new file mode 100644 index 0000000..7d0d8e7 --- /dev/null +++ b/frontend/src/utils/securityUpdateVisuals.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme'; +import { + SECURITY_UPDATE_ACTION_BUTTON_CLASS, + SECURITY_UPDATE_BANNER_CLASS, + SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS, + SECURITY_UPDATE_RESULT_CARD_CLASS, + getSecurityUpdateActionButtonStyle, + getSecurityUpdateBannerSurfaceStyle, + getSecurityUpdateSectionSurfaceStyle, + getSecurityUpdateShellSurfaceStyle, +} from './securityUpdateVisuals'; + +describe('securityUpdateVisuals', () => { + it('builds action buttons without default ant focus glow shadow', () => { + expect(SECURITY_UPDATE_ACTION_BUTTON_CLASS).toBe('security-update-action-btn'); + expect(SECURITY_UPDATE_BANNER_CLASS).toBe('security-update-banner'); + expect(SECURITY_UPDATE_RESULT_CARD_CLASS).toBe('security-update-result-card'); + expect(SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS).toBe('security-update-result-card-active'); + expect(getSecurityUpdateActionButtonStyle()).toMatchObject({ + height: 36, + borderRadius: 12, + boxShadow: 'none', + fontWeight: 600, + }); + }); + + it('keeps the shell surface aligned with overlay shell tokens in light and dark mode', () => { + const lightTheme = buildOverlayWorkbenchTheme(false); + const darkTheme = buildOverlayWorkbenchTheme(true); + + expect(getSecurityUpdateShellSurfaceStyle(lightTheme)).toMatchObject({ + border: lightTheme.shellBorder, + background: lightTheme.shellBg, + boxShadow: lightTheme.shellShadow, + backdropFilter: lightTheme.shellBackdropFilter, + }); + expect(getSecurityUpdateShellSurfaceStyle(darkTheme)).toMatchObject({ + border: darkTheme.shellBorder, + background: darkTheme.shellBg, + boxShadow: darkTheme.shellShadow, + backdropFilter: darkTheme.shellBackdropFilter, + }); + }); + + it('keeps the banner surface aligned with overlay shell tokens instead of translucent section tokens', () => { + const lightTheme = buildOverlayWorkbenchTheme(false); + const darkTheme = buildOverlayWorkbenchTheme(true); + + expect(getSecurityUpdateBannerSurfaceStyle(lightTheme)).toMatchObject({ + border: lightTheme.shellBorder, + background: lightTheme.shellBg, + boxShadow: 'none', + backdropFilter: lightTheme.shellBackdropFilter, + }); + expect(getSecurityUpdateBannerSurfaceStyle(darkTheme)).toMatchObject({ + border: darkTheme.shellBorder, + background: darkTheme.shellBg, + boxShadow: 'none', + backdropFilter: darkTheme.shellBackdropFilter, + }); + }); + + it('can emphasize a section surface for transient focus and recent-result highlighting', () => { + const lightTheme = buildOverlayWorkbenchTheme(false); + const darkTheme = buildOverlayWorkbenchTheme(true); + + expect(getSecurityUpdateSectionSurfaceStyle(lightTheme)).toMatchObject({ + border: lightTheme.sectionBorder, + background: lightTheme.sectionBg, + boxShadow: 'none', + }); + expect(getSecurityUpdateSectionSurfaceStyle(darkTheme)).toMatchObject({ + border: darkTheme.sectionBorder, + background: darkTheme.sectionBg, + boxShadow: 'none', + }); + + const emphasizedLight = getSecurityUpdateSectionSurfaceStyle(lightTheme, { emphasized: true }); + const emphasizedDark = getSecurityUpdateSectionSurfaceStyle(darkTheme, { emphasized: true }); + + expect(emphasizedLight.background).not.toBe(lightTheme.sectionBg); + expect(emphasizedLight.boxShadow).not.toBe('none'); + expect(emphasizedDark.background).not.toBe(darkTheme.sectionBg); + expect(emphasizedDark.boxShadow).not.toBe('none'); + }); +}); diff --git a/frontend/src/utils/securityUpdateVisuals.ts b/frontend/src/utils/securityUpdateVisuals.ts new file mode 100644 index 0000000..735ca2d --- /dev/null +++ b/frontend/src/utils/securityUpdateVisuals.ts @@ -0,0 +1,65 @@ +import type { CSSProperties } from 'react'; + +import type { OverlayWorkbenchTheme } from './overlayWorkbenchTheme'; + +export const SECURITY_UPDATE_ACTION_BUTTON_CLASS = 'security-update-action-btn'; +export const SECURITY_UPDATE_BANNER_CLASS = 'security-update-banner'; +export const SECURITY_UPDATE_MODAL_CLASS = 'security-update-modal'; +export const SECURITY_UPDATE_RESULT_CARD_CLASS = 'security-update-result-card'; +export const SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS = 'security-update-result-card-active'; + +type SecurityUpdateSectionSurfaceOptions = { + emphasized?: boolean; +}; + +const getSecurityUpdateHighlightBorder = (overlayTheme: OverlayWorkbenchTheme): string => ( + overlayTheme.isDark + ? '1px solid rgba(255,214,102,0.26)' + : '1px solid rgba(22,119,255,0.22)' +); + +const getSecurityUpdateHighlightBackground = (overlayTheme: OverlayWorkbenchTheme): string => ( + overlayTheme.isDark + ? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,255,255,0.05) 100%)' + : 'linear-gradient(180deg, rgba(22,119,255,0.12) 0%, rgba(255,255,255,0.96) 100%)' +); + +const getSecurityUpdateHighlightShadow = (overlayTheme: OverlayWorkbenchTheme): string => ( + overlayTheme.isDark + ? '0 0 0 1px rgba(255,214,102,0.12), 0 12px 24px rgba(0,0,0,0.16)' + : '0 0 0 1px rgba(22,119,255,0.08), 0 10px 22px rgba(15,23,42,0.08)' +); + +export const getSecurityUpdateActionButtonStyle = (): CSSProperties => ({ + height: 36, + borderRadius: 12, + paddingInline: 16, + boxShadow: 'none', + fontWeight: 600, +}); + +export const getSecurityUpdateShellSurfaceStyle = ( + overlayTheme: OverlayWorkbenchTheme, +): CSSProperties => ({ + border: overlayTheme.shellBorder, + background: overlayTheme.shellBg, + boxShadow: overlayTheme.shellShadow, + backdropFilter: overlayTheme.shellBackdropFilter, +}); + +export const getSecurityUpdateBannerSurfaceStyle = ( + overlayTheme: OverlayWorkbenchTheme, +): CSSProperties => ({ + ...getSecurityUpdateShellSurfaceStyle(overlayTheme), + boxShadow: 'none', +}); + +export const getSecurityUpdateSectionSurfaceStyle = ( + overlayTheme: OverlayWorkbenchTheme, + options: SecurityUpdateSectionSurfaceOptions = {}, +): CSSProperties => ({ + border: options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder, + background: options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg, + boxShadow: options.emphasized ? getSecurityUpdateHighlightShadow(overlayTheme) : 'none', + transition: 'background 180ms ease, border-color 180ms ease, box-shadow 180ms ease', +}); diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index e18d5a7..ab7f7d3 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -15,6 +15,8 @@ export function CheckDriverNetworkStatus():Promise; export function CheckForUpdates():Promise; +export function CheckForUpdatesSilently():Promise; + export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 5f65811..269c6af 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -22,6 +22,10 @@ export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } +export function CheckForUpdatesSilently() { + return window['go']['app']['App']['CheckForUpdatesSilently'](); +} + export function ConfigureDriverRuntimeDirectory(arg1) { return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1); } diff --git a/internal/app/connection_package_crypto.go b/internal/app/connection_package_crypto.go index e844144..8337e2e 100644 --- a/internal/app/connection_package_crypto.go +++ b/internal/app/connection_package_crypto.go @@ -69,7 +69,13 @@ func encryptConnectionPackage(payload connectionPackagePayload, password string) } ciphertext := aead.Seal(nil, nonce, plain, aad) + if len(ciphertext) > connectionPackageMaxCiphertextBytes { + return connectionPackageFile{}, errConnectionPackagePayloadTooLarge + } file.Payload = base64.StdEncoding.EncodeToString(ciphertext) + if len(file.Payload) > connectionPackageMaxPayloadBase64Bytes { + return connectionPackageFile{}, errConnectionPackagePayloadTooLarge + } return file, nil } @@ -84,6 +90,9 @@ func decryptConnectionPackage(file connectionPackageFile, password string) (conn plain, err := decryptConnectionPackagePlaintext(file, normalizedPassword) if err != nil { + if errors.Is(err, errConnectionPackagePayloadTooLarge) { + return connectionPackagePayload{}, err + } return connectionPackagePayload{}, errConnectionPackageDecryptFailed } @@ -127,10 +136,16 @@ func decryptConnectionPackagePlaintext(file connectionPackageFile, password stri if err != nil || len(nonce) != connectionPackageNonceBytes { return nil, errors.New("invalid nonce") } + if len(file.Payload) > connectionPackageMaxPayloadBase64Bytes { + return nil, errConnectionPackagePayloadTooLarge + } ciphertext, err := base64.StdEncoding.DecodeString(file.Payload) if err != nil || len(ciphertext) == 0 { return nil, errors.New("invalid payload") } + if len(ciphertext) > connectionPackageMaxCiphertextBytes { + return nil, errConnectionPackagePayloadTooLarge + } key, err := deriveConnectionPackageKey(password, file.KDF) if err != nil { diff --git a/internal/app/connection_package_crypto_test.go b/internal/app/connection_package_crypto_test.go index b1368e0..22ba2f1 100644 --- a/internal/app/connection_package_crypto_test.go +++ b/internal/app/connection_package_crypto_test.go @@ -1,9 +1,11 @@ package app import ( + "encoding/base64" "encoding/json" "errors" "reflect" + "strings" "testing" "GoNavi-Wails/internal/connection" @@ -222,3 +224,74 @@ func TestValidateConnectionPackageKDFSpecRejectsOversizedParams(t *testing.T) { } }) } + +func TestDecryptConnectionPackagePlaintextRejectsOversizedPayload(t *testing.T) { + nonce := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageNonceBytes)) + salt := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageSaltBytes)) + payload := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageMaxCiphertextBytes+1)) + + file := connectionPackageFile{ + SchemaVersion: connectionPackageSchemaVersion, + Kind: connectionPackageKind, + Cipher: connectionPackageCipher, + KDF: connectionPackageKDFSpec{ + Name: connectionPackageKDFName, + MemoryKiB: connectionPackageKDFDefaultMemoryKiB, + TimeCost: connectionPackageKDFDefaultTimeCost, + Parallelism: connectionPackageKDFDefaultParallelism, + Salt: salt, + }, + Nonce: nonce, + Payload: payload, + } + + _, err := decryptConnectionPackagePlaintext(file, "correct-password") + if !errors.Is(err, errConnectionPackagePayloadTooLarge) { + t.Fatalf("oversized payload should return errConnectionPackagePayloadTooLarge, got: %v", err) + } +} + +func TestDecryptConnectionPackagePlaintextRejectsOversizedBase64PayloadBeforeDecode(t *testing.T) { + nonce := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageNonceBytes)) + + file := connectionPackageFile{ + SchemaVersion: connectionPackageSchemaVersion, + Kind: connectionPackageKind, + Cipher: connectionPackageCipher, + KDF: connectionPackageKDFSpec{ + Name: connectionPackageKDFName, + MemoryKiB: connectionPackageKDFDefaultMemoryKiB, + TimeCost: connectionPackageKDFDefaultTimeCost, + Parallelism: connectionPackageKDFDefaultParallelism, + Salt: base64.StdEncoding.EncodeToString(make([]byte, connectionPackageSaltBytes)), + }, + Nonce: nonce, + Payload: strings.Repeat("A", connectionPackageMaxPayloadBase64Bytes+4), + } + + _, err := decryptConnectionPackagePlaintext(file, "correct-password") + if !errors.Is(err, errConnectionPackagePayloadTooLarge) { + t.Fatalf("oversized base64 payload should return errConnectionPackagePayloadTooLarge, got: %v", err) + } +} + +func TestEncryptConnectionPackageRejectsOversizedPayload(t *testing.T) { + _, err := encryptConnectionPackage(connectionPackagePayload{ + Connections: []connectionPackageItem{ + { + ID: "conn-large", + Name: strings.Repeat("x", connectionPackageMaxCiphertextBytes), + Config: connection.ConnectionConfig{ + ID: "conn-large", + Type: "postgres", + Host: "db.large.local", + Port: 5432, + User: "postgres", + }, + }, + }, + }, "correct-password") + if !errors.Is(err, errConnectionPackagePayloadTooLarge) { + t.Fatalf("oversized export payload should return errConnectionPackagePayloadTooLarge, got: %v", err) + } +} diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go index 4cd47e3..3fc8e31 100644 --- a/internal/app/connection_package_transfer.go +++ b/internal/app/connection_package_transfer.go @@ -9,6 +9,8 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/secretstore" + + "github.com/google/uuid" ) func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle) connectionPackageItem { @@ -86,25 +88,99 @@ func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connecti } } -func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ([]connection.SavedConnectionView, error) { +func dedupeImportedSavedConnectionViews(views []connection.SavedConnectionView) []connection.SavedConnectionView { + if len(views) < 2 { + return views + } + + lastIndexByID := make(map[string]int, len(views)) + for index, view := range views { + id := strings.TrimSpace(view.ID) + if id == "" { + continue + } + lastIndexByID[id] = index + } + + result := make([]connection.SavedConnectionView, 0, len(views)) + for index, view := range views { + id := strings.TrimSpace(view.ID) + if id != "" && lastIndexByID[id] != index { + continue + } + result = append(result, view) + } + return result +} + +func dedupeImportedSavedConnectionInputs(inputs []connection.SavedConnectionInput) []connection.SavedConnectionInput { + if len(inputs) < 2 { + return inputs + } + + lastIndexByID := make(map[string]int, len(inputs)) + for index, input := range inputs { + id := strings.TrimSpace(input.ID) + if id == "" { + continue + } + lastIndexByID[id] = index + } + + result := make([]connection.SavedConnectionInput, 0, len(inputs)) + for index, input := range inputs { + id := strings.TrimSpace(input.ID) + if id != "" && lastIndexByID[id] != index { + continue + } + result = append(result, input) + } + return result +} + +func normalizeImportedSavedConnectionInput(input connection.SavedConnectionInput) connection.SavedConnectionInput { + if strings.TrimSpace(input.ID) == "" && strings.TrimSpace(input.Config.ID) == "" { + input.ID = "conn-" + uuid.New().String()[:8] + } + if strings.TrimSpace(input.ID) == "" { + input.ID = strings.TrimSpace(input.Config.ID) + } + input.Config.ID = input.ID + return input +} + +func (a *App) importSavedConnectionsAtomically(inputs []connection.SavedConnectionInput) ([]connection.SavedConnectionView, error) { repo := a.savedConnectionRepository() - rollbackSnapshot, err := captureConnectionPackageImportRollbackSnapshot(a, payload) + normalizedInputs := make([]connection.SavedConnectionInput, 0, len(inputs)) + for _, input := range inputs { + normalizedInputs = append(normalizedInputs, normalizeImportedSavedConnectionInput(input)) + } + finalInputs := dedupeImportedSavedConnectionInputs(normalizedInputs) + rollbackSnapshot, err := captureConnectionImportRollbackSnapshot(a, finalInputs) 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)) + result := make([]connection.SavedConnectionView, 0, len(finalInputs)) + for _, input := range finalInputs { + view, err := repo.Save(input) 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, errors.Join(err, fmt.Errorf("restore connection import rollback: %w", rollbackErr)) } return nil, err } result = append(result, view) } - return result, nil + return dedupeImportedSavedConnectionViews(result), nil +} + +func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ([]connection.SavedConnectionView, error) { + inputs := make([]connection.SavedConnectionInput, 0, len(payload.Connections)) + for _, item := range payload.Connections { + inputs = append(inputs, newSavedConnectionInputFromPackageItem(item)) + } + return a.importSavedConnectionsAtomically(inputs) } func (a *App) ImportConnectionsPayload(raw string, password string) ([]connection.SavedConnectionView, error) { @@ -112,6 +188,9 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio if trimmed == "" { return nil, errConnectionPackageUnsupported } + if len(trimmed) > connectionImportMaxFileBytes { + return nil, errConnectionImportFileTooLarge + } if isConnectionPackageEnvelope(trimmed) { var file connectionPackageFile @@ -139,7 +218,7 @@ type connectionPackageImportRollbackSnapshot struct { connectionCleanupRefs []string } -func captureConnectionPackageImportRollbackSnapshot(a *App, payload connectionPackagePayload) (connectionPackageImportRollbackSnapshot, error) { +func captureConnectionImportRollbackSnapshot(a *App, inputs []connection.SavedConnectionInput) (connectionPackageImportRollbackSnapshot, error) { snapshot := connectionPackageImportRollbackSnapshot{ connectionSecrets: make(map[string]securityUpdateSecretSnapshot), } @@ -163,9 +242,11 @@ func captureConnectionPackageImportRollbackSnapshot(a *App, payload connectionPa cleanupSet := make(map[string]struct{}) seenIDs := make(map[string]struct{}) - for _, item := range payload.Connections { - input := newSavedConnectionInputFromPackageItem(item) + for _, input := range inputs { connectionID := strings.TrimSpace(input.ID) + if connectionID == "" { + connectionID = strings.TrimSpace(input.Config.ID) + } if connectionID == "" { continue } diff --git a/internal/app/connection_package_transfer_test.go b/internal/app/connection_package_transfer_test.go index a988266..81d40ea 100644 --- a/internal/app/connection_package_transfer_test.go +++ b/internal/app/connection_package_transfer_test.go @@ -173,7 +173,7 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() - _, err := app.importConnectionPackagePayload(connectionPackagePayload{ + imported, err := app.importConnectionPackagePayload(connectionPackagePayload{ Connections: []connectionPackageItem{ { ID: "conn-dup", @@ -204,6 +204,12 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) { if err != nil { t.Fatalf("importConnectionPackagePayload returned error: %v", err) } + if len(imported) != 1 { + t.Fatalf("expected duplicate ids to return 1 final imported item, got %d", len(imported)) + } + if imported[0].Name != "Second" { + t.Fatalf("expected returned import result to keep latest entry, got %q", imported[0].Name) + } saved, err := app.GetSavedConnections() if err != nil { @@ -225,6 +231,153 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) { } } +func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T) { + failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-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: "legacy-1", + Name: "Existing Legacy", + Config: connection.ConnectionConfig{ + ID: "legacy-1", + Type: "postgres", + Host: "db.old.local", + Port: 5432, + User: "postgres", + Password: "old-primary", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + + raw, err := json.Marshal([]connection.LegacySavedConnection{ + { + ID: "legacy-1", + Name: "Imported Existing Legacy", + Config: connection.ConnectionConfig{ + ID: "legacy-1", + Type: "postgres", + Host: "db.new.local", + Port: 5432, + User: "postgres", + }, + }, + { + ID: "legacy-2", + Name: "Imported New Legacy", + Config: connection.ConnectionConfig{ + ID: "legacy-2", + Type: "mysql", + Host: "db.second.local", + Port: 3306, + User: "root", + Password: "second-primary", + }, + }, + }) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + + imported, err := app.ImportConnectionsPayload(string(raw), "ignored") + if err == nil { + t.Fatal("expected ImportConnectionsPayload 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 legacy connection, got %d", len(saved)) + } + if saved[0].ID != "legacy-1" || saved[0].Name != "Existing Legacy" { + t.Fatalf("expected rollback to restore original legacy metadata, got %#v", saved[0]) + } + if saved[0].Config.Host != "db.old.local" { + t.Fatalf("expected rollback to restore original legacy 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 legacy password, got %q", resolved.Password) + } + + if _, err := store.Get(failRef); !os.IsNotExist(err) { + t.Fatalf("expected rollback to remove partially imported legacy secret ref, got err=%v", err) + } +} + +func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T) { + failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2") + if err != nil { + t.Fatalf("BuildRef returned error: %v", err) + } + + store := newFailOnPutSecretStore(failRef) + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + imported, err := app.ImportLegacyConnections([]connection.LegacySavedConnection{ + { + Name: "Generated ID Legacy", + Config: connection.ConnectionConfig{ + Type: "postgres", + Host: "db.generated.local", + Port: 5432, + User: "postgres", + Password: "generated-secret", + }, + }, + { + ID: "legacy-2", + Name: "Will Fail", + Config: connection.ConnectionConfig{ + ID: "legacy-2", + Type: "mysql", + Host: "db.fail.local", + Port: 3306, + User: "root", + Password: "fail-secret", + }, + }, + }) + if err == nil { + t.Fatal("expected ImportLegacyConnections 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) != 0 { + t.Fatalf("expected rollback to remove generated-id connection, got %d saved connections", len(saved)) + } + + if got := len(store.base.items); got != 0 { + t.Fatalf("expected rollback to remove generated secret refs, got %d remaining items", got) + } + if _, err := store.Get(failRef); !os.IsNotExist(err) { + t.Fatalf("expected rollback to remove failed explicit secret ref, got err=%v", err) + } +} + func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) { failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "conn-2") if err != nil { @@ -313,7 +466,7 @@ func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) { } } -func TestImportConnectionsPayloadLegacyJSONKeepsExistingSecretWhenMissing(t *testing.T) { +func TestImportConnectionsPayloadLegacyJSONClearsExistingSecretWhenMissing(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() @@ -365,8 +518,162 @@ func TestImportConnectionsPayloadLegacyJSONKeepsExistingSecretWhenMissing(t *tes 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) + if resolved.Password != "" { + t.Fatalf("expected legacy import to clear existing secret when the imported file omits it, got %q", resolved.Password) + } +} + +func TestImportConnectionsPayloadLegacyJSONLatestEntryWinsForSameID(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + raw, err := json.Marshal([]connection.LegacySavedConnection{ + { + ID: "legacy-dup", + Name: "First", + Config: connection.ConnectionConfig{ + ID: "legacy-dup", + Type: "postgres", + Host: "db.first.local", + Port: 5432, + User: "postgres", + Password: "first-secret", + }, + }, + { + ID: "legacy-dup", + Name: "Second", + Config: connection.ConnectionConfig{ + ID: "legacy-dup", + Type: "postgres", + Host: "db.second.local", + Port: 5432, + User: "postgres", + Password: "second-secret", + }, + }, + }) + 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 duplicate legacy ids to return 1 final imported item, got %d", len(imported)) + } + if imported[0].Name != "Second" { + t.Fatalf("expected returned import result to keep latest legacy entry, got %q", imported[0].Name) + } + + saved, err := app.GetSavedConnections() + if err != nil { + t.Fatalf("GetSavedConnections returned error: %v", err) + } + if len(saved) != 1 { + t.Fatalf("expected 1 saved legacy item after duplicate id overwrite, got %d", len(saved)) + } + if saved[0].Name != "Second" { + t.Fatalf("expected latest legacy 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 legacy secret to win, got %q", resolved.Password) + } +} + +func TestImportConnectionsPayloadLegacyJSONLatestEntryWithoutPasswordDoesNotKeepEarlierDuplicateSecret(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + raw, err := json.Marshal([]connection.LegacySavedConnection{ + { + ID: "legacy-dup", + Name: "First", + Config: connection.ConnectionConfig{ + ID: "legacy-dup", + Type: "postgres", + Host: "db.first.local", + Port: 5432, + User: "postgres", + Password: "first-secret", + }, + }, + { + ID: "legacy-dup", + Name: "Second", + Config: connection.ConnectionConfig{ + ID: "legacy-dup", + Type: "postgres", + Host: "db.second.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 duplicate legacy ids to return 1 final imported item, got %d", len(imported)) + } + + saved, err := app.GetSavedConnections() + if err != nil { + t.Fatalf("GetSavedConnections returned error: %v", err) + } + if len(saved) != 1 { + t.Fatalf("expected 1 saved legacy item after duplicate id overwrite, got %d", len(saved)) + } + if saved[0].HasPrimaryPassword { + t.Fatalf("expected latest legacy item without password to clear earlier duplicate secret, got view=%#v", saved[0]) + } + + resolved, err := app.resolveConnectionSecrets(saved[0].Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets returned error: %v", err) + } + if resolved.Password != "" { + t.Fatalf("expected latest legacy item without password to keep empty secret, got %q", resolved.Password) + } +} + +func TestImportConnectionsPayloadEnvelopeRejectsOversizedPayloadWithDedicatedError(t *testing.T) { + raw, err := json.Marshal(connectionPackageFile{ + SchemaVersion: connectionPackageSchemaVersion, + Kind: connectionPackageKind, + Cipher: connectionPackageCipher, + KDF: connectionPackageKDFSpec{ + Name: connectionPackageKDFName, + MemoryKiB: connectionPackageKDFDefaultMemoryKiB, + TimeCost: connectionPackageKDFDefaultTimeCost, + Parallelism: connectionPackageKDFDefaultParallelism, + Salt: "AAAAAAAAAAAAAAAAAAAAAA==", + }, + Nonce: "AAAAAAAAAAAAAAAA", + Payload: strings.Repeat("A", connectionPackageMaxPayloadBase64Bytes+4), + }) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + _, err = app.ImportConnectionsPayload(string(raw), "package-password") + if !errors.Is(err, errConnectionPackagePayloadTooLarge) { + t.Fatalf("expected errConnectionPackagePayloadTooLarge, got %v", err) } } diff --git a/internal/app/connection_package_types.go b/internal/app/connection_package_types.go index df959c9..18b28b6 100644 --- a/internal/app/connection_package_types.go +++ b/internal/app/connection_package_types.go @@ -21,12 +21,18 @@ const ( connectionPackageKDFMaxMemoryKiB = 262144 connectionPackageKDFMaxTimeCost = 10 connectionPackageKDFMaxParallelism = 16 + + connectionPackageMaxCiphertextBytes = 16 * 1024 * 1024 + connectionPackageMaxPayloadBase64Bytes = ((connectionPackageMaxCiphertextBytes + 2) / 3) * 4 + connectionImportMaxFileBytes = connectionPackageMaxPayloadBase64Bytes + (1 * 1024 * 1024) ) var ( errConnectionPackagePasswordRequired = errors.New("恢复包密码不能为空") errConnectionPackageDecryptFailed = errors.New("文件密码错误或文件已损坏") errConnectionPackageUnsupported = errors.New("不支持的连接恢复包格式") + errConnectionImportFileTooLarge = errors.New("连接导入文件过大") + errConnectionPackagePayloadTooLarge = errors.New("连接恢复包过大") errConnectionPackageNotImplemented = errors.New("connection package not implemented") ) diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 64e7aef..924730a 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -259,6 +259,22 @@ func (cr *countingReader) Read(p []byte) (int, error) { return n, err } +func readImportedConnectionConfigFile(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Size() > connectionImportMaxFileBytes { + return "", errConnectionImportFileTooLarge + } + + content, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(content), nil +} + func (a *App) ImportConfigFile() connection.QueryResult { selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Select Config File", @@ -282,12 +298,12 @@ func (a *App) ImportConfigFile() connection.QueryResult { return connection.QueryResult{Success: false, Message: "已取消"} } - content, err := os.ReadFile(selection) + content, err := readImportedConnectionConfigFile(selection) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: string(content)} + return connection.QueryResult{Success: true, Data: content} } func (a *App) ExportConnectionsPackage(password string) connection.QueryResult { @@ -320,6 +336,9 @@ func (a *App) ExportConnectionsPackage(password string) connection.QueryResult { if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } + if len(content) > connectionImportMaxFileBytes { + return connection.QueryResult{Success: false, Message: errConnectionImportFileTooLarge.Error()} + } if err := os.WriteFile(filename, content, 0o644); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } diff --git a/internal/app/methods_file_import_test.go b/internal/app/methods_file_import_test.go new file mode 100644 index 0000000..d2b13bc --- /dev/null +++ b/internal/app/methods_file_import_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestReadImportedConnectionConfigFileRejectsOversizedFiles(t *testing.T) { + for _, ext := range []string{connectionPackageExtension, ".json"} { + t.Run(ext, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "connections"+ext) + + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + if err := file.Truncate(connectionImportMaxFileBytes + 1); err != nil { + file.Close() + t.Fatalf("Truncate returned error: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + _, err = readImportedConnectionConfigFile(path) + if !errors.Is(err, errConnectionImportFileTooLarge) { + t.Fatalf("oversized import file should return errConnectionImportFileTooLarge, got: %v", err) + } + }) + } +} diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 8b4a0b0..08f6c22 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -19,12 +19,20 @@ import ( var ( redisCache = make(map[string]redis.RedisClient) redisCacheMu sync.Mutex + newRedisClientFunc = redis.NewRedisClient ) // getRedisClient gets or creates a Redis client from cache func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) { - effectiveConfig := applyGlobalProxyToConnection(config) - connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig) + resolvedConfig, err := a.resolveConnectionSecrets(config) + if err != nil { + wrapped := wrapConnectError(config, err) + logger.Error(wrapped, "Redis 密文解析失败:%s", formatRedisConnSummary(config)) + return nil, wrapped + } + + effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) + connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) if proxyErr != nil { wrapped := wrapConnectError(effectiveConfig, proxyErr) logger.Error(wrapped, "Redis 代理准备失败:%s", formatRedisConnSummary(effectiveConfig)) @@ -54,7 +62,7 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli } logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey) - client := redis.NewRedisClient() + client := newRedisClientFunc() if err := client.Connect(connectConfig); err != nil { wrapped := wrapConnectError(effectiveConfig, err) logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey) diff --git a/internal/app/methods_redis_test.go b/internal/app/methods_redis_test.go new file mode 100644 index 0000000..f713cad --- /dev/null +++ b/internal/app/methods_redis_test.go @@ -0,0 +1,258 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" + redislib "GoNavi-Wails/internal/redis" +) + +type capturingRedisClient struct { + connectConfig connection.ConnectionConfig +} + +func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error { + c.connectConfig = config + return nil +} + +func (c *capturingRedisClient) Close() error { return nil } + +func (c *capturingRedisClient) Ping() error { return nil } + +func (c *capturingRedisClient) ScanKeys(pattern string, cursor uint64, count int64) (*redislib.RedisScanResult, error) { + return &redislib.RedisScanResult{}, nil +} + +func (c *capturingRedisClient) GetKeyType(key string) (string, error) { return "", nil } + +func (c *capturingRedisClient) GetTTL(key string) (int64, error) { return 0, nil } + +func (c *capturingRedisClient) SetTTL(key string, ttl int64) error { return nil } + +func (c *capturingRedisClient) DeleteKeys(keys []string) (int64, error) { return 0, nil } + +func (c *capturingRedisClient) RenameKey(oldKey, newKey string) error { return nil } + +func (c *capturingRedisClient) KeyExists(key string) (bool, error) { return false, nil } + +func (c *capturingRedisClient) GetValue(key string) (*redislib.RedisValue, error) { + return &redislib.RedisValue{}, nil +} + +func (c *capturingRedisClient) GetString(key string) (string, error) { return "", nil } + +func (c *capturingRedisClient) SetString(key, value string, ttl int64) error { return nil } + +func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) { return map[string]string{}, nil } + +func (c *capturingRedisClient) SetHashField(key, field, value string) error { return nil } + +func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error { return nil } + +func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) { return nil, nil } + +func (c *capturingRedisClient) ListPush(key string, values ...string) error { return nil } + +func (c *capturingRedisClient) ListSet(key string, index int64, value string) error { return nil } + +func (c *capturingRedisClient) GetSet(key string) ([]string, error) { return nil, nil } + +func (c *capturingRedisClient) SetAdd(key string, members ...string) error { return nil } + +func (c *capturingRedisClient) SetRemove(key string, members ...string) error { return nil } + +func (c *capturingRedisClient) GetZSet(key string, start, stop int64) ([]redislib.ZSetMember, error) { + return nil, nil +} + +func (c *capturingRedisClient) ZSetAdd(key string, members ...redislib.ZSetMember) error { return nil } + +func (c *capturingRedisClient) ZSetRemove(key string, members ...string) error { return nil } + +func (c *capturingRedisClient) GetStream(key, start, stop string, count int64) ([]redislib.StreamEntry, error) { + return nil, nil +} + +func (c *capturingRedisClient) StreamAdd(key string, fields map[string]string, id string) (string, error) { + return "", nil +} + +func (c *capturingRedisClient) StreamDelete(key string, ids ...string) (int64, error) { return 0, nil } + +func (c *capturingRedisClient) ExecuteCommand(args []string) (interface{}, error) { return nil, nil } + +func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) { return map[string]string{}, nil } + +func (c *capturingRedisClient) GetDatabases() ([]redislib.RedisDBInfo, error) { return nil, nil } + +func (c *capturingRedisClient) SelectDB(index int) error { return nil } + +func (c *capturingRedisClient) GetCurrentDB() int { return 0 } + +func (c *capturingRedisClient) FlushDB() error { return nil } + +func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) { + testCases := []struct { + name string + savedConfig connection.ConnectionConfig + runtimeConfig connection.ConnectionConfig + assertResolved func(t *testing.T, got connection.ConnectionConfig) + }{ + { + name: "redis and ssh secrets", + savedConfig: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "redis.local", + Port: 6379, + Password: "redis-secret", + UseSSH: true, + SSH: connection.SSHConfig{ + Host: "ssh.local", + Port: 22, + User: "ops", + Password: "ssh-secret", + }, + }, + runtimeConfig: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "redis.local", + Port: 6379, + UseSSH: true, + SSH: connection.SSHConfig{ + Host: "ssh.local", + Port: 22, + User: "ops", + }, + }, + assertResolved: func(t *testing.T, got connection.ConnectionConfig) { + t.Helper() + if got.Password != "redis-secret" { + t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password) + } + if got.SSH.Password != "ssh-secret" { + t.Fatalf("expected RedisConnect to resolve saved SSH password, got %q", got.SSH.Password) + } + }, + }, + { + name: "proxy secret", + savedConfig: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "redis.local", + Port: 6379, + Password: "redis-secret", + UseProxy: true, + Proxy: connection.ProxyConfig{ + Type: "http", + Host: "proxy.local", + Port: 8080, + User: "proxy-user", + Password: "proxy-secret", + }, + }, + runtimeConfig: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "redis.local", + Port: 6379, + UseProxy: true, + Proxy: connection.ProxyConfig{ + Type: "http", + Host: "proxy.local", + Port: 8080, + User: "proxy-user", + }, + }, + assertResolved: func(t *testing.T, got connection.ConnectionConfig) { + t.Helper() + if got.Password != "redis-secret" { + t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password) + } + if got.Proxy.Password != "proxy-secret" { + t.Fatalf("expected RedisConnect to resolve saved proxy password, got %q", got.Proxy.Password) + } + }, + }, + { + name: "http tunnel secret", + savedConfig: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "redis.local", + Port: 6379, + Password: "redis-secret", + UseHTTPTunnel: true, + HTTPTunnel: connection.HTTPTunnelConfig{ + Host: "tunnel.local", + Port: 8443, + User: "tunnel-user", + Password: "tunnel-secret", + }, + }, + runtimeConfig: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "redis.local", + Port: 6379, + UseHTTPTunnel: true, + HTTPTunnel: connection.HTTPTunnelConfig{ + Host: "tunnel.local", + Port: 8443, + User: "tunnel-user", + }, + }, + assertResolved: func(t *testing.T, got connection.ConnectionConfig) { + t.Helper() + if got.Password != "redis-secret" { + t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password) + } + if got.HTTPTunnel.Password != "tunnel-secret" { + t.Fatalf("expected RedisConnect to resolve saved HTTP tunnel password, got %q", got.HTTPTunnel.Password) + } + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "redis-1", + Name: "Redis Saved", + Config: testCase.savedConfig, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + + CloseAllRedisClients() + client := &capturingRedisClient{} + originalNewRedisClientFunc := newRedisClientFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newRedisClientFunc = originalNewRedisClientFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + CloseAllRedisClients() + }() + newRedisClientFunc = func() redislib.RedisClient { + return client + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + result := app.RedisConnect(testCase.runtimeConfig) + if !result.Success { + t.Fatalf("RedisConnect returned failure: %+v", result) + } + + testCase.assertResolved(t, client.connectConfig) + }) + } +} diff --git a/internal/app/methods_saved_connections.go b/internal/app/methods_saved_connections.go index d8d916d..fc2351e 100644 --- a/internal/app/methods_saved_connections.go +++ b/internal/app/methods_saved_connections.go @@ -1,6 +1,10 @@ package app -import "GoNavi-Wails/internal/connection" +import ( + "strings" + + "GoNavi-Wails/internal/connection" +) func (a *App) savedConnectionRepository() *savedConnectionRepository { return newSavedConnectionRepository(a.configDir, a.secretStore) @@ -23,16 +27,20 @@ func (a *App) DuplicateConnection(id string) (connection.SavedConnectionView, er } func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) { - result := make([]connection.SavedConnectionView, 0, len(items)) - repo := a.savedConnectionRepository() + inputs := make([]connection.SavedConnectionInput, 0, len(items)) for _, item := range items { - view, err := repo.Save(connection.SavedConnectionInput(item)) - if err != nil { - return nil, err - } - result = append(result, view) + input := connection.SavedConnectionInput(item) + input.ClearPrimaryPassword = strings.TrimSpace(item.Config.Password) == "" + input.ClearSSHPassword = strings.TrimSpace(item.Config.SSH.Password) == "" + input.ClearProxyPassword = strings.TrimSpace(item.Config.Proxy.Password) == "" + input.ClearHTTPTunnelPassword = strings.TrimSpace(item.Config.HTTPTunnel.Password) == "" + input.ClearMySQLReplicaPassword = strings.TrimSpace(item.Config.MySQLReplicaPassword) == "" + input.ClearMongoReplicaPassword = strings.TrimSpace(item.Config.MongoReplicaPassword) == "" + input.ClearOpaqueURI = strings.TrimSpace(item.Config.URI) == "" + input.ClearOpaqueDSN = strings.TrimSpace(item.Config.DSN) == "" + inputs = append(inputs, input) } - return result, nil + return a.importSavedConnectionsAtomically(inputs) } func (a *App) SaveGlobalProxy(input connection.SaveGlobalProxyInput) (connection.GlobalProxyView, error) { diff --git a/internal/app/methods_saved_connections_test.go b/internal/app/methods_saved_connections_test.go index a813c53..dc8b63c 100644 --- a/internal/app/methods_saved_connections_test.go +++ b/internal/app/methods_saved_connections_test.go @@ -219,7 +219,7 @@ func TestImportLegacyConnectionsIsIdempotentForSameID(t *testing.T) { } } -func TestImportLegacyConnectionsKeepsExistingSecretWhenReimportOmitsPassword(t *testing.T) { +func TestImportLegacyConnectionsClearsExistingSecretWhenReimportOmitsPassword(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() @@ -267,7 +267,7 @@ func TestImportLegacyConnectionsKeepsExistingSecretWhenReimportOmitsPassword(t * 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) + if resolved.Password != "" { + t.Fatalf("expected missing import password to clear existing secret, got %q", resolved.Password) } } diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 240f446..43f2d90 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -30,6 +30,12 @@ const ( updateDownloadProgressEvent = "update:download-progress" ) +var ( + updateFetchLatestRelease = fetchLatestRelease + updateFetchReleaseSHA256 = fetchReleaseSHA256 + updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } +) + type updateState struct { lastCheck *UpdateInfo downloading bool @@ -100,9 +106,19 @@ type githubAsset struct { } func (a *App) CheckForUpdates() connection.QueryResult { + return a.checkForUpdates(true) +} + +func (a *App) CheckForUpdatesSilently() connection.QueryResult { + return a.checkForUpdates(false) +} + +func (a *App) checkForUpdates(logFailure bool) connection.QueryResult { info, err := fetchLatestUpdateInfo() if err != nil { - logger.Error(err, "检查更新失败") + if logFailure { + updateLogCheckError(err) + } return connection.QueryResult{Success: false, Message: err.Error()} } @@ -359,7 +375,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { } func fetchLatestUpdateInfo() (UpdateInfo, error) { - release, err := fetchLatestRelease() + release, err := updateFetchLatestRelease() if err != nil { return UpdateInfo{}, err } @@ -370,6 +386,17 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) { return UpdateInfo{}, errors.New("无法解析最新版本号") } + hasUpdate := compareVersion(currentVersion, latestVersion) < 0 + if !hasUpdate { + return UpdateInfo{ + HasUpdate: false, + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + ReleaseName: release.Name, + ReleaseNotesURL: release.HTMLURL, + }, nil + } + assetVersion := strings.TrimSpace(release.TagName) if assetVersion == "" { assetVersion = latestVersion @@ -383,7 +410,7 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) { return UpdateInfo{}, err } - hashMap, err := fetchReleaseSHA256(release.Assets) + hashMap, err := updateFetchReleaseSHA256(release.Assets) if err != nil { return UpdateInfo{}, err } @@ -391,9 +418,6 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) { if sha256Value == "" { return UpdateInfo{}, errors.New("SHA256SUMS 未包含当前平台更新包") } - - hasUpdate := compareVersion(currentVersion, latestVersion) < 0 - return UpdateInfo{ HasUpdate: hasUpdate, CurrentVersion: currentVersion, @@ -407,6 +431,30 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) { }, nil } +func swapUpdateFetchLatestRelease(next func() (*githubRelease, error)) func() { + original := updateFetchLatestRelease + updateFetchLatestRelease = next + return func() { + updateFetchLatestRelease = original + } +} + +func swapUpdateFetchReleaseSHA256(next func([]githubAsset) (map[string]string, error)) func() { + original := updateFetchReleaseSHA256 + updateFetchReleaseSHA256 = next + return func() { + updateFetchReleaseSHA256 = original + } +} + +func swapUpdateCheckErrorLogger(next func(error)) func() { + original := updateLogCheckError + updateLogCheckError = next + return func() { + updateLogCheckError = original + } +} + func getCurrentAuthor() string { if env := strings.TrimSpace(os.Getenv("GONAVI_AUTHOR")); env != "" { return env diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go new file mode 100644 index 0000000..ed1beaf --- /dev/null +++ b/internal/app/methods_update_test.go @@ -0,0 +1,160 @@ +package app + +import ( + "errors" + stdRuntime "runtime" + "testing" +) + +func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *testing.T) { + assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5") + if err != nil { + t.Fatalf("expectedAssetName returned error: %v", err) + } + + originalVersion := AppVersion + AppVersion = "0.6.5" + defer func() { + AppVersion = originalVersion + }() + + releaseCalled := false + restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + releaseCalled = true + return &githubRelease{ + TagName: "v0.6.5", + Name: "v0.6.5", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v0.6.5", + Assets: []githubAsset{ + { + Name: assetName, + BrowserDownloadURL: "https://example.com/" + assetName, + Size: 1024, + }, + }, + }, nil + }) + defer restoreRelease() + + checksumCalled := false + restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) { + checksumCalled = true + return nil, errors.New("checksum should not be fetched when no update is needed") + }) + defer restoreChecksum() + + info, err := fetchLatestUpdateInfo() + if err != nil { + t.Fatalf("fetchLatestUpdateInfo returned error: %v", err) + } + if !releaseCalled { + t.Fatal("expected latest release metadata to be fetched") + } + if checksumCalled { + t.Fatal("expected SHA256SUMS fetch to be skipped when current version is already latest") + } + if info.HasUpdate { + t.Fatalf("expected HasUpdate=false, got %#v", info) + } + if info.LatestVersion != "0.6.5" || info.CurrentVersion != "0.6.5" { + t.Fatalf("unexpected version info: %#v", info) + } +} + +func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T) { + assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5") + if err != nil { + t.Fatalf("expectedAssetName returned error: %v", err) + } + + originalVersion := AppVersion + AppVersion = "0.6.4" + defer func() { + AppVersion = originalVersion + }() + + restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + return &githubRelease{ + TagName: "v0.6.5", + Name: "v0.6.5", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v0.6.5", + Assets: []githubAsset{ + { + Name: assetName, + BrowserDownloadURL: "https://example.com/" + assetName, + Size: 4096, + }, + }, + }, nil + }) + defer restoreRelease() + + checksumCalled := false + restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) { + checksumCalled = true + return map[string]string{ + assetName: "abc123", + }, nil + }) + defer restoreChecksum() + + info, err := fetchLatestUpdateInfo() + if err != nil { + t.Fatalf("fetchLatestUpdateInfo returned error: %v", err) + } + if !checksumCalled { + t.Fatal("expected SHA256SUMS fetch when update is available") + } + if !info.HasUpdate { + t.Fatalf("expected HasUpdate=true, got %#v", info) + } + if info.SHA256 != "abc123" || info.AssetName != assetName { + t.Fatalf("unexpected update info: %#v", info) + } +} + +func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) { + app := &App{} + + restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + return nil, errors.New("request timed out") + }) + defer restoreRelease() + + logged := 0 + restoreLogger := swapUpdateCheckErrorLogger(func(error) { + logged++ + }) + defer restoreLogger() + + result := app.CheckForUpdates() + if result.Success { + t.Fatalf("expected failure result, got %#v", result) + } + if logged != 1 { + t.Fatalf("expected manual check to log once, got %d", logged) + } +} + +func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) { + app := &App{} + + restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + return nil, errors.New("request timed out") + }) + defer restoreRelease() + + logged := 0 + restoreLogger := swapUpdateCheckErrorLogger(func(error) { + logged++ + }) + defer restoreLogger() + + result := app.CheckForUpdatesSilently() + if result.Success { + t.Fatalf("expected failure result, got %#v", result) + } + if logged != 0 { + t.Fatalf("expected silent check to skip error logging, got %d", logged) + } +} diff --git a/internal/secretstore/keyring_store.go b/internal/secretstore/keyring_store.go index 93fe0bc..e1d4e1e 100644 --- a/internal/secretstore/keyring_store.go +++ b/internal/secretstore/keyring_store.go @@ -3,7 +3,10 @@ package secretstore import ( "errors" "fmt" + "os" "runtime" + "strings" + "syscall" "github.com/99designs/keyring" ) @@ -56,19 +59,32 @@ func (s *keyringStore) Delete(ref string) error { func (s *keyringStore) HealthCheck() error { _, err := s.ring.Get(healthCheckRef) - if err == nil || errors.Is(err, keyring.ErrKeyNotFound) { + if err == nil || isKeyringSecretNotFound(err) { return nil } return wrapKeyringError(err) } func wrapKeyringError(err error) error { - if err == nil || errors.Is(err, keyring.ErrKeyNotFound) || IsUnavailable(err) { + if err == nil || IsUnavailable(err) { return err } + if isKeyringSecretNotFound(err) { + return os.ErrNotExist + } return &UnavailableError{Reason: err.Error()} } +func isKeyringSecretNotFound(err error) bool { + if err == nil { + return false + } + if errors.Is(err, keyring.ErrKeyNotFound) || errors.Is(err, syscall.Errno(1168)) { + return true + } + return strings.EqualFold(strings.TrimSpace(err.Error()), keyring.ErrKeyNotFound.Error()) +} + func keyringConfigFor(goos string) (keyring.Config, error) { backends := allowedBackendsFor(goos) if len(backends) == 0 { diff --git a/internal/secretstore/keyring_store_test.go b/internal/secretstore/keyring_store_test.go index 03fc49f..440b5d6 100644 --- a/internal/secretstore/keyring_store_test.go +++ b/internal/secretstore/keyring_store_test.go @@ -2,6 +2,9 @@ package secretstore import ( "errors" + "fmt" + "os" + "syscall" "testing" "github.com/99designs/keyring" @@ -58,6 +61,33 @@ func TestKeyringStoreHealthCheckTreatsMissingProbeItemAsHealthy(t *testing.T) { } } +func TestKeyringStoreHealthCheckTreatsWinCredNotFoundMessageAsHealthy(t *testing.T) { + t.Parallel() + + store := &keyringStore{ring: fakeKeyringClient{getErr: errors.New("The specified item could not be found in the keyring")}} + if err := store.HealthCheck(); err != nil { + t.Fatalf("HealthCheck should accept WinCred not-found errors, got %v", err) + } +} + +func TestKeyringStoreHealthCheckDoesNotTreatWrappedOsErrNotExistAsHealthy(t *testing.T) { + t.Parallel() + + store := &keyringStore{ring: fakeKeyringClient{getErr: fmt.Errorf("backend unavailable: %w", os.ErrNotExist)}} + if err := store.HealthCheck(); err == nil { + t.Fatal("HealthCheck should not accept unrelated wrapped os.ErrNotExist errors as healthy") + } +} + +func TestKeyringStoreHealthCheckDoesNotTreatPlainOsErrNotExistAsHealthy(t *testing.T) { + t.Parallel() + + store := &keyringStore{ring: fakeKeyringClient{getErr: os.ErrNotExist}} + if err := store.HealthCheck(); err == nil { + t.Fatal("HealthCheck should not accept plain os.ErrNotExist errors as healthy") + } +} + func TestKeyringStoreHealthCheckReturnsUnavailableErrorOnBackendFailure(t *testing.T) { t.Parallel() @@ -82,6 +112,67 @@ func TestNewKeyringStoreReturnsUnavailableStoreWhenOpenFails(t *testing.T) { } } +func TestWrapKeyringErrorNormalizesWinCredNotFoundMessage(t *testing.T) { + t.Parallel() + + err := wrapKeyringError(errors.New("The specified item could not be found in the keyring")) + if err == nil { + t.Fatal("wrapKeyringError should preserve missing-secret semantics") + } + if !os.IsNotExist(err) { + t.Fatalf("wrapKeyringError should map WinCred not-found errors to os.ErrNotExist, got %v", err) + } + if IsUnavailable(err) { + t.Fatalf("wrapKeyringError should not treat WinCred not-found errors as unavailable, got %v", err) + } +} + +func TestWrapKeyringErrorNormalizesWrappedKeyringErrKeyNotFound(t *testing.T) { + t.Parallel() + + err := wrapKeyringError(fmt.Errorf("wrapped: %w", keyring.ErrKeyNotFound)) + if err == nil { + t.Fatal("wrapKeyringError should preserve wrapped missing-secret semantics") + } + if !os.IsNotExist(err) { + t.Fatalf("wrapKeyringError should map wrapped ErrKeyNotFound to os.ErrNotExist, got %v", err) + } + if IsUnavailable(err) { + t.Fatalf("wrapKeyringError should not treat wrapped ErrKeyNotFound as unavailable, got %v", err) + } +} + +func TestWrapKeyringErrorNormalizesWinCredErrno1168(t *testing.T) { + t.Parallel() + + err := wrapKeyringError(syscall.Errno(1168)) + if err == nil { + t.Fatal("wrapKeyringError should preserve WinCred errno missing-secret semantics") + } + if !os.IsNotExist(err) { + t.Fatalf("wrapKeyringError should map WinCred errno to os.ErrNotExist, got %v", err) + } + if IsUnavailable(err) { + t.Fatalf("wrapKeyringError should not treat WinCred errno as unavailable, got %v", err) + } +} + +func TestWrapKeyringErrorDoesNotSwallowUnrelatedElementNotFoundMessages(t *testing.T) { + t.Parallel() + + backendErr := errors.New("database element not found while enumerating providers") + err := wrapKeyringError(backendErr) + if err == nil { + t.Fatal("wrapKeyringError should preserve backend failures") + } + if os.IsNotExist(err) { + t.Fatalf("wrapKeyringError should not map unrelated element-not-found errors to os.ErrNotExist, got %v", err) + } + if !IsUnavailable(err) { + t.Fatalf("wrapKeyringError should keep unrelated backend failures unavailable, got %v", err) + } +} + type fakeKeyringClient struct { getErr error item keyring.Item