diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59a200e..4ae0f0b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,6 +48,7 @@ import { hasSecurityUpdateRecentResult, resolveSecurityUpdateRepairEntry, resolveSecurityUpdateSettingsFocusTarget, + shouldRefreshSecurityUpdateDetailsFocus, shouldReopenSecurityUpdateDetails, shouldRetrySecurityUpdateAfterRepairSave, type SecurityUpdateRepairSource, @@ -276,6 +277,7 @@ function App() { status?: Partial | 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} /> 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, diff --git a/frontend/src/components/SecurityUpdateIntroModal.tsx b/frontend/src/components/SecurityUpdateIntroModal.tsx index e02c099..e8d0db5 100644 --- a/frontend/src/components/SecurityUpdateIntroModal.tsx +++ b/frontend/src/components/SecurityUpdateIntroModal.tsx @@ -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 }, diff --git a/frontend/src/components/SecurityUpdateProgressModal.tsx b/frontend/src/components/SecurityUpdateProgressModal.tsx index 5e3888b..35f130c 100644 --- a/frontend/src/components/SecurityUpdateProgressModal.tsx +++ b/frontend/src/components/SecurityUpdateProgressModal.tsx @@ -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 ( ({ 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 = ({
@@ -211,7 +217,7 @@ const SecurityUpdateSettingsModal = ({
-
+
影响范围
@@ -226,9 +232,8 @@ const SecurityUpdateSettingsModal = ({
@@ -239,7 +244,7 @@ const SecurityUpdateSettingsModal = ({
-
+
待处理清单
@@ -258,7 +263,7 @@ const SecurityUpdateSettingsModal = ({
最近一次结果 diff --git a/frontend/src/utils/secureConfigBootstrap.test.ts b/frontend/src/utils/secureConfigBootstrap.test.ts index 32c9cd5..7f7b0cd 100644 --- a/frontend/src/utils/secureConfigBootstrap.test.ts +++ b/frontend/src/utils/secureConfigBootstrap.test.ts @@ -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(); diff --git a/frontend/src/utils/secureConfigBootstrap.ts b/frontend/src/utils/secureConfigBootstrap.ts index f457024..cd26ef2 100644 --- a/frontend/src/utils/secureConfigBootstrap.ts +++ b/frontend/src/utils/secureConfigBootstrap.ts @@ -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, diff --git a/frontend/src/utils/securityUpdateRepairFlow.test.ts b/frontend/src/utils/securityUpdateRepairFlow.test.ts index 0cb57f7..2642dcc 100644 --- a/frontend/src/utils/securityUpdateRepairFlow.test.ts +++ b/frontend/src/utils/securityUpdateRepairFlow.test.ts @@ -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); + }); }); diff --git a/frontend/src/utils/securityUpdateRepairFlow.ts b/frontend/src/utils/securityUpdateRepairFlow.ts index 9a6be1e..bac59c4 100644 --- a/frontend/src/utils/securityUpdateRepairFlow.ts +++ b/frontend/src/utils/securityUpdateRepairFlow.ts @@ -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'; diff --git a/frontend/src/utils/securityUpdateVisuals.test.ts b/frontend/src/utils/securityUpdateVisuals.test.ts index 7d0d8e7..781e47c 100644 --- a/frontend/src/utils/securityUpdateVisuals.test.ts +++ b/frontend/src/utils/securityUpdateVisuals.test.ts @@ -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); diff --git a/frontend/src/utils/securityUpdateVisuals.ts b/frontend/src/utils/securityUpdateVisuals.ts index 735ca2d..a93fce6 100644 --- a/frontend/src/utils/securityUpdateVisuals.ts +++ b/frontend/src/utils/securityUpdateVisuals.ts @@ -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', }); diff --git a/internal/app/connection_secret_resolution.go b/internal/app/connection_secret_resolution.go index 5e7eb6f..ac21714 100644 --- a/internal/app/connection_secret_resolution.go +++ b/internal/app/connection_secret_resolution.go @@ -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 diff --git a/internal/app/connection_secret_resolution_test.go b/internal/app/connection_secret_resolution_test.go index e09e24a..8ecf590 100644 --- a/internal/app/connection_secret_resolution_test.go +++ b/internal/app/connection_secret_resolution_test.go @@ -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) + } +} diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 08f6c22..71b70a7 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -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) diff --git a/internal/app/methods_redis_test.go b/internal/app/methods_redis_test.go index f713cad..5f801bf 100644 --- a/internal/app/methods_redis_test.go +++ b/internal/app/methods_redis_test.go @@ -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) + } +}