import { createHmac } from "node:crypto"; import { dbRun } from "../server/db.mjs"; const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; async function request(path, options = {}) { const response = await fetch(`${base}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}) } }); const payload = await response.json().catch(() => ({})); return { response, payload }; } function assert(condition, message) { if (!condition) throw new Error(message); } function totp(secret, timestamp = Date.now()) { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const normalized = String(secret).replace(/=+$/g, "").toUpperCase(); let buffer = 0; let bits = 0; const bytes = []; for (const character of normalized) { buffer = (buffer << 5) | alphabet.indexOf(character); bits += 5; if (bits >= 8) { bits -= 8; bytes.push((buffer >> bits) & 0xff); } } const counter = Math.floor(timestamp / 30000); const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(BigInt(counter)); const digest = createHmac("sha1", Buffer.from(bytes)).update(counterBuffer).digest(); const offset = digest[digest.length - 1] & 0x0f; const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); return String(value % 1000000).padStart(6, "0"); } const ownerLogin = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); assert(ownerLogin.response.ok && ownerLogin.payload.session?.token, "identity smoke owner login failed"); const ownerHeaders = { authorization: `Bearer ${ownerLogin.payload.session.token}` }; const writerLogin = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" }) }); assert(writerLogin.response.ok && writerLogin.payload.session?.token, "identity smoke writer login failed"); const writerHeaders = { authorization: `Bearer ${writerLogin.payload.session.token}` }; const writerDenied = await request("/api/system/identity", { headers: writerHeaders }); assert(writerDenied.response.status === 403, "普通成员读取身份中心必须被后端拒绝"); const initial = await request("/api/system/identity", { headers: ownerHeaders }); assert(initial.response.ok && initial.payload.policy && Array.isArray(initial.payload.providers), "管理员读取身份中心失败"); const provider = await request("/api/system/identity/providers", { method: "POST", headers: ownerHeaders, body: JSON.stringify({ name: `Smoke OIDC ${Date.now()}`, kind: "oidc", issuerUrl: "http://127.0.0.1:8799/issuer", clientId: "smoke-client", clientSecretRef: "SMOKE_OIDC_CLIENT_SECRET", scopes: ["openid", "profile", "email"], enabled: true }) }); assert(provider.response.status === 201 && provider.payload.providers.some((item) => item.enabled), "OIDC 提供商登记失败"); const smokeProvider = provider.payload.providers.at(-1); const policy = await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: true, localLoginFallback: true, mfaRequiredForAdmins: true }) }); assert(policy.response.ok && policy.payload.policy.ssoEnabled && policy.payload.policy.mfaRequiredForAdmins, "身份策略保存失败"); const enrollmentLogin = await request("/api/auth/login", { method: "POST", body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) }); assert(enrollmentLogin.response.ok && enrollmentLogin.payload.mfaEnrollmentRequired && enrollmentLogin.payload.enrollmentToken, "强制管理员 MFA 必须进入首次绑定流程"); const enrollmentSetup = await request("/api/auth/mfa/enroll/setup", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollmentLogin.payload.enrollmentToken }) }); assert(enrollmentSetup.response.status === 201 && enrollmentSetup.payload.setup?.secret, "MFA enrollment setup failed"); const enrollmentComplete = await request("/api/auth/mfa/enroll/enable", { method: "POST", body: JSON.stringify({ enrollmentToken: enrollmentLogin.payload.enrollmentToken, methodId: enrollmentSetup.payload.setup.methodId, code: totp(enrollmentSetup.payload.setup.secret) }) }); assert(enrollmentComplete.response.ok && enrollmentComplete.payload.session?.token, "MFA enrollment completion failed"); const publicProviders = await request("/api/auth/sso/providers"); assert(publicProviders.response.ok && publicProviders.payload.providers.some((item) => item.id === smokeProvider.id), "公开 SSO 提供商目录读取失败"); const directory = await request("/api/system/identity/directory-syncs", { method: "POST", headers: ownerHeaders, body: JSON.stringify({ name: `Smoke SCIM ${Date.now()}`, syncMode: "provision-and-deprovision", schedule: "manual" }) }); assert(directory.response.status === 201 && directory.payload.token && directory.payload.directorySync?.endpointPath, "SCIM 目录创建失败"); const directoryId = directory.payload.directorySync.id; const enabledDirectory = await request(`/api/system/identity/directory-syncs/${encodeURIComponent(directoryId)}`, { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ enabled: true }) }); assert(enabledDirectory.response.ok && enabledDirectory.payload.directorySyncs.some((item) => item.id === directoryId && item.enabled), "SCIM 目录启用失败"); const scimHeaders = { authorization: `Bearer ${directory.payload.token}` }; const scimList = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { headers: scimHeaders }); assert(scimList.response.ok && Array.isArray(scimList.payload.Resources), "SCIM 用户列表读取失败"); const scimEmail = `scim-${Date.now()}@local.test`; const scimCreate = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { method: "POST", headers: scimHeaders, body: JSON.stringify({ userName: scimEmail, displayName: "SCIM 测试成员", active: true }) }); assert(scimCreate.response.status === 201 && scimCreate.payload.userName === scimEmail, "SCIM 用户创建失败"); const scimUserId = scimCreate.payload.id; const scimPatch = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users/${encodeURIComponent(scimUserId)}`, { method: "PATCH", headers: scimHeaders, body: JSON.stringify({ Operations: [{ op: "Replace", path: "active", value: false }] }) }); assert(scimPatch.response.ok && scimPatch.payload.active === false, "SCIM 用户停用失败"); const invalidScim = await request(`/scim/v2.0/${encodeURIComponent(directoryId)}/Users`, { headers: { authorization: "Bearer invalid-token" } }); assert(invalidScim.response.status === 401, "无效 SCIM 令牌必须被拒绝"); const cleanupMfa = await request("/api/auth/mfa/disable", { method: "POST", headers: { authorization: `Bearer ${enrollmentComplete.payload.session.token}` }, body: JSON.stringify({ currentPassword: "Demo@123456", code: totp(enrollmentSetup.payload.setup.secret) }) }); assert(cleanupMfa.response.ok && cleanupMfa.payload.enabled === false, "identity smoke cleanup MFA failed"); await request(`/api/system/identity/providers/${encodeURIComponent(smokeProvider.id)}`, { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ enabled: false }) }); await request("/api/system/identity/policy", { method: "PATCH", headers: ownerHeaders, body: JSON.stringify({ ssoEnabled: false, mfaRequiredForAdmins: false }) }); dbRun("DELETE FROM directory_sync_tokens WHERE directory_sync_id IN (SELECT id FROM directory_syncs WHERE name LIKE 'Smoke SCIM %')"); dbRun("DELETE FROM directory_syncs WHERE name LIKE 'Smoke SCIM %'"); dbRun("DELETE FROM identity_providers WHERE name LIKE 'Smoke OIDC %'"); dbRun("DELETE FROM users WHERE email = ?", [scimEmail]); console.log(`identity smoke passed: ${base}`);