mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-25 16:04:02 +08:00
🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
||||
hasSecurityUpdateRecentResult,
|
||||
resolveSecurityUpdateRepairEntry,
|
||||
resolveSecurityUpdateSettingsFocusTarget,
|
||||
shouldRefreshSecurityUpdateDetailsFocus,
|
||||
shouldReopenSecurityUpdateDetails,
|
||||
shouldRetrySecurityUpdateAfterRepairSave,
|
||||
type SecurityUpdateRepairSource,
|
||||
@@ -276,6 +277,7 @@ function App() {
|
||||
status?: Partial<SecurityUpdateStatus> | null,
|
||||
options?: {
|
||||
openSettings?: boolean;
|
||||
refreshFocus?: boolean;
|
||||
resetBannerDismissed?: boolean;
|
||||
},
|
||||
) => {
|
||||
@@ -287,8 +289,10 @@ function App() {
|
||||
setIsSecurityUpdateBannerDismissed(false);
|
||||
}
|
||||
if (options?.openSettings) {
|
||||
setSecurityUpdateSettingsFocusTarget(resolveSecurityUpdateSettingsFocusTarget(nextStatus));
|
||||
setSecurityUpdateSettingsFocusRequest((current) => current + 1);
|
||||
if (options.refreshFocus !== false) {
|
||||
setSecurityUpdateSettingsFocusTarget(resolveSecurityUpdateSettingsFocusTarget(nextStatus));
|
||||
setSecurityUpdateSettingsFocusRequest((current) => current + 1);
|
||||
}
|
||||
setIsSecurityUpdateSettingsOpen(true);
|
||||
}
|
||||
return nextStatus;
|
||||
@@ -845,12 +849,16 @@ function App() {
|
||||
const stageText = mode === 'retry'
|
||||
? '正在校验更新结果'
|
||||
: '正在更新安全存储';
|
||||
const detailsWereOpen = isSecurityUpdateSettingsOpen;
|
||||
setSecurityUpdateProgressStage(stageText);
|
||||
setIsSecurityUpdateProgressOpen(true);
|
||||
setIsSecurityUpdateIntroOpen(false);
|
||||
setIsSecurityUpdateSettingsOpen(false);
|
||||
|
||||
let nextStatus: SecurityUpdateStatus | null = null;
|
||||
let shouldOpenSettings = false;
|
||||
let refreshSettingsFocus = false;
|
||||
try {
|
||||
let nextStatus: SecurityUpdateStatus | null = null;
|
||||
if (mode === 'start') {
|
||||
const result = await startSecurityUpdateFromBootstrap({
|
||||
backend: backendApp,
|
||||
@@ -891,29 +899,44 @@ function App() {
|
||||
}, nextStatus);
|
||||
}
|
||||
|
||||
const shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
|
||||
applySecurityUpdateStatus(nextStatus, {
|
||||
openSettings: shouldOpenSettings,
|
||||
shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
|
||||
refreshSettingsFocus = shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: shouldOpenSettings,
|
||||
wasOpen: detailsWereOpen,
|
||||
});
|
||||
|
||||
if (nextStatus.overallStatus === 'completed') {
|
||||
setSecurityUpdateHasLegacySensitiveItems(false);
|
||||
setSecurityUpdateRawPayload(null);
|
||||
setIsSecurityUpdateSettingsOpen(false);
|
||||
void message.success('已保存配置已完成安全更新');
|
||||
} else if (nextStatus.overallStatus === 'needs_attention') {
|
||||
void message.warning('更新尚未完成,有少量配置需要你处理');
|
||||
} else if (nextStatus.overallStatus === 'rolled_back') {
|
||||
void message.warning('本次更新未完成,系统已保留当前可用配置');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('Failed to execute security update round', err);
|
||||
void message.error(err?.message || '安全更新未完成,请稍后重试');
|
||||
} finally {
|
||||
setIsSecurityUpdateProgressOpen(false);
|
||||
if (detailsWereOpen) {
|
||||
setIsSecurityUpdateSettingsOpen(true);
|
||||
}
|
||||
void message.error(err?.message || '安全更新未完成,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextStatus) {
|
||||
setIsSecurityUpdateProgressOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsSecurityUpdateProgressOpen(false);
|
||||
applySecurityUpdateStatus(nextStatus, {
|
||||
openSettings: shouldOpenSettings,
|
||||
refreshFocus: refreshSettingsFocus,
|
||||
});
|
||||
|
||||
if (nextStatus.overallStatus === 'completed') {
|
||||
setSecurityUpdateHasLegacySensitiveItems(false);
|
||||
setSecurityUpdateRawPayload(null);
|
||||
setIsSecurityUpdateSettingsOpen(false);
|
||||
void message.success('已保存配置已完成安全更新');
|
||||
} else if (nextStatus.overallStatus === 'needs_attention') {
|
||||
void message.warning('更新尚未完成,有少量配置需要你处理');
|
||||
} else if (nextStatus.overallStatus === 'rolled_back') {
|
||||
void message.warning('本次更新未完成,系统已保留当前可用配置');
|
||||
}
|
||||
}, [
|
||||
applySecurityUpdateStatus,
|
||||
isSecurityUpdateSettingsOpen,
|
||||
normalizeSecurityUpdateStatus,
|
||||
replaceConnections,
|
||||
replaceGlobalProxy,
|
||||
@@ -2355,6 +2378,7 @@ function App() {
|
||||
status={securityUpdateStatus}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
surfaceOpacity={effectiveOpacity}
|
||||
onStart={handleStartSecurityUpdate}
|
||||
onRetry={handleRetrySecurityUpdate}
|
||||
onRestart={handleRestartSecurityUpdate}
|
||||
@@ -2501,6 +2525,7 @@ function App() {
|
||||
loading={isSecurityUpdateProgressOpen}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
surfaceOpacity={effectiveOpacity}
|
||||
onStart={handleStartSecurityUpdate}
|
||||
onPostpone={handlePostponeSecurityUpdate}
|
||||
onViewDetails={() => handleOpenSecurityUpdateSettings()}
|
||||
@@ -2509,6 +2534,7 @@ function App() {
|
||||
open={isSecurityUpdateSettingsOpen}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
surfaceOpacity={effectiveOpacity}
|
||||
status={securityUpdateStatus}
|
||||
focusTarget={securityUpdateSettingsFocusTarget}
|
||||
focusRequest={securityUpdateSettingsFocusRequest}
|
||||
@@ -2522,6 +2548,7 @@ function App() {
|
||||
open={isSecurityUpdateProgressOpen}
|
||||
stageText={securityUpdateProgressStage}
|
||||
overlayTheme={overlayTheme}
|
||||
surfaceOpacity={effectiveOpacity}
|
||||
/>
|
||||
<AISettingsModal
|
||||
open={isAISettingsOpen}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface SecurityUpdateBannerProps {
|
||||
status: SecurityUpdateStatus;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
@@ -74,6 +75,7 @@ const SecurityUpdateBanner = ({
|
||||
status,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
@@ -92,7 +94,7 @@ const SecurityUpdateBanner = ({
|
||||
margin: '12px 12px 0',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 16,
|
||||
...getSecurityUpdateBannerSurfaceStyle(overlayTheme),
|
||||
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateIntroModalProps {
|
||||
@@ -14,6 +15,7 @@ interface SecurityUpdateIntroModalProps {
|
||||
loading?: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onPostpone: () => void;
|
||||
onViewDetails: () => void;
|
||||
@@ -30,6 +32,7 @@ const SecurityUpdateIntroModal = ({
|
||||
loading = false,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onPostpone,
|
||||
onViewDetails,
|
||||
@@ -71,12 +74,7 @@ const SecurityUpdateIntroModal = ({
|
||||
onCancel={onPostpone}
|
||||
width={560}
|
||||
styles={{
|
||||
content: {
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
},
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
|
||||
@@ -2,13 +2,17 @@ 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';
|
||||
import {
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateProgressModalProps {
|
||||
open: boolean;
|
||||
stageText: string;
|
||||
detailText?: string;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
}
|
||||
|
||||
const SecurityUpdateProgressModal = ({
|
||||
@@ -16,6 +20,7 @@ const SecurityUpdateProgressModal = ({
|
||||
stageText,
|
||||
detailText,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
}: SecurityUpdateProgressModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -28,12 +33,7 @@ const SecurityUpdateProgressModal = ({
|
||||
width={420}
|
||||
centered
|
||||
styles={{
|
||||
content: {
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
},
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { display: 'none' },
|
||||
body: { padding: 28 },
|
||||
}}
|
||||
|
||||
@@ -31,6 +31,7 @@ interface SecurityUpdateSettingsModalProps {
|
||||
open: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
status: SecurityUpdateStatus;
|
||||
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
|
||||
focusRequest?: number;
|
||||
@@ -43,11 +44,15 @@ interface SecurityUpdateSettingsModalProps {
|
||||
|
||||
const sectionStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity: number,
|
||||
options?: { emphasized?: boolean },
|
||||
) => ({
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, options),
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
|
||||
...options,
|
||||
surfaceOpacity,
|
||||
}),
|
||||
});
|
||||
|
||||
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
|
||||
@@ -59,6 +64,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
open,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
status,
|
||||
focusTarget = null,
|
||||
focusRequest = 0,
|
||||
@@ -174,7 +180,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
]}
|
||||
width={760}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme),
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
@@ -184,7 +190,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
<div
|
||||
ref={statusSectionRef}
|
||||
tabIndex={-1}
|
||||
style={sectionStyle(overlayTheme, { emphasized: activeFocus.target === 'status' })}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
@@ -211,7 +217,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme)}>
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
影响范围
|
||||
</div>
|
||||
@@ -226,9 +232,8 @@ const SecurityUpdateSettingsModal = ({
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
border: overlayTheme.sectionBorder,
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
background: overlayTheme.sectionBg,
|
||||
padding: '12px 10px',
|
||||
}}
|
||||
>
|
||||
@@ -239,7 +244,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme)}>
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
待处理清单
|
||||
</div>
|
||||
@@ -258,7 +263,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
<div
|
||||
key={issue.id}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme),
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
@@ -306,7 +311,7 @@ const SecurityUpdateSettingsModal = ({
|
||||
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' })}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
|
||||
最近一次结果
|
||||
|
||||
@@ -297,6 +297,71 @@ describe('secureConfigBootstrap', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('does not merge local legacy pending items back into an active migration round that already reports needs_attention', () => {
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
migrationId: 'migration-active-1',
|
||||
overallStatus: 'needs_attention',
|
||||
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
|
||||
issues: [
|
||||
{
|
||||
id: 'ai-provider-openai-main',
|
||||
scope: 'ai_provider',
|
||||
refId: 'openai-main',
|
||||
title: 'OpenAI',
|
||||
severity: 'medium',
|
||||
status: 'needs_attention',
|
||||
reasonCode: 'secret_missing',
|
||||
action: 'open_ai_settings',
|
||||
message: 'AI 提供商配置需要补充后才能完成安全更新。',
|
||||
},
|
||||
],
|
||||
}, legacyPayload);
|
||||
|
||||
expect(status.overallStatus).toBe('needs_attention');
|
||||
expect(status.summary).toEqual({
|
||||
total: 3,
|
||||
updated: 2,
|
||||
pending: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(status.issues).toEqual([
|
||||
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not merge local legacy pending items back into a rolled_back migration round', () => {
|
||||
const status = mergeSecurityUpdateStatusWithLegacySource({
|
||||
migrationId: 'migration-active-2',
|
||||
overallStatus: 'rolled_back',
|
||||
summary: { total: 3, updated: 1, pending: 0, skipped: 0, failed: 2 },
|
||||
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: 1,
|
||||
pending: 0,
|
||||
skipped: 0,
|
||||
failed: 2,
|
||||
});
|
||||
expect(status.issues).toEqual([
|
||||
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads backend secure config directly when no legacy source exists', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const replaceConnections = vi.fn();
|
||||
|
||||
@@ -193,6 +193,7 @@ export const mergeSecurityUpdateStatusWithLegacySource = (
|
||||
},
|
||||
issues: Array.isArray(status?.issues) ? status.issues : [],
|
||||
};
|
||||
const hasActiveMigrationRound = String(base.migrationId || '').trim() !== '';
|
||||
const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue));
|
||||
|
||||
const legacy = buildLegacyPendingDetails(rawPayload);
|
||||
@@ -231,6 +232,9 @@ export const mergeSecurityUpdateStatusWithLegacySource = (
|
||||
}
|
||||
|
||||
if (base.overallStatus === 'rolled_back' || base.overallStatus === 'needs_attention') {
|
||||
if (hasActiveMigrationRound) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveSecurityUpdateFocusState,
|
||||
resolveSecurityUpdateRepairEntry,
|
||||
resolveSecurityUpdateSettingsFocusTarget,
|
||||
shouldRefreshSecurityUpdateDetailsFocus,
|
||||
shouldReopenSecurityUpdateDetails,
|
||||
shouldRetrySecurityUpdateAfterRepairSave,
|
||||
} from './securityUpdateRepairFlow';
|
||||
@@ -136,4 +137,19 @@ describe('securityUpdateRepairFlow', () => {
|
||||
expect(shouldRetrySecurityUpdateAfterRepairSave('ai')).toBe(false);
|
||||
expect(shouldRetrySecurityUpdateAfterRepairSave(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not force a new focus pulse when the details modal is already open and only the round result is refreshing', () => {
|
||||
expect(shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: true,
|
||||
wasOpen: true,
|
||||
})).toBe(false);
|
||||
expect(shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: true,
|
||||
wasOpen: false,
|
||||
})).toBe(true);
|
||||
expect(shouldRefreshSecurityUpdateDetailsFocus({
|
||||
requestedOpen: false,
|
||||
wasOpen: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,14 @@ export const shouldReopenSecurityUpdateDetails = (
|
||||
repairSource: SecurityUpdateRepairSource | null | undefined,
|
||||
): boolean => repairSource === 'connection' || repairSource === 'proxy' || repairSource === 'ai';
|
||||
|
||||
export const shouldRefreshSecurityUpdateDetailsFocus = ({
|
||||
requestedOpen,
|
||||
wasOpen,
|
||||
}: {
|
||||
requestedOpen: boolean;
|
||||
wasOpen: boolean;
|
||||
}): boolean => requestedOpen && !wasOpen;
|
||||
|
||||
export const shouldRetrySecurityUpdateAfterRepairSave = (
|
||||
repairSource: SecurityUpdateRepairSource | null | undefined,
|
||||
): boolean => repairSource === 'connection';
|
||||
|
||||
@@ -62,6 +62,17 @@ describe('securityUpdateVisuals', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('can scale shell surface alpha with the current appearance opacity so reminder layers stay visually consistent', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
const fadedShell = getSecurityUpdateShellSurfaceStyle(lightTheme, 0.5);
|
||||
const fadedBanner = getSecurityUpdateBannerSurfaceStyle(lightTheme, 0.5);
|
||||
|
||||
expect(fadedShell.background).not.toBe(lightTheme.shellBg);
|
||||
expect(fadedShell.border).not.toBe(lightTheme.shellBorder);
|
||||
expect(fadedShell.background).toContain('0.49');
|
||||
expect(fadedBanner.background).toContain('0.49');
|
||||
});
|
||||
|
||||
it('can emphasize a section surface for transient focus and recent-result highlighting', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
|
||||
@@ -10,6 +10,25 @@ export const SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS = 'security-update-result-
|
||||
|
||||
type SecurityUpdateSectionSurfaceOptions = {
|
||||
emphasized?: boolean;
|
||||
surfaceOpacity?: number;
|
||||
};
|
||||
|
||||
const clampOpacity = (value: number): number => Math.min(1, Math.max(0.1, value));
|
||||
|
||||
const formatAlpha = (value: number): string => (
|
||||
Number(value.toFixed(3)).toString()
|
||||
);
|
||||
|
||||
const applySurfaceOpacity = (token: string, surfaceOpacity = 1): string => {
|
||||
const normalizedOpacity = clampOpacity(surfaceOpacity);
|
||||
if (normalizedOpacity >= 0.999) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return token.replace(
|
||||
/rgba\(\s*([^)]+?)\s*,\s*([0-9]*\.?[0-9]+)\s*\)/g,
|
||||
(_, channels: string, alpha: string) => `rgba(${channels}, ${formatAlpha(Number(alpha) * normalizedOpacity)})`,
|
||||
);
|
||||
};
|
||||
|
||||
const getSecurityUpdateHighlightBorder = (overlayTheme: OverlayWorkbenchTheme): string => (
|
||||
@@ -40,17 +59,19 @@ export const getSecurityUpdateActionButtonStyle = (): CSSProperties => ({
|
||||
|
||||
export const getSecurityUpdateShellSurfaceStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity = 1,
|
||||
): CSSProperties => ({
|
||||
border: overlayTheme.shellBorder,
|
||||
background: overlayTheme.shellBg,
|
||||
boxShadow: overlayTheme.shellShadow,
|
||||
border: applySurfaceOpacity(overlayTheme.shellBorder, surfaceOpacity),
|
||||
background: applySurfaceOpacity(overlayTheme.shellBg, surfaceOpacity),
|
||||
boxShadow: applySurfaceOpacity(overlayTheme.shellShadow, surfaceOpacity),
|
||||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
});
|
||||
|
||||
export const getSecurityUpdateBannerSurfaceStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity = 1,
|
||||
): CSSProperties => ({
|
||||
...getSecurityUpdateShellSurfaceStyle(overlayTheme),
|
||||
...getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
boxShadow: 'none',
|
||||
});
|
||||
|
||||
@@ -58,8 +79,16 @@ 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',
|
||||
border: applySurfaceOpacity(
|
||||
options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder,
|
||||
options.surfaceOpacity,
|
||||
),
|
||||
background: applySurfaceOpacity(
|
||||
options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg,
|
||||
options.surfaceOpacity,
|
||||
),
|
||||
boxShadow: options.emphasized
|
||||
? applySurfaceOpacity(getSecurityUpdateHighlightShadow(overlayTheme), options.surfaceOpacity)
|
||||
: 'none',
|
||||
transition: 'background 180ms ease, border-color 180ms ease, box-shadow 180ms ease',
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
@@ -15,6 +18,9 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
|
||||
repo := newSavedConnectionRepository(a.configDir, a.secretStore)
|
||||
view, err := repo.Find(config.ID)
|
||||
if err != nil {
|
||||
if shouldFallbackToInlineConnectionSecrets(config, err) {
|
||||
return config, nil
|
||||
}
|
||||
return config, normalizeConnectionSecretResolutionError(config, err)
|
||||
}
|
||||
|
||||
@@ -24,6 +30,9 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
|
||||
}
|
||||
bundle, err := repo.loadSecretBundle(view)
|
||||
if err != nil {
|
||||
if shouldFallbackToInlineConnectionSecrets(config, err) {
|
||||
return mergeInlineConnectionSecrets(base, config), nil
|
||||
}
|
||||
return base, normalizeConnectionSecretResolutionError(base, err)
|
||||
}
|
||||
resolved := mergeConnectionSecretBundleIntoConfig(base, bundle)
|
||||
@@ -31,6 +40,57 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func shouldFallbackToInlineConnectionSecrets(config connection.ConnectionConfig, err error) bool {
|
||||
if err == nil || !connectionConfigCarriesInlineSecrets(config) || secretstore.IsUnavailable(err) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(lower, "saved connection not found:")
|
||||
}
|
||||
|
||||
func connectionConfigCarriesInlineSecrets(config connection.ConnectionConfig) bool {
|
||||
return strings.TrimSpace(config.Password) != "" ||
|
||||
strings.TrimSpace(config.SSH.Password) != "" ||
|
||||
strings.TrimSpace(config.Proxy.Password) != "" ||
|
||||
strings.TrimSpace(config.HTTPTunnel.Password) != "" ||
|
||||
strings.TrimSpace(config.MySQLReplicaPassword) != "" ||
|
||||
strings.TrimSpace(config.MongoReplicaPassword) != "" ||
|
||||
strings.TrimSpace(config.URI) != "" ||
|
||||
strings.TrimSpace(config.DSN) != ""
|
||||
}
|
||||
|
||||
func mergeInlineConnectionSecrets(base connection.ConnectionConfig, inline connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
merged := base
|
||||
if strings.TrimSpace(inline.Password) != "" {
|
||||
merged.Password = inline.Password
|
||||
}
|
||||
if strings.TrimSpace(inline.SSH.Password) != "" {
|
||||
merged.SSH.Password = inline.SSH.Password
|
||||
}
|
||||
if strings.TrimSpace(inline.Proxy.Password) != "" {
|
||||
merged.Proxy.Password = inline.Proxy.Password
|
||||
}
|
||||
if strings.TrimSpace(inline.HTTPTunnel.Password) != "" {
|
||||
merged.HTTPTunnel.Password = inline.HTTPTunnel.Password
|
||||
}
|
||||
if strings.TrimSpace(inline.MySQLReplicaPassword) != "" {
|
||||
merged.MySQLReplicaPassword = inline.MySQLReplicaPassword
|
||||
}
|
||||
if strings.TrimSpace(inline.MongoReplicaPassword) != "" {
|
||||
merged.MongoReplicaPassword = inline.MongoReplicaPassword
|
||||
}
|
||||
if strings.TrimSpace(inline.URI) != "" {
|
||||
merged.URI = inline.URI
|
||||
}
|
||||
if strings.TrimSpace(inline.DSN) != "" {
|
||||
merged.DSN = inline.DSN
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func normalizeConnectionSecretResolutionError(config connection.ConnectionConfig, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -61,3 +61,78 @@ func TestResolveConnectionSecretsReturnsFriendlyMessageWhenSavedSecretSourceIsMi
|
||||
t.Fatalf("expected a secret-specific error message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedConnectionIsMissing(t *testing.T) {
|
||||
store := newFakeAppSecretStore()
|
||||
app := NewAppWithSecretStore(store)
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
input := connection.ConnectionConfig{
|
||||
ID: "legacy-inline",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "inline-secret",
|
||||
DSN: "postgres://postgres:inline-secret@db.local/app",
|
||||
}
|
||||
|
||||
resolved, err := app.resolveConnectionSecrets(input)
|
||||
if err != nil {
|
||||
t.Fatalf("expected inline secrets to be used as fallback, got error: %v", err)
|
||||
}
|
||||
if resolved.Password != "inline-secret" {
|
||||
t.Fatalf("expected inline password to be preserved, got %q", resolved.Password)
|
||||
}
|
||||
if resolved.DSN != "postgres://postgres:inline-secret@db.local/app" {
|
||||
t.Fatalf("expected inline DSN to be preserved, got %q", resolved.DSN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConnectionSecretsFallsBackToInlineSecretsWhenSavedSecretBundleIsMissing(t *testing.T) {
|
||||
store := newFakeAppSecretStore()
|
||||
app := NewAppWithSecretStore(store)
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
view, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "conn-inline-fallback",
|
||||
Name: "Primary",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-inline-fallback",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "stored-secret",
|
||||
DSN: "postgres://postgres:stored-secret@db.local/app",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
if view.SecretRef == "" {
|
||||
t.Fatal("expected saved connection to allocate a secret ref")
|
||||
}
|
||||
if err := store.Delete(view.SecretRef); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{
|
||||
ID: "conn-inline-fallback",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "inline-secret",
|
||||
DSN: "postgres://postgres:inline-secret@db.local/app",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected inline secrets to be used when secret bundle is missing, got error: %v", err)
|
||||
}
|
||||
if resolved.Password != "inline-secret" {
|
||||
t.Fatalf("expected inline password to be preserved, got %q", resolved.Password)
|
||||
}
|
||||
if resolved.DSN != "postgres://postgres:inline-secret@db.local/app" {
|
||||
t.Fatalf("expected inline DSN to be preserved, got %q", resolved.DSN)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -62,18 +63,78 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
|
||||
}
|
||||
|
||||
logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey)
|
||||
client := newRedisClientFunc()
|
||||
if err := client.Connect(connectConfig); err != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, err)
|
||||
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||
client, connectedConfig, connectErr := connectRedisClientWithLegacyRootFallback(connectConfig)
|
||||
if connectErr != nil {
|
||||
wrapped := wrapConnectError(connectedConfig, connectErr)
|
||||
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(connectedConfig), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
redisCache[key] = client
|
||||
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(connectedConfig), shortKey)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func connectRedisClientWithLegacyRootFallback(config connection.ConnectionConfig) (redis.RedisClient, connection.ConnectionConfig, error) {
|
||||
client := newRedisClientFunc()
|
||||
if err := client.Connect(config); err == nil {
|
||||
return client, config, nil
|
||||
} else {
|
||||
client.Close()
|
||||
if !shouldRetryRedisWithClearedLegacyRoot(config, err) {
|
||||
return nil, config, err
|
||||
}
|
||||
|
||||
fallbackConfig := config
|
||||
fallbackConfig.User = ""
|
||||
logger.Warnf("Redis 使用用户名 root 认证失败,已按历史默认值回退为空用户名重试:%s", formatRedisConnSummary(config))
|
||||
|
||||
fallbackClient := newRedisClientFunc()
|
||||
if retryErr := fallbackClient.Connect(fallbackConfig); retryErr != nil {
|
||||
fallbackClient.Close()
|
||||
return nil, fallbackConfig, retryErr
|
||||
}
|
||||
return fallbackClient, fallbackConfig, nil
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRetryRedisWithClearedLegacyRoot(config connection.ConnectionConfig, err error) bool {
|
||||
if err == nil || strings.ToLower(strings.TrimSpace(config.Type)) != "redis" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(config.User) != "root" {
|
||||
return false
|
||||
}
|
||||
if _, ok := extractExplicitRedisUsername(config.URI); ok {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(lower, "wrongpass") ||
|
||||
strings.Contains(lower, "invalid username-password pair") ||
|
||||
strings.Contains(lower, "auth failed") ||
|
||||
strings.Contains(lower, "wrong number of arguments for 'auth' command") ||
|
||||
strings.Contains(lower, "authentication failed")
|
||||
}
|
||||
|
||||
func extractExplicitRedisUsername(rawURI string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(rawURI)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.User == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(parsed.User.Username())
|
||||
if username == "" {
|
||||
return "", false
|
||||
}
|
||||
return username, true
|
||||
}
|
||||
|
||||
func getRedisClientCacheKey(config connection.ConnectionConfig) string {
|
||||
normalized := normalizeCacheKeyConfig(config)
|
||||
b, _ := json.Marshal(normalized)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -92,6 +93,20 @@ func (c *capturingRedisClient) GetCurrentDB() int { return 0 }
|
||||
|
||||
func (c *capturingRedisClient) FlushDB() error { return nil }
|
||||
|
||||
type scriptedRedisClient struct {
|
||||
capturingRedisClient
|
||||
connectErr error
|
||||
connectCalls *[]connection.ConnectionConfig
|
||||
}
|
||||
|
||||
func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error {
|
||||
c.connectConfig = config
|
||||
if c.connectCalls != nil {
|
||||
*c.connectCalls = append(*c.connectCalls, config)
|
||||
}
|
||||
return c.connectErr
|
||||
}
|
||||
|
||||
func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -215,6 +230,34 @@ func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit redis username from uri is preserved even when it is root",
|
||||
savedConfig: connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "redis.local",
|
||||
Port: 6379,
|
||||
User: "root",
|
||||
Password: "redis-secret",
|
||||
URI: "redis://root:redis-secret@redis.local:6379/0",
|
||||
},
|
||||
runtimeConfig: connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "redis.local",
|
||||
Port: 6379,
|
||||
User: "root",
|
||||
},
|
||||
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
|
||||
t.Helper()
|
||||
if got.User != "root" {
|
||||
t.Fatalf("expected RedisConnect to preserve explicit uri user root, got %q", got.User)
|
||||
}
|
||||
if got.URI != "redis://root:redis-secret@redis.local:6379/0" {
|
||||
t.Fatalf("expected RedisConnect to restore saved redis uri, got %q", got.URI)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -256,3 +299,130 @@ func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisConnectPreservesExplicitRootUserWithoutURIWhenConnectSucceeds(t *testing.T) {
|
||||
app := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
_, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "redis-1",
|
||||
Name: "Redis Saved",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "redis.local",
|
||||
Port: 6379,
|
||||
User: "root",
|
||||
Password: "redis-secret",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
CloseAllRedisClients()
|
||||
connectCalls := make([]connection.ConnectionConfig, 0, 1)
|
||||
client := &scriptedRedisClient{connectCalls: &connectCalls}
|
||||
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(connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "redis.local",
|
||||
Port: 6379,
|
||||
User: "root",
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("RedisConnect returned failure: %+v", result)
|
||||
}
|
||||
if len(connectCalls) != 1 {
|
||||
t.Fatalf("expected exactly one Redis connect attempt, got %d", len(connectCalls))
|
||||
}
|
||||
if connectCalls[0].User != "root" {
|
||||
t.Fatalf("expected RedisConnect to preserve explicit root user when connect succeeds, got %q", connectCalls[0].User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisConnectRetriesLegacyDefaultRootUserWithoutUsernameAfterAuthFailure(t *testing.T) {
|
||||
app := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
_, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "redis-1",
|
||||
Name: "Redis Saved",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "redis.local",
|
||||
Port: 6379,
|
||||
User: "root",
|
||||
Password: "redis-secret",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
CloseAllRedisClients()
|
||||
connectCalls := make([]connection.ConnectionConfig, 0, 2)
|
||||
clients := []redislib.RedisClient{
|
||||
&scriptedRedisClient{
|
||||
connectErr: errors.New("WRONGPASS invalid username-password pair"),
|
||||
connectCalls: &connectCalls,
|
||||
},
|
||||
&scriptedRedisClient{
|
||||
connectCalls: &connectCalls,
|
||||
},
|
||||
}
|
||||
clientIndex := 0
|
||||
originalNewRedisClientFunc := newRedisClientFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
defer func() {
|
||||
newRedisClientFunc = originalNewRedisClientFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
CloseAllRedisClients()
|
||||
}()
|
||||
newRedisClientFunc = func() redislib.RedisClient {
|
||||
if clientIndex >= len(clients) {
|
||||
t.Fatalf("unexpected Redis client allocation #%d", clientIndex+1)
|
||||
}
|
||||
client := clients[clientIndex]
|
||||
clientIndex++
|
||||
return client
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
result := app.RedisConnect(connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "redis.local",
|
||||
Port: 6379,
|
||||
User: "root",
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("RedisConnect returned failure after fallback: %+v", result)
|
||||
}
|
||||
if len(connectCalls) != 2 {
|
||||
t.Fatalf("expected RedisConnect to retry exactly once after auth failure, got %d attempts", len(connectCalls))
|
||||
}
|
||||
if connectCalls[0].User != "root" {
|
||||
t.Fatalf("expected first Redis connect attempt to keep root user, got %q", connectCalls[0].User)
|
||||
}
|
||||
if connectCalls[1].User != "" {
|
||||
t.Fatalf("expected fallback Redis connect attempt to clear legacy root user, got %q", connectCalls[1].User)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user