mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
🐛 fix(security): 完善密文升级导入覆盖与安全更新链路
- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理 - 修复安全更新详情高亮反馈与相关前后端链路 - 补强 keyring 误判边界与安全更新回归测试
This commit is contained in:
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1 +1 @@
|
||||
20168ff7047e0ecea00acb73f413f7db
|
||||
8cc5d6401a6ce7dd0f500c66ce8bb4a9
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>,
|
||||
]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '已取消' }),
|
||||
|
||||
@@ -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: '导出失败',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
88
frontend/src/utils/securityUpdateVisuals.test.ts
Normal file
88
frontend/src/utils/securityUpdateVisuals.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
65
frontend/src/utils/securityUpdateVisuals.ts
Normal file
65
frontend/src/utils/securityUpdateVisuals.ts
Normal 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',
|
||||
});
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
33
internal/app/methods_file_import_test.go
Normal file
33
internal/app/methods_file_import_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
258
internal/app/methods_redis_test.go
Normal file
258
internal/app/methods_redis_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
160
internal/app/methods_update_test.go
Normal file
160
internal/app/methods_update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user