fix(web): 完善统一认证恢复与重配交互

为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。

验证:pnpm test;pnpm lint;pnpm build。
This commit is contained in:
2026-07-17 18:31:38 +08:00
parent a312ad880d
commit b179162330
9 changed files with 696 additions and 98 deletions
+46
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
cancelIdentityPairing,
connectSecurityEventTransmitter,
deleteOIDCBrowserSession,
GatewayApiError,
@@ -7,6 +8,7 @@ import {
getCurrentUser,
OIDC_BROWSER_SESSION_CREDENTIAL,
startIdentityPairing,
retireIdentityPairingSecurityEventConflict,
validateIdentityRevision,
} from './api';
@@ -22,6 +24,10 @@ describe('Gateway provisioning errors', () => {
['OIDC_SESSION_REFRESH_UNAVAILABLE', '认证中心暂时不可用,请稍后重试'],
['OIDC_SESSION_STORE_UNAVAILABLE', '登录会话存储暂时不可用,请稍后重试'],
['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'],
['IDENTITY_PAIRING_IN_PROGRESS', '请先完成或放弃当前统一认证配对'],
['IDENTITY_PAIRING_NOT_CANCELLABLE', '当前统一认证配置不能放弃'],
['IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE', '当前配对状态已变化,请刷新后重试'],
['IDENTITY_VERSION_CONFLICT', '统一认证状态已变化,请刷新后重试'],
] as const;
for (const [code, expected] of cases) {
@@ -80,6 +86,46 @@ describe('identity configuration transport', () => {
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
expect(init.credentials).toBe('include');
});
it('cancels a pairing with its own ETag and an idempotency key', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
id: 'pairing', status: 'cancelled', cleanupStatus: 'pending', version: 8,
}), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await cancelIdentityPairing('manager-token', 'pairing', 7);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/admin/system/identity/pairings/pairing/cancel');
expect(init.method).toBe('POST');
expect(init.body).toBeUndefined();
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-cancel-');
expect(init.credentials).toBe('include');
});
it('retires an SSF conflict through the pairing-scoped recovery endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
id: 'pairing', status: 'credentials_saved', version: 7,
}), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await retireIdentityPairingSecurityEventConflict('manager-token', 'pairing', 7);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/admin/system/identity/pairings/pairing/retire-conflicting-security-event');
expect(init.method).toBe('POST');
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-retire-ssf-conflict-');
});
});
describe('security event connection transport', () => {