🐛 fix(security): 完善密文升级导入覆盖与安全更新链路

- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理

- 修复安全更新详情高亮反馈与相关前后端链路

- 补强 keyring 误判边界与安全更新回归测试
This commit is contained in:
tianqijiuyun-latiao
2026-04-11 16:53:03 +08:00
parent 070ff72ad8
commit 82e06bd94d
35 changed files with 2021 additions and 110 deletions

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "gonavi-client",
"private": true,
"version": "0.0.1",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1 +1 @@
20168ff7047e0ecea00acb73f413f7db
8cc5d6401a6ce7dd0f500c66ce8bb4a9

View File

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

View File

@@ -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<SecurityUpdateSettingsFocusTarget | null>(null);
const [securityUpdateSettingsFocusRequest, setSecurityUpdateSettingsFocusRequest] = useState(0);
const [isSecurityUpdateProgressOpen, setIsSecurityUpdateProgressOpen] = useState(false);
const [securityUpdateProgressStage, setSecurityUpdateProgressStage] = useState('正在检查已保存配置');
const [securityUpdateRepairSource, setSecurityUpdateRepairSource] = useState<SecurityUpdateRepairSource | null>(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="拖动调整宽度"
/>
</Sider>
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<Content style={{ background: bgContent, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
{securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && (
<SecurityUpdateBanner
status={securityUpdateStatus}
@@ -2331,7 +2358,9 @@ function App() {
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
onOpenDetails={handleOpenSecurityUpdateSettings}
onOpenDetails={() => 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()}
/>
<SecurityUpdateSettingsModal
open={isSecurityUpdateSettingsOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
status={securityUpdateStatus}
focusTarget={securityUpdateSettingsFocusTarget}
focusRequest={securityUpdateSettingsFocusRequest}
onClose={() => setIsSecurityUpdateSettingsOpen(false)}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}

View File

@@ -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 (
<div
className={SECURITY_UPDATE_BANNER_CLASS}
style={{
margin: '12px 12px 0',
padding: '14px 16px',
borderRadius: 16,
border: overlayTheme.sectionBorder,
background: darkMode
? 'linear-gradient(135deg, rgba(255,214,102,0.08) 0%, rgba(255,255,255,0.03) 100%)'
: 'linear-gradient(135deg, rgba(255,235,170,0.72) 0%, rgba(255,255,255,0.92) 100%)',
...getSecurityUpdateBannerSurfaceStyle(overlayTheme),
display: 'flex',
alignItems: 'center',
gap: 16,
overflow: 'hidden',
}}
>
<div
@@ -118,14 +124,25 @@ const SecurityUpdateBanner = ({
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{secondaryAction ? (
<Button onClick={secondaryAction.onClick}>
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
) : null}
<Button type="primary" onClick={primaryAction.onClick}>
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type="primary"
onClick={primaryAction.onClick}
>
{primaryAction.label}
</Button>
<Button type="text" icon={<CloseOutlined />} onClick={onDismiss} />
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
type="text"
icon={<CloseOutlined />}
onClick={onDismiss}
/>
</div>
</div>
);

View File

@@ -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 (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
title={(
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
@@ -77,13 +82,36 @@ const SecurityUpdateIntroModal = ({
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
footer={[
<Button key="details" type="primary" ghost style={actionButtonStyle} onClick={onViewDetails} disabled={loading}>
<Button
key="details"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
ghost
style={actionButtonStyle}
onClick={onViewDetails}
disabled={loading}
>
</Button>,
<Button key="later" type="primary" ghost style={actionButtonStyle} onClick={onPostpone} disabled={loading}>
<Button
key="later"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
ghost
style={actionButtonStyle}
onClick={onPostpone}
disabled={loading}
>
</Button>,
<Button key="start" type="primary" style={actionButtonStyle} loading={loading} onClick={onStart}>
<Button
key="start"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
style={actionButtonStyle}
loading={loading}
onClick={onStart}
>
</Button>,
]}

View File

@@ -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 (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
open={open}
closable={false}
maskClosable={false}

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Modal, Tag } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
@@ -9,13 +10,30 @@ import {
getSecurityUpdateStatusMeta,
sortSecurityUpdateIssues,
} from '../utils/securityUpdatePresentation';
import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateFocusState,
type SecurityUpdateFocusState,
type SecurityUpdateSettingsFocusTarget,
} from '../utils/securityUpdateRepairFlow';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_MODAL_CLASS,
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
SECURITY_UPDATE_RESULT_CARD_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateSectionSurfaceStyle,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateSettingsModalProps {
open: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
status: SecurityUpdateStatus;
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
focusRequest?: number;
onClose: () => 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<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
const statusSectionRef = useRef<HTMLDivElement | null>(null);
const recentResultRef = useRef<HTMLDivElement | null>(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 (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
title={(
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
@@ -80,39 +148,44 @@ const SecurityUpdateSettingsModal = ({
onCancel={onClose}
footer={[
showRetry ? (
<Button key="retry" onClick={onRetry}>
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
</Button>
) : null,
showRestart ? (
<Button key="restart" onClick={onRestart}>
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
</Button>
) : null,
showStart ? (
<Button key="start" type="primary" onClick={onStart}>
<Button
key="start"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type="primary"
onClick={onStart}
>
</Button>
) : null,
<Button key="close" onClick={onClose}>
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
</Button>,
]}
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 },
}}
>
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
<div style={sectionStyle(overlayTheme)}>
<div
ref={statusSectionRef}
tabIndex={-1}
style={sectionStyle(overlayTheme, { emphasized: activeFocus.target === 'status' })}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
@@ -153,8 +226,9 @@ const SecurityUpdateSettingsModal = ({
<div
key={item.label}
style={{
border: overlayTheme.sectionBorder,
borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.75)',
background: overlayTheme.sectionBg,
padding: '12px 10px',
}}
>
@@ -184,9 +258,8 @@ const SecurityUpdateSettingsModal = ({
<div
key={issue.id}
style={{
...getSecurityUpdateSectionSurfaceStyle(overlayTheme),
borderRadius: 12,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.78)',
padding: 14,
display: 'flex',
alignItems: 'flex-start',
@@ -211,6 +284,8 @@ const SecurityUpdateSettingsModal = ({
</div>
</div>
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
onClick={() => onIssueAction(issue)}
>
@@ -223,14 +298,24 @@ const SecurityUpdateSettingsModal = ({
)}
</div>
{status.backupPath ? (
<div style={sectionStyle(overlayTheme)}>
{showRecentResult ? (
<div
ref={recentResultRef}
tabIndex={-1}
className={[
SECURITY_UPDATE_RESULT_CARD_CLASS,
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
].filter(Boolean).join(' ')}
style={sectionStyle(overlayTheme, { emphasized: activeFocus.target === 'recent_result' })}
>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
</div>
{status.backupPath ? (
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
</div>
) : null}
{status.lastError ? (
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
{status.lastError}

View File

@@ -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: '已取消' }),

View File

@@ -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: '导出失败',
});
});
});

View File

@@ -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<string, unknown>;
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.');
};

View File

@@ -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',
}),
]);
});
});

View File

@@ -54,6 +54,10 @@ type StartSecurityUpdateResult = {
error: Error | null;
};
type MergeSecurityUpdateStatusOptions = {
previousStatus?: Partial<SecurityUpdateStatus> | null;
};
const defaultSummary = () => ({
total: 0,
updated: 0,
@@ -129,9 +133,56 @@ const mergeSecurityUpdateIssues = (
};
};
const isLocalLegacyIssue = (issue: Partial<SecurityUpdateIssue> | 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<SecurityUpdateStatus> | 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<SecurityUpdateStatus> | 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,

View File

@@ -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> = {}): 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);

View File

@@ -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<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): boolean => Boolean(status?.backupPath || status?.lastError);
export const resolveSecurityUpdateSettingsFocusTarget = (
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | 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<SecurityUpdateStatus, 'backupPath' | 'lastError'> | 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),
};
};

View File

@@ -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');
});
});

View File

@@ -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',
});

View File

@@ -15,6 +15,8 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
export function CheckForUpdatesSilently():Promise<connection.QueryResult>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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")
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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

View File

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

View File

@@ -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 {

View File

@@ -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