import { afterEach, describe, expect, it, vi } from 'vitest'; import { cancelIdentityPairing, connectSecurityEventTransmitter, createResponse, deleteOIDCBrowserSession, GatewayApiError, gatewayErrorMessage, getAPITask, getCurrentUser, getOpsManagementSkillMetadata, loginLocalAccount, listApiKeyAssignableModels, OIDC_BROWSER_SESSION_CREDENTIAL, startIdentityPairing, retireIdentityPairingSecurityEventConflict, validateIdentityRevision, } from './api'; import { applyTaskSubmissionMode, runTask } from './lib/run-task'; describe('local login transport', () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it('aborts after ten seconds and returns a stable login timeout message', async () => { vi.useFakeTimers(); const fetchMock = vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); })); vi.stubGlobal('fetch', fetchMock); const login = loginLocalAccount({ account: 'timeout-test-account', password: 'timeout-test-password' }); const rejection = expect(login).rejects.toMatchObject({ message: '登录请求超时,请稍后重试', }); await vi.advanceTimersByTimeAsync(10_000); await rejection; const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.signal).toBeInstanceOf(AbortSignal); expect(init.signal?.aborted).toBe(true); }); }); describe('Gateway provisioning errors', () => { const cases = [ ['GATEWAY_USER_NOT_PROVISIONED', '该账号尚未开通 EasyAI Gateway'], ['GATEWAY_USER_DISABLED', '该 Gateway 账号已停用,请联系管理员'], ['GATEWAY_TENANT_UNAVAILABLE', 'Gateway 租户尚未就绪,请联系管理员'], ['GATEWAY_USER_PROVISIONING_FAILED', 'Gateway 账号初始化失败,请稍后重试'], ['OIDC_BROWSER_SESSION_DISABLED', 'Gateway 浏览器会话尚未启用'], ['OIDC_SESSION_INVALID', '统一认证会话无效,请重新登录'], ['OIDC_SESSION_EXPIRED', '统一认证会话已过期,请重新登录'], ['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) { it(`maps ${code} to a stable Chinese status`, () => { expect(gatewayErrorMessage({ code, message: 'internal server message', status: 503 })).toBe(expected); expect(new GatewayApiError({ code, message: 'internal server message', status: 503 }).message).toContain(expected); expect(new GatewayApiError({ code, message: 'internal server message', status: 503 }).message).not.toContain('internal server message'); }); } it('keeps the server message for unrelated errors', () => { expect(gatewayErrorMessage({ code: 'OTHER_ERROR', message: '模型不可用', status: 503 })).toBe('模型不可用'); }); }); describe('identity configuration transport', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('keeps the one-time onboarding code in the request body and enforces write headers', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'pairing', version: 1 }), { status: 202, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); await startIdentityPairing('manager-token', { authCenterUrl: 'https://auth.example.com', onboardingCode: 'one-time-code-must-stay-in-body', publicBaseUrl: 'https://api.gateway.example.com', webBaseUrl: 'https://gateway.example.com', localTenantKey: 'default', legacyJwtEnabled: false, }); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).not.toContain('one-time-code-must-stay-in-body'); expect(JSON.parse(String(init.body)).onboardingCode).toBe('one-time-code-must-stay-in-body'); expect(new Headers(init.headers).get('If-Match')).toBe('W/"0"'); expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-pair-'); }); it('sends the revision version when validating a draft or active runtime', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'revision', version: 8 }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); await validateIdentityRevision('manager-token', 'revision', 7); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; 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', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('sends the one-time machine credential only in the connection request', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { status: 202, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret'); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.method).toBe('PUT'); expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf', management_client_id: 'gateway-machine', management_client_secret: 'one-time-machine-secret', }); expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-'); expect(new Headers(init.headers).has('If-Match')).toBe(false); }); it('sends If-Match when retrying an existing failed connection', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { status: 202, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); }); }); describe('OIDC browser session transport', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('uses the shared cookie without sending a synthetic bearer token', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ sub: 'oidc-user' }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.credentials).toBe('include'); expect(new Headers(init.headers).has('Authorization')).toBe(false); }); it('deletes the shared cookie with a credentialed request', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); vi.stubGlobal('fetch', fetchMock); await deleteOIDCBrowserSession(); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.method).toBe('DELETE'); expect(init.credentials).toBe('include'); expect(new Headers(init.headers).has('Authorization')).toBe(false); }); }); describe('API Key permission resources', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('loads the user-owned resource pool independently from playable models', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); await listApiKeyAssignableModels('user-token'); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toContain('/api/v1/api-keys/assignable-models'); expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token'); }); }); describe('Public Agent resources', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('loads operations skill metadata without authorization', async () => { const metadata = { name: 'ai-gateway-ops-management', version: '1.0.2', displayName: 'AI Gateway 运维管理', modules: ['model-runtime'], fileName: 'ai-gateway-ops-management-v1.0.2.zip', downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download', apiDocsJsonPath: '/api/v1/openapi.json', apiDocsYamlPath: '/api/v1/openapi.yaml', }; const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); await expect(getOpsManagementSkillMetadata()).resolves.toEqual(metadata); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toContain('/api/v1/public/skills/ai-gateway-ops-management/metadata'); expect(new Headers(init.headers).has('Authorization')).toBe(false); }); }); describe('API documentation runner transports', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('runs Responses against the public OpenAI-compatible endpoint', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'resp-test', object: 'response' }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); await createResponse('sk-test', { model: 'gpt-test', input: 'hello', store: true, stream: false }); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toContain('/v1/responses'); expect(init.method).toBe('POST'); expect(new Headers(init.headers).get('Authorization')).toBe('Bearer sk-test'); expect(JSON.parse(String(init.body))).toMatchObject({ model: 'gpt-test', input: 'hello', store: true, stream: false }); }); it('retrieves a task from the documented API path', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'task-123', status: 'running' }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); await getAPITask('sk-test', 'task-123'); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toContain('/api/v1/tasks/task-123'); expect(init.method).toBe('GET'); }); it('removes every simulation switch from a real submission while preserving edited parameters', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'chatcmpl-real' }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); await runTask( 'sk-test', { kind: 'chat.completions', model: 'gpt-fallback', prompt: 'fallback' }, { submissionMode: 'production', requestBody: { model: 'gpt-real', messages: [{ role: 'user', content: 'edited body' }], temperature: 0.25, runMode: 'simulation', run_mode: 'simulation', simulation: true, testMode: true, test_mode: true, }, }, ); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(JSON.parse(String(init.body))).toEqual({ model: 'gpt-real', messages: [{ role: 'user', content: 'edited body' }], temperature: 0.25, }); }); it('uses canonical simulation parameters without removing a model-specific mode field', () => { expect(applyTaskSubmissionMode({ model: 'video-model', mode: 'pro', testMode: false }, 'simulation')).toEqual({ model: 'video-model', mode: 'pro', runMode: 'simulation', simulation: true, }); }); });