mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-03 13:09:47 +08:00
🐛 fix(security): 完善密文升级导入覆盖与安全更新链路
- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理 - 修复安全更新详情高亮反馈与相关前后端链路 - 补强 keyring 误判边界与安全更新回归测试
This commit is contained in:
@@ -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',
|
||||
});
|
||||
Reference in New Issue
Block a user