From c0a390baa54e919d116c20e09b510b5eefb1ea5e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 18:57:23 +0800 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .gitignore | 1 + package.json | 3 +- tests/selfcheck.ts | 1772 -------------------------------------------- 3 files changed, 2 insertions(+), 1774 deletions(-) delete mode 100644 tests/selfcheck.ts diff --git a/.gitignore b/.gitignore index 7924ee7..9cd77ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .dev.vars wrangler.my.toml RELEASE_NOTES.md +tests/selfcheck.ts # Build output dist/ diff --git a/package.json b/package.json index 2487f54..7264213 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "scripts": { "dev": "wrangler dev -c wrangler.toml", "deploymy": "wrangler deploy -c wrangler.my.toml", - "deploy": "wrangler deploy", - "selfcheck": "npx tsx tests/selfcheck.ts" + "deploy": "wrangler deploy" }, "keywords": [ "bitwarden", diff --git a/tests/selfcheck.ts b/tests/selfcheck.ts deleted file mode 100644 index aa6dc17..0000000 --- a/tests/selfcheck.ts +++ /dev/null @@ -1,1772 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * ╔══════════════════════════════════════════════════════════════╗ - * ║ NodeWarden 自查程序 — Bitwarden API 兼容性全面诊断 ║ - * ╚══════════════════════════════════════════════════════════════╝ - * - * 功能:自动验证 NodeWarden 服务端的所有 API 端点,确保兼容 - * Bitwarden 全平台客户端(Windows / Android / iOS / 浏览器 - * 插件 / Linux / macOS / CLI)。 - * - * 核心特性: - * · 内置 Bitwarden 标准 KDF(PBKDF2-SHA256),输入明文密码即可 - * · 自动注册(全新实例)或自动登录(已有用户) - * · 空保管库锁定/解锁回归测试(历史 bug 场景) - * · JWT 内部声明验证(移动端依赖) - * · 多客户端平台兼容性验证(不同 client_id、设备头) - * · CORS 深度验证(浏览器插件依赖) - * · 覆盖全部已实现端点 + 未实现端点差距分析 - * · 响应结构合规性校验(字段、格式、嵌套结构) - * · 带颜色的分组输出 + 汇总报告 - * - * 用法: - * npx tsx tests/selfcheck.ts [服务器地址] [邮箱] [明文密码] - * - * 示例: - * npx tsx tests/selfcheck.ts http://localhost:8787 test@test.com testtesttest - * - * 也可以通过环境变量传入(优先级低于命令行参数): - * NW_URL=http://localhost:8787 - * NW_EMAIL=test@test.com - * NW_PASSWORD=testtesttest - * - * 注意: - * · 运行前请确保 NodeWarden 服务器已启动(npm run dev) - * · 自查会创建测试数据(文件夹、密码项等),测试结束后会自动清理 - * · 如果是全新数据库,会自动用提供的邮箱和密码注册第一个用户 - */ - -import { pbkdf2Sync, randomBytes } from 'node:crypto'; - -// ─── 配置 ─────────────────────────────────────────────────────────────────── -// 优先取命令行参数,其次取环境变量,最后用默认值 - -const BASE = (process.argv[2] || process.env.NW_URL || 'https://key.shuai.plus').replace(/\/+$/, ''); -const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'shuai@cock.li').toLowerCase(); -const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'rezwangul4qoxka@'); - -// ─── Bitwarden KDF ───────────────────────────────────────────────────────── -// Bitwarden 客户端在注册和登录时,不会把明文密码发给服务器。 -// 流程: -// 1. prelogin 获取 KDF 参数(kdfType, kdfIterations) -// 2. masterKey = PBKDF2-SHA256(password, salt=email, iterations, 32字节) -// 3. masterPasswordHash = Base64( PBKDF2-SHA256(masterKey, salt=password, 1次, 32字节) ) -// 4. 把 masterPasswordHash 发给服务器 -// -// 下面的函数实现了这套标准流程。 - -/** - * 计算 Bitwarden 的 masterPasswordHash - * @param password - 用户明文密码 - * @param email - 用户邮箱(小写,作为盐) - * @param kdfType - KDF 类型(0=PBKDF2, 1=Argon2id) - * @param iterations - KDF 迭代次数 - * @returns Base64 编码的 masterPasswordHash - */ -function computePasswordHash(password: string, email: string, kdfType: number, iterations: number): string { - if (kdfType !== 0) { - throw new Error(`不支持的 KDF 类型: ${kdfType}(仅支持 PBKDF2=0)`); - } - // 第一步:用邮箱作为盐,对密码做 PBKDF2 派生 → masterKey(32字节) - const masterKey = pbkdf2Sync(password, email, iterations, 32, 'sha256'); - // 第二步:用密码作为盐,对 masterKey 再做 1 次 PBKDF2 → 最终哈希 - const hash = pbkdf2Sync(masterKey, password, 1, 32, 'sha256'); - return hash.toString('base64'); -} - -/** - * 生成假的加密密钥(注册时占位用) - * 格式模拟 Bitwarden 客户端: "2.base64IV|base64Data|base64MAC" - */ -function generateFakeEncKey(): string { - const iv = randomBytes(16).toString('base64'); - const data = randomBytes(32).toString('base64'); - const mac = randomBytes(32).toString('base64'); - return `2.${iv}|${data}|${mac}`; -} - -/** - * 解码 JWT payload(不验证签名,仅用于检查声明字段) - * Bitwarden 移动端会在本地解码 JWT 检查 email_verified、amr 等字段 - */ -function decodeJwtPayload(token: string): Record | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - // JWT 的 base64url 编码需要转换为标准 base64 - let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - while (b64.length % 4) b64 += '='; - return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8')); - } catch { return null; } -} - -// ─── ANSI 颜色 ───────────────────────────────────────────────────────────── - -const c = { - reset : '\x1b[0m', - bold : '\x1b[1m', - dim : '\x1b[2m', - green : '\x1b[32m', - red : '\x1b[31m', - yellow: '\x1b[33m', - cyan : '\x1b[36m', - gray : '\x1b[90m', - white : '\x1b[97m', -}; - -// ─── 结果类型 ─────────────────────────────────────────────────────────────── - -type Status = 'PASS' | 'FAIL' | 'WARN' | 'SKIP'; - -interface TestResult { - group : string; - name : string; - status : Status; - detail? : string; - ms : number; -} - -// ─── 运行时状态 ───────────────────────────────────────────────────────────── - -let masterPasswordHash = ''; // 经 KDF 计算后的密码哈希 -let userEncKey = ''; // 用户加密密钥 -let accessToken = ''; // JWT 访问令牌 -let refreshToken = ''; // 刷新令牌 -let userId = ''; // 用户 ID -let testFolderId = ''; // 测试文件夹 ID -let testCipherId = ''; // 测试 Login 密码项 ID -let testCipher2Id = ''; // 测试 SecureNote 密码项 ID(将被永久删除) -let testAttachmentId = ''; // 测试附件 ID -let downloadToken = ''; // 附件下载令牌 -let isNewRegistration = false; - -// Track ALL test-created cipher and folder IDs so cleanup can permanently delete them. -// This prevents leftover undecryptable "[error: cannot decrypt]" items in the vault. -const allCreatedCipherIds: string[] = []; -const allCreatedFolderIds: string[] = []; - -const results: TestResult[] = []; - -// ─── HTTP 请求辅助 ───────────────────────────────────────────────────────── - -type FetchOpt = { - method? : string; - body? : any; - form? : Record; - auth? : boolean; - headers? : Record; -}; - -/** - * 统一 API 请求封装 - * @param path - 请求路径 - * @param opt - 选项:method、body(JSON)、form(表单)、auth(是否附加令牌)、headers - */ -async function api(path: string, opt: FetchOpt = {}): Promise<{ status: number; body: any; raw: Response }> { - const url = `${BASE}${path}`; - const headers: Record = { 'Accept': 'application/json', ...opt.headers }; - - if (opt.auth !== false && accessToken) { - headers['Authorization'] = `Bearer ${accessToken}`; - } - - let reqBody: string | undefined; - if (opt.form) { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - reqBody = new URLSearchParams(opt.form).toString(); - } else if (opt.body !== undefined) { - headers['Content-Type'] = 'application/json'; - reqBody = JSON.stringify(opt.body); - } - - const resp = await fetch(url, { method: opt.method || 'GET', headers, body: reqBody, redirect: 'manual' }); - let body: any; - const text = await resp.text(); - try { body = JSON.parse(text); } catch { body = text; } - return { status: resp.status, body, raw: resp }; -} - -// ─── 测试运行器 ───────────────────────────────────────────────────────────── - -let currentGroup = ''; - -function group(name: string) { - currentGroup = name; - console.log(`\n${c.bold}${c.cyan}━━ ${name} ━━${c.reset}`); -} - -async function test(name: string, fn: () => Promise<{ ok: boolean; detail?: string; warn?: boolean }>): Promise { - const t0 = performance.now(); - let status: Status = 'PASS'; - let detail: string | undefined; - try { - const r = await fn(); - if (r.warn) { - status = 'WARN'; - } else { - status = r.ok ? 'PASS' : 'FAIL'; - } - detail = r.detail; - } catch (e: any) { - status = 'FAIL'; - detail = e.message || String(e); - } - const ms = performance.now() - t0; - results.push({ group: currentGroup, name, status, detail, ms }); - const icon = { PASS: `${c.green}✔`, FAIL: `${c.red}✘`, WARN: `${c.yellow}⚠`, SKIP: `${c.gray}○` }[status]; - const time = `${c.dim}${ms.toFixed(0)}ms${c.reset}`; - const det = detail ? ` ${c.dim}${detail}${c.reset}` : ''; - console.log(` ${icon} ${c.reset}${name} ${time}${det}`); -} - -function skip(name: string, reason: string) { - results.push({ group: currentGroup, name, status: 'SKIP', detail: reason, ms: 0 }); - console.log(` ${c.gray}○ ${name} ${c.dim}${reason}${c.reset}`); -} - -// ─── 结构验证辅助 ────────────────────────────────────────────────────────── - -/** 检查对象是否包含指定的所有键,返回缺失键列表 */ -function hasKeys(obj: any, keys: string[]): string[] { - if (!obj || typeof obj !== 'object') return ['(不是对象)']; - return keys.filter(k => !(k in obj)); -} - -/** 验证 Bitwarden 列表格式 { data: [...], object: "list" } */ -function expectList(body: any, objectName = 'list'): { ok: boolean; detail?: string } { - const missing = hasKeys(body, ['data', 'object']); - if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; - if (body.object !== objectName) return { ok: false, detail: `object="${body.object}" 期望="${objectName}"` }; - if (!Array.isArray(body.data)) return { ok: false, detail: 'data 不是数组' }; - return { ok: true }; -} - -// ─── 客户端期望的关键响应字段清单 ────────────────────────────────────────── - -// Profile:全平台客户端都会读取这些字段 -const PROFILE_KEYS = [ - 'id', 'name', 'email', 'emailVerified', 'premium', 'key', 'privateKey', - 'securityStamp', 'organizations', 'providers', 'providerOrganizations', - 'twoFactorEnabled', 'forcePasswordReset', 'culture', 'object', 'creationDate', -]; - -// Cipher:密码项响应的完整字段 -const CIPHER_KEYS = [ - 'id', 'type', 'name', 'favorite', 'reprompt', 'edit', 'viewPassword', - 'creationDate', 'revisionDate', 'object', 'collectionIds', 'organizationId', - 'permissions', 'deletedDate', -]; - -const FOLDER_KEYS = ['id', 'name', 'revisionDate', 'object']; - -// Sync:全量同步的顶级字段 -const SYNC_KEYS = [ - 'profile', 'folders', 'collections', 'ciphers', 'domains', - 'policies', 'sends', 'object', 'UserDecryptionOptions', 'userDecryption', -]; - -// Token:登录/刷新响应的必需字段 -const TOKEN_KEYS = [ - 'access_token', 'expires_in', 'token_type', 'refresh_token', - 'Key', 'PrivateKey', 'Kdf', 'KdfIterations', 'scope', 'UserDecryptionOptions', -]; - -// ═══════════════════════════════════════════════════════════════════════════ -// 测试套件 -// ═══════════════════════════════════════════════════════════════════════════ - -// ─── 1. 服务器连通性 + Config 深度验证 ────────────────────────────────────── -// 验证服务器基础端点、Config 结构、favicon、DevTools 探针 - -async function suiteConnectivity() { - group('1 · 服务器连通性'); - - await test('GET /config 返回有效配置', async () => { - const { status, body } = await api('/config', { auth: false }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, ['version', 'environment', 'object']); - if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; - return { ok: body.object === 'config', detail: `版本 ${body.version}` }; - }); - - await test('GET /api/config(别名路径)', async () => { - const { status, body } = await api('/api/config', { auth: false }); - return { ok: status === 200 && body?.object === 'config' }; - }); - - // Config.environment 所有 URL 字段必须指向服务器自身 - // 客户端用这些 URL 构建后续请求地址 - await test('Config.environment URL 一致性', async () => { - const { body } = await api('/config', { auth: false }); - const env = body?.environment; - if (!env) return { ok: false, detail: 'environment 缺失' }; - const checks = [ - env.vault && env.vault.startsWith('http'), - env.api && env.api.includes('/api'), - env.identity && env.identity.includes('/identity'), - env.notifications && env.notifications.includes('/notifications'), - ]; - return { ok: checks.every(Boolean), detail: `vault=${env.vault}` }; - }); - - // featureStates 字段存在(客户端读取 feature flags) - await test('Config.featureStates 存在', async () => { - const { body } = await api('/config', { auth: false }); - return { ok: body?.featureStates && typeof body.featureStates === 'object' }; - }); - - await test('GET /api/version 返回版本字符串', async () => { - const { status, body } = await api('/api/version', { auth: false }); - return { ok: status === 200 && typeof body === 'string' && body.length > 0, detail: body }; - }); - - await test('GET /favicon.ico 返回 SVG 图标', async () => { - const resp = await fetch(`${BASE}/favicon.ico`); - const ct = resp.headers.get('content-type') || ''; - const text = await resp.text(); - return { ok: resp.status === 200 && ct.includes('svg') && text.includes(' { - const resp = await fetch(`${BASE}/favicon.svg`); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - await test('GET /.well-known DevTools 探针端点', async () => { - const { status } = await api('/.well-known/appspecific/com.chrome.devtools.json', { auth: false }); - return { ok: status === 200 }; - }); -} - -// ─── 2. CORS 深度验证 ────────────────────────────────────────────────────── -// 浏览器插件(Chrome/Firefox/Safari/Edge)依赖 CORS 头 -// 缺少任何必需头都会导致插件请求被浏览器拦截 - -async function suiteCors() { - group('2 · CORS 深度验证(浏览器插件必需)'); - - await test('OPTIONS / 返回 204 + CORS 头', async () => { - const resp = await fetch(`${BASE}/`, { - method: 'OPTIONS', - headers: { Origin: BASE }, - }); - const acao = resp.headers.get('access-control-allow-origin'); - return { ok: resp.status === 204 && acao === BASE }; - }); - - // 浏览器插件请求 /identity/connect/token 前会发 OPTIONS 预检 - await test('OPTIONS /identity/connect/token CORS 预检', async () => { - const resp = await fetch(`${BASE}/identity/connect/token`, { method: 'OPTIONS' }); - return { ok: resp.status === 204 }; - }); - - // 浏览器插件请求 /api/sync 前也会预检 - await test('OPTIONS /api/sync CORS 预检', async () => { - const resp = await fetch(`${BASE}/api/sync`, { method: 'OPTIONS' }); - return { ok: resp.status === 204 }; - }); - - // Access-Control-Allow-Headers 必须包含这些头 - // Bitwarden 客户端会发送 Device-Type、Bitwarden-Client-Name 等自定义头 - await test('CORS Allow-Headers 包含全部必需头', async () => { - const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); - const ah = (resp.headers.get('access-control-allow-headers') || '').toLowerCase(); - const required = ['authorization', 'content-type', 'accept', 'device-type', - 'bitwarden-client-name', 'bitwarden-client-version']; - const missing = required.filter(h => !ah.includes(h)); - return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : '全部包含' }; - }); - - // Allow-Methods 必须包含所有 HTTP 方法 - await test('CORS Allow-Methods 包含 GET/POST/PUT/DELETE', async () => { - const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); - const am = (resp.headers.get('access-control-allow-methods') || '').toUpperCase(); - const required = ['GET', 'POST', 'PUT', 'DELETE']; - const missing = required.filter(m => !am.includes(m)); - return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : undefined }; - }); - - // 实际 JSON 响应也必须带 CORS 头(不只是 OPTIONS) - await test('JSON 响应包含 Access-Control-Allow-Origin(同源)', async () => { - const resp = await fetch(`${BASE}/config`, { - headers: { Origin: BASE }, - }); - const acao = resp.headers.get('access-control-allow-origin'); - return { ok: acao === BASE }; - }); -} - -// ─── 3. 注册与设置 ────────────────────────────────────────────────────────── - -async function suiteRegistration() { - group('3 · 注册与设置'); - - await test('GET /setup/status 返回设置状态', async () => { - const { status, body } = await api('/setup/status', { auth: false }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - return { ok: 'registered' in body && 'disabled' in body, detail: `已注册=${body.registered}` }; - }); - - // 用默认 KDF 参数计算密码哈希(注册时用默认参数) - const defaultIter = 600000; - masterPasswordHash = computePasswordHash(PASSWORD, EMAIL, 0, defaultIter); - userEncKey = generateFakeEncKey(); - - await test('POST /api/accounts/register(单用户注册)', async () => { - const { status, body } = await api('/api/accounts/register', { - method: 'POST', auth: false, - body: { - email: EMAIL, name: EMAIL.split('@')[0], - masterPasswordHash, key: userEncKey, - kdf: 0, kdfIterations: defaultIter, kdfMemory: null, kdfParallelism: null, - keys: { publicKey: 'selfcheck-pubkey-placeholder', encryptedPrivateKey: 'selfcheck-privkey-placeholder' }, - }, - }); - if (status === 200) { isNewRegistration = true; return { ok: true, detail: '✓ 新用户创建成功' }; } - if (status === 403) { return { ok: true, detail: '已有用户注册(正常)' }; } - return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - }); - - await test('POST /api/accounts/register 重复注册 → 403', async () => { - const { status } = await api('/api/accounts/register', { - method: 'POST', auth: false, - body: { - email: 'duplicate@test.com', masterPasswordHash: 'x', key: 'x', - kdf: 0, kdfIterations: 600000, - keys: { publicKey: 'x', encryptedPrivateKey: 'x' }, - }, - }); - return { ok: status === 403, detail: `状态码=${status}` }; - }); -} - -// ─── 4. 认证 ── 多客户端 + JWT Claims + 边界条件 ─────────────────────────── -// 覆盖所有平台的登录行为差异 - -async function suiteAuth() { - group('4 · 认证(多平台登录 + JWT 声明)'); - - // 4.1 Prelogin - await test('POST /identity/accounts/prelogin 返回 KDF 参数', async () => { - const { status, body } = await api('/identity/accounts/prelogin', { - method: 'POST', auth: false, body: { email: EMAIL }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - // 用服务器返回的真实 KDF 参数重新计算密码哈希 - masterPasswordHash = computePasswordHash(PASSWORD, EMAIL, body.kdf, body.kdfIterations); - return { ok: true, detail: `kdf=${body.kdf} 迭代=${body.kdfIterations}` }; - }); - - // 防枚举:不存在的用户也返回默认参数 - await test('Prelogin 不存在的用户 → 返回默认参数(防枚举)', async () => { - const { status, body } = await api('/identity/accounts/prelogin', { - method: 'POST', auth: false, body: { email: 'nobody-exists@test.com' }, - }); - return { ok: status === 200 && body.kdf === 0 && body.kdfIterations === 600000 }; - }); - - // 4.2 密码登录(web client_id) - await test('密码登录 client_id=web', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'web', - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - const missing = hasKeys(body, TOKEN_KEYS); - if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - userEncKey = body.Key; - return { ok: true, detail: `有效期=${body.expires_in}s` }; - }); - - // 4.3 不同 client_id 登录(模拟各平台客户端) - // 浏览器插件用 browser,桌面端用 desktop,移动端用 mobile,CLI 用 cli - for (const cid of ['browser', 'desktop', 'mobile', 'cli']) { - await test(`密码登录 client_id=${cid}(${ - { browser: '浏览器插件', desktop: '桌面端', mobile: '移动端', cli: 'CLI' }[cid] - })`, async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: cid, - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - // 更新令牌到最新的 - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: !!body.access_token && !!body.Key }; - }); - } - - // 4.4 带设备头的登录(Android/iOS 会发送 deviceType、deviceName、deviceIdentifier) - await test('带设备头登录(Android 设备参数)', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'mobile', - deviceType: '0', deviceName: 'Android', deviceIdentifier: 'selfcheck-device-id', - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true }; - }); - - // 4.5 JSON 格式登录(部分第三方客户端用 JSON 而非 form-urlencoded) - await test('JSON 格式登录(非 form-urlencoded)', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - body: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'web', - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true }; - }); - - // 4.6 错误密码 - await test('错误密码 → 400 invalid_grant', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'password', username: EMAIL, password: 'wrong-hash' }, - }); - return { ok: status === 400 && body?.error === 'invalid_grant' }; - }); - - // 4.7 缺少字段 - await test('缺少 grant_type → 400', async () => { - const { status } = await api('/identity/connect/token', { - method: 'POST', auth: false, form: { username: EMAIL, password: 'x' }, - }); - return { ok: status === 400 }; - }); - - // 4.8 不支持的 grant_type - await test('grant_type=client_credentials → 400', async () => { - const { status } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'client_credentials', client_id: 'x', client_secret: 'x' }, - }); - return { ok: status === 400 }; - }); - - // 4.9 JWT 内部声明验证 - // 移动端(Android/iOS)会解码 JWT 检查这些字段,缺失会导致认证失败 - await test('JWT payload 包含 email_verified=true(移动端必需)', async () => { - const payload = decodeJwtPayload(accessToken); - if (!payload) return { ok: false, detail: 'JWT 解码失败' }; - return { ok: payload.email_verified === true, detail: `email_verified=${payload.email_verified}` }; - }); - - await test('JWT payload 包含 amr=["Application"](移动端必需)', async () => { - const payload = decodeJwtPayload(accessToken); - if (!payload) return { ok: false, detail: 'JWT 解码失败' }; - return { ok: Array.isArray(payload.amr) && payload.amr.includes('Application') }; - }); - - await test('JWT payload 包含 premium=true', async () => { - const payload = decodeJwtPayload(accessToken); - return { ok: payload?.premium === true }; - }); - - await test('JWT payload 包含 sub / email / sstamp / iss', async () => { - const payload = decodeJwtPayload(accessToken); - if (!payload) return { ok: false, detail: 'JWT 解码失败' }; - const missing = ['sub', 'email', 'sstamp', 'iss'].filter(k => !(k in payload)); - return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : undefined }; - }); - - // 4.10 Token 响应的 UserDecryptionOptions 深度验证 - await test('Token.UserDecryptionOptions 嵌套结构完整', async () => { - const { body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'password', username: EMAIL, password: masterPasswordHash }, - }); - accessToken = body.access_token; - refreshToken = body.refresh_token; - const udo = body?.UserDecryptionOptions; - if (!udo) return { ok: false, detail: 'UDO 缺失' }; - const mpu = udo.MasterPasswordUnlock; - if (!mpu) return { ok: false, detail: 'MasterPasswordUnlock 缺失' }; - const checks = [ - udo.HasMasterPassword === true, - mpu.Salt === EMAIL, - mpu.MasterKeyWrappedUserKey != null, - mpu.Kdf?.KdfType === 0, - mpu.Kdf?.Iterations === 600000 || mpu.Kdf?.Iterations > 0, - ]; - const failed = checks.filter(c => !c).length; - return { ok: failed === 0, detail: failed ? `${failed} 项检查失败` : `Salt=${mpu.Salt}` }; - }); -} - -// ─── 5. 令牌刷新完整性 ───────────────────────────────────────────────────── -// 令牌刷新是客户端后台自动行为,响应结构必须与登录一致 - -async function suiteRefresh() { - group('5 · 令牌刷新完整性'); - - if (!refreshToken) { skip('全部刷新测试', '无刷新令牌'); return; } - - // 保存旧 refresh_token 用于后续的复用测试 - const oldRefreshToken = refreshToken; - - await test('刷新令牌 → 返回全部字段', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, TOKEN_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true, detail: '令牌已轮换' }; - }); - - // 刷新响应必须包含 UserDecryptionOptions(Android 空 vault 解锁依赖此) - await test('刷新响应包含 UserDecryptionOptions', async () => { - // 用新的 refresh_token 再刷新一次 - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - const udo = body?.UserDecryptionOptions; - return { ok: !!udo && udo.HasMasterPassword === true && !!udo.MasterPasswordUnlock }; - }); - - // 刷新响应必须包含 Key 和 PrivateKey(桌面端重建加密上下文需要) - await test('刷新响应包含 Key 和 PrivateKey', async () => { - // 通过上一次测试已更新了 accessToken,直接检查最近的响应 - const { body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }); - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: body?.Key != null && typeof body.Key === 'string' && body.Key.length > 0 }; - }); - - // 安全性:旧的 refresh_token 不可复用(令牌轮换机制) - await test('旧 refresh_token 不可复用(令牌轮换安全性)', async () => { - const { status } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: oldRefreshToken }, - }); - return { ok: status === 400 || status === 401, detail: `状态码=${status}` }; - }); -} - -// ─── 6. 空保管库回归测试 ─────────────────────────────────────────────────── -// 【关键场景】用户报告的 bug:刚注册、没有任何密码项,锁定后解锁报错。 -// -// 复现路径:注册 → 登录 → sync(空数据)→ 锁定(前端丢弃密钥)→ 重新登录 -// 本套件模拟这个完整流程,验证空 vault 状态下所有核心端点正常工作。 -// -// 客户端锁定/解锁的本质: -// 锁定 = 前端丢弃内存中的 masterKey 和 encKey -// 解锁 = 用密码重新派生 masterKey → 调用 /identity/connect/token → 获取 Key → 解密 -// 所以 "解锁失败" 的根因通常是 Token 响应中 Key 为空或 UDO 结构不完整。 - -async function suiteEmptyVault() { - group('6 · 空保管库回归测试(锁定/解锁 bug 场景)'); - - if (!accessToken) { skip('全部空保管库测试', '未获取到访问令牌'); return; } - - // 6.1 空 vault sync — 最核心的测试 - await test('空 vault GET /api/sync 结构完整', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, SYNC_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - // 即使没有数据,数组字段也必须存在且为数组(不能是 null/undefined) - const arrays = ['folders', 'collections', 'ciphers', 'policies', 'sends']; - const nullArrays = arrays.filter(k => !Array.isArray(body[k])); - if (nullArrays.length) return { ok: false, detail: `非数组字段: ${nullArrays.join(', ')}` }; - return { ok: body.object === 'sync' }; - }); - - // 6.2 空 vault 下 ciphers 列表 - await test('空 vault GET /api/ciphers → 空列表', async () => { - const { status, body } = await api('/api/ciphers'); - const r = expectList(body); - if (!r.ok) return r; - // 注意:可能上次测试残留了数据,这里只验证格式正确 - return { ok: status === 200, detail: `数量=${body.data.length}` }; - }); - - // 6.3 空 vault 下 folders 列表 - await test('空 vault GET /api/folders → 空列表', async () => { - const { status, body } = await api('/api/folders'); - const r = expectList(body); - return { ok: status === 200 && r.ok, detail: `数量=${body.data.length}` }; - }); - - // 6.4 空 vault 下 revision-date 仍然有效 - await test('空 vault revision-date 有效(>0)', async () => { - const { status, body } = await api('/api/accounts/revision-date'); - return { ok: status === 200 && typeof body === 'number' && body > 0, detail: `时间戳=${body}` }; - }); - - // 6.5 Sync.UserDecryptionOptions 深度验证(PascalCase — 桌面端/浏览器插件) - await test('Sync.UserDecryptionOptions 嵌套结构(桌面端/浏览器插件)', async () => { - const { body } = await api('/api/sync'); - const udo = body?.UserDecryptionOptions; - if (!udo) return { ok: false, detail: 'UDO 缺失' }; - const mpu = udo.MasterPasswordUnlock; - if (!mpu) return { ok: false, detail: 'MasterPasswordUnlock 缺失' }; - // Salt 必须等于用户邮箱,否则客户端 KDF 计算会出错 - if (mpu.Salt !== EMAIL) return { ok: false, detail: `Salt="${mpu.Salt}" 期望="${EMAIL}"` }; - // MasterKeyWrappedUserKey 不能为 null(这是解锁的关键数据) - if (!mpu.MasterKeyWrappedUserKey) return { ok: false, detail: 'MasterKeyWrappedUserKey 为空' }; - // Kdf 结构 - if (!mpu.Kdf) return { ok: false, detail: 'Kdf 缺失' }; - if (typeof mpu.Kdf.KdfType !== 'number') return { ok: false, detail: 'Kdf.KdfType 缺失' }; - return { ok: true, detail: `KdfType=${mpu.Kdf.KdfType} Iterations=${mpu.Kdf.Iterations}` }; - }); - - // 6.6 Sync.userDecryption 深度验证(camelCase — Android 专用) - await test('Sync.userDecryption 嵌套结构(Android 客户端)', async () => { - const { body } = await api('/api/sync'); - const ud = body?.userDecryption; - if (!ud) return { ok: false, detail: 'userDecryption 缺失' }; - const mpu = ud.masterPasswordUnlock; - if (!mpu) return { ok: false, detail: 'masterPasswordUnlock 缺失' }; - if (mpu.salt !== EMAIL) return { ok: false, detail: `salt="${mpu.salt}" 期望="${EMAIL}"` }; - if (!mpu.masterKeyWrappedUserKey) return { ok: false, detail: 'masterKeyWrappedUserKey 为空' }; - if (!mpu.kdf) return { ok: false, detail: 'kdf 缺失' }; - if (typeof mpu.kdf.kdfType !== 'number') return { ok: false, detail: 'kdf.kdfType 缺失' }; - return { ok: true }; - }); - - // 6.7 Sync.domains 结构(不能为 null) - await test('Sync.domains 结构完整', async () => { - const { body } = await api('/api/sync'); - const d = body?.domains; - if (!d) return { ok: false, detail: 'domains 缺失' }; - return { - ok: d.object === 'domains' - && Array.isArray(d.equivalentDomains) - && Array.isArray(d.globalEquivalentDomains), - }; - }); - - // 6.8 模拟锁定后解锁(本质是重新登录 → 获取完整 Token 响应) - await test('模拟解锁:重新登录获取 Key(锁定/解锁核心)', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'web', - }, - }); - if (status !== 200) return { ok: false, detail: `登录失败 状态码=${status}` }; - // Key 必须非空且格式有效(这是解锁失败的常见根因) - if (!body.Key || typeof body.Key !== 'string' || body.Key.length < 10) { - return { ok: false, detail: `Key 无效: "${body.Key}"` }; - } - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true, detail: `Key 长度=${body.Key.length}` }; - }); - - // 6.9 解锁后 sync 正常 - await test('解锁后 sync 正常', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const ok = body?.object === 'sync' && body?.profile?.email === EMAIL; - return { ok }; - }); - - // 6.10 Profile 的 key 字段非空(解锁时用于初始化加密上下文) - await test('Profile.key 非空(加密上下文初始化依赖)', async () => { - const { body } = await api('/api/accounts/profile'); - return { ok: body?.key != null && typeof body.key === 'string' && body.key.length > 10 }; - }); -} - -// ─── 7. 账户端点 ──────────────────────────────────────────────────────────── - -async function suiteAccounts() { - group('7 · 账户端点'); - - if (!accessToken) { skip('全部账户测试', '未获取到访问令牌'); return; } - - await test('GET /api/accounts/profile 获取用户资料', async () => { - const { status, body } = await api('/api/accounts/profile'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, PROFILE_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - userId = body.id; - return { ok: body.object === 'profile' && body.email === EMAIL, detail: `id=${userId}` }; - }); - - // Profile 详细字段验证 - await test('Profile 字段类型正确', async () => { - const { body } = await api('/api/accounts/profile'); - const checks: [string, boolean][] = [ - ['emailVerified=true', body.emailVerified === true], - ['premium=true', body.premium === true], - ['twoFactorEnabled=bool', typeof body.twoFactorEnabled === 'boolean'], - ['forcePasswordReset=false', body.forcePasswordReset === false], - ['organizations=array', Array.isArray(body.organizations)], - ['providers=array', Array.isArray(body.providers)], - ['providerOrganizations=array', Array.isArray(body.providerOrganizations)], - ['culture=string', typeof body.culture === 'string'], - ]; - const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); - return { ok: failed.length === 0, detail: failed.length ? `失败: ${failed.join(', ')}` : undefined }; - }); - - await test('PUT /api/accounts/profile 更新用户资料', async () => { - const { status, body } = await api('/api/accounts/profile', { - method: 'PUT', body: { name: 'SelfCheck Updated', masterPasswordHint: null }, - }); - return { ok: status === 200 && body?.object === 'profile' }; - }); - - await test('POST /api/accounts/keys 更新密钥', async () => { - const current = await api('/api/accounts/profile'); - if (current.status !== 200 || !current.body?.key || !current.body?.privateKey) { - return { ok: false, detail: '无法读取当前 key/privateKey' }; - } - const { status, body } = await api('/api/accounts/keys', { - method: 'POST', - // Non-destructive roundtrip: submit current encrypted keys as-is. - body: { key: current.body.key, encryptedPrivateKey: current.body.privateKey }, - }); - return { ok: status === 200 && body?.object === 'profile' }; - }); - - await test('GET /api/accounts/revision-date 时间戳', async () => { - const { status, body } = await api('/api/accounts/revision-date'); - return { ok: status === 200 && typeof body === 'number' && body > 0, detail: `时间戳=${body}` }; - }); - - await test('POST /api/accounts/verify-password 正确密码 → 200', async () => { - const { status } = await api('/api/accounts/verify-password', { - method: 'POST', body: { masterPasswordHash }, - }); - return { ok: status === 200 }; - }); - - await test('POST /api/accounts/verify-password 错误密码 → 400', async () => { - const { status } = await api('/api/accounts/verify-password', { - method: 'POST', body: { masterPasswordHash: 'wrong-hash-value' }, - }); - return { ok: status === 400 }; - }); -} - -// ─── 8. 同步深度验证 ─────────────────────────────────────────────────────── - -async function suiteSync() { - group('8 · 同步深度验证'); - - if (!accessToken) { skip('全部同步测试', '未获取到访问令牌'); return; } - - await test('GET /api/sync 完整同步', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, SYNC_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - const pMissing = hasKeys(body.profile, PROFILE_KEYS); - if (pMissing.length) return { ok: false, detail: `profile 缺少: ${pMissing.join(', ')}` }; - return { ok: body.object === 'sync' }; - }); - - // Sync.profile 与独立 Profile 一致性 - await test('Sync.profile 与 GET /api/accounts/profile 一致', async () => { - const [sync, profile] = await Promise.all([api('/api/sync'), api('/api/accounts/profile')]); - const sp = sync.body?.profile; - const pp = profile.body; - return { ok: sp?.id === pp?.id && sp?.email === pp?.email && sp?.key === pp?.key }; - }); -} - -// ─── 9. 文件夹 CRUD ───────────────────────────────────────────────────────── - -async function suiteFolders() { - group('9 · 文件夹'); - - if (!accessToken) { skip('全部文件夹测试', '未获取到访问令牌'); return; } - - await test('POST /api/folders 创建', async () => { - const { status, body } = await api('/api/folders', { - method: 'POST', body: { name: '2.自查测试文件夹==' }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, FOLDER_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - testFolderId = body.id; - return { ok: body.object === 'folder', detail: `id=${testFolderId}` }; - }); - - await test('GET /api/folders 列表', async () => { - const { status, body } = await api('/api/folders'); - const r = expectList(body); - if (!r.ok) return r; - return { ok: body.data.length >= 1, detail: `数量=${body.data.length}` }; - }); - - await test('GET /api/folders/:id 单个', async () => { - if (!testFolderId) return { ok: false, detail: '无可用文件夹' }; - const { status, body } = await api(`/api/folders/${testFolderId}`); - return { ok: status === 200 && body?.id === testFolderId }; - }); - - await test('PUT /api/folders/:id 更新', async () => { - if (!testFolderId) return { ok: false, detail: '无可用文件夹' }; - const { status, body } = await api(`/api/folders/${testFolderId}`, { - method: 'PUT', body: { name: '2.更新后文件夹==' }, - }); - return { ok: status === 200 && body?.object === 'folder' }; - }); -} - -// ─── 10. 密码项 CRUD + 边界条件 ───────────────────────────────────────────── - -async function suiteCiphers() { - group('10 · 密码项(Ciphers)'); - - if (!accessToken) { skip('全部密码项测试', '未获取到访问令牌'); return; } - - // 记录创建前的 revision-date,用于后面验证递增 - let revDateBefore = 0; - { - const { body } = await api('/api/accounts/revision-date'); - if (typeof body === 'number') revDateBefore = body; - } - - // --- 创建:四种类型 --- - - await test('POST /api/ciphers 创建 Login 类型', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', - body: { - type: 1, name: '2.测试登录项==', notes: '2.备注内容==', - folderId: testFolderId || null, favorite: true, reprompt: 0, - login: { - username: '2.用户名==', password: '2.密码==', - uris: [{ uri: '2.https://example.com==', match: null }], totp: null, - }, - fields: [{ name: '2.自定义字段==', value: '2.值==', type: 0, linkedId: null }], - passwordHistory: [{ password: '2.旧密码==', lastUsedDate: new Date().toISOString() }], - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - const missing = hasKeys(body, CIPHER_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - testCipherId = body.id; - allCreatedCipherIds.push(body.id); - return { ok: body.object === 'cipher' && body.type === 1, detail: `id=${testCipherId}` }; - }); - - await test('POST /api/ciphers 创建 SecureNote', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', body: { type: 2, name: '2.安全笔记==', secureNote: { type: 0 }, reprompt: 0 }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - testCipher2Id = body.id; - allCreatedCipherIds.push(body.id); - return { ok: body.type === 2, detail: `id=${testCipher2Id}` }; - }); - - await test('POST /api/ciphers 创建 Card', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', - body: { - type: 3, name: '2.银行卡==', reprompt: 0, - card: { cardholderName: '2.持卡人==', number: '2.卡号==', brand: '2.Visa==', - expMonth: '2.01==', expYear: '2.2030==', code: '2.123==' }, - }, - }); - if (body?.id) allCreatedCipherIds.push(body.id); - return { ok: status === 200 && body?.type === 3 }; - }); - - await test('POST /api/ciphers 创建 Identity', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', - body: { - type: 4, name: '2.身份信息==', reprompt: 0, - identity: { firstName: '2.名==', lastName: '2.姓==', email: '2.邮箱==' }, - }, - }); - if (body?.id) allCreatedCipherIds.push(body.id); - return { ok: status === 200 && body?.type === 4 }; - }); - - // 部分客户端用 { cipher: {...} } 嵌套格式 - await test('POST /api/ciphers/create 嵌套格式', async () => { - const { status, body } = await api('/api/ciphers/create', { - method: 'POST', - body: { cipher: { type: 2, name: '2.嵌套创建==', secureNote: { type: 0 }, reprompt: 0 } }, - }); - if (body?.id) allCreatedCipherIds.push(body.id); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - // --- 响应字段深度验证 --- - - await test('Cipher 响应字段完整性', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { body } = await api(`/api/ciphers/${testCipherId}`); - const checks: [string, boolean][] = [ - ['organizationId=null', body.organizationId === null], - ['edit=true', body.edit === true], - ['viewPassword=true', body.viewPassword === true], - ['collectionIds=[]', Array.isArray(body.collectionIds) && body.collectionIds.length === 0], - ['permissions.delete=true', body.permissions?.delete === true], - ['permissions.restore=true', body.permissions?.restore === true], - ['deletedDate=null', body.deletedDate === null], - ]; - const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); - return { ok: failed.length === 0, detail: failed.length ? `失败: ${failed.join(', ')}` : undefined }; - }); - - // --- 读取 --- - - await test('GET /api/ciphers 列表', async () => { - const { status, body } = await api('/api/ciphers'); - const r = expectList(body); - if (!r.ok) return r; - return { ok: body.data.length >= 4, detail: `数量=${body.data.length}` }; - }); - - await test('GET /api/ciphers/:id 单个', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`); - return { ok: status === 200 && body?.id === testCipherId }; - }); - - await test('GET /api/ciphers/:id/details 详情', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/details`); - return { ok: status === 200 && body?.id === testCipherId }; - }); - - // --- 更新 --- - - await test('PUT /api/ciphers/:id 更新', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`, { - method: 'PUT', - body: { type: 1, name: '2.已更新==', reprompt: 0, - login: { username: '2.新用户名==', password: '2.新密码==', uris: [] } }, - }); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - // POST 方式更新(部分 Android 客户端行为) - await test('POST /api/ciphers/:id 更新(POST 别名)', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`, { - method: 'POST', - body: { type: 1, name: '2.POST更新==', reprompt: 0, - login: { username: '2.u==', password: '2.p==', uris: [] } }, - }); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - await test('PUT /api/ciphers/:id/partial 部分更新', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/partial`, { - method: 'PUT', body: { favorite: false, folderId: null }, - }); - return { ok: status === 200 && body?.favorite === false }; - }); - - await test('POST /api/ciphers/:id/share(单用户 stub)', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/share`, { method: 'POST', body: {} }); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - // --- revision-date 递增验证 --- - - await test('写操作后 revision-date 递增', async () => { - const { body } = await api('/api/accounts/revision-date'); - if (typeof body !== 'number') return { ok: false, detail: '返回非数字' }; - return { ok: body >= revDateBefore, detail: `前=${revDateBefore} 后=${body}` }; - }); - - // --- 软删除、恢复、永久删除 --- - - await test('DELETE /api/ciphers/:id 软删除', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`, { method: 'DELETE' }); - return { ok: status === 200 && body?.deletedDate != null }; - }); - - await test('PUT /api/ciphers/:id/restore 恢复', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/restore`, { method: 'PUT' }); - return { ok: status === 200 && body?.deletedDate === null }; - }); - - await test('PUT /api/ciphers/:id/delete 软删除(别名)', async () => { - if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipher2Id}/delete`, { method: 'PUT' }); - return { ok: status === 200 && body?.deletedDate != null }; - }); - - // 验证 deleted 过滤功能 - await test('GET /api/ciphers 默认不含已删除项', async () => { - const { body } = await api('/api/ciphers'); - const hasDeleted = body?.data?.some((c: any) => c.deletedDate != null); - return { ok: !hasDeleted, detail: hasDeleted ? '包含已删除项' : undefined }; - }); - - await test('GET /api/ciphers?deleted=true 包含已删除项', async () => { - const { body } = await api('/api/ciphers?deleted=true'); - // 至少有一个被软删除的项(testCipher2Id) - const hasDeleted = body?.data?.some((c: any) => c.deletedDate != null); - return { ok: body?.data?.length > 0 && hasDeleted, detail: `数量=${body?.data?.length}` }; - }); - - await test('DELETE /api/ciphers/:id/delete 永久删除', async () => { - if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; - const { status } = await api(`/api/ciphers/${testCipher2Id}/delete`, { method: 'DELETE' }); - return { ok: status === 204 || status === 200 }; - }); - - await test('永久删除后 → 404', async () => { - if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; - const { status } = await api(`/api/ciphers/${testCipher2Id}`); - return { ok: status === 404 }; - }); - - // --- 批量操作 --- - - await test('POST /api/ciphers/move 批量移动', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status } = await api('/api/ciphers/move', { - method: 'POST', body: { ids: [testCipherId], folderId: testFolderId || null }, - }); - return { ok: status === 204 || status === 200 }; - }); - - // PUT 也应该支持(部分桌面端行为) - await test('PUT /api/ciphers/move 批量移动(PUT 别名)', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status } = await api('/api/ciphers/move', { - method: 'PUT', body: { ids: [testCipherId], folderId: null }, - }); - return { ok: status === 204 || status === 200 }; - }); - - await test('POST /api/ciphers/import 批量导入', async () => { - const { status, body } = await api('/api/ciphers/import', { - method: 'POST', - body: { - ciphers: [{ type: 1, name: '2.导入项==', login: { username: '2.u==', password: '2.p==' }, reprompt: 0 }], - folders: [{ name: '2.导入文件夹==' }], - folderRelationships: [{ key: 0, value: 0 }], - }, - }); - // Track imported ciphers/folders for cleanup - if (body?.ciphers) for (const c of body.ciphers) { if (c?.id) allCreatedCipherIds.push(c.id); } - if (body?.folders) for (const f of body.folders) { if (f?.id) allCreatedFolderIds.push(f.id); } - return { ok: status === 200, detail: `状态码=${status}` }; - }); -} - -// ─── 11. 附件 ─────────────────────────────────────────────────────────────── - -async function suiteAttachments() { - group('11 · 附件'); - - if (!accessToken || !testCipherId) { skip('全部附件测试', '无可用令牌或密码项'); return; } - - // v2 端点(新版客户端标准流程) - await test('POST /api/ciphers/:id/attachment/v2 创建元数据', async () => { - const { status, body } = await api(`/api/ciphers/${testCipherId}/attachment/v2`, { - method: 'POST', body: { fileName: '2.测试文件.txt==', key: '2.附件密钥==', fileSize: 42 }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - testAttachmentId = body.attachmentId; - return { ok: !!testAttachmentId && body.object === 'attachment-fileUpload' && !!body.url, - detail: `id=${testAttachmentId}` }; - }); - - await test('POST /api/ciphers/:id/attachment/:aid 上传文件', async () => { - if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; - const formData = new FormData(); - formData.append('data', new Blob([new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])]), 'test.bin'); - const resp = await fetch(`${BASE}/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`, { - method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` }, body: formData, - }); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - // 上传后验证附件出现在 cipher 的 attachments 数组中 - await test('上传后 cipher.attachments 非空(Android 依赖)', async () => { - const { body } = await api(`/api/ciphers/${testCipherId}`); - const atts = body?.attachments; - if (!Array.isArray(atts) || atts.length === 0) return { ok: false, detail: 'attachments 为空' }; - const att = atts[0]; - // Android 要求 url 非 null,size 为数字 - const checks = [ - typeof att.url === 'string' && att.url.length > 0, - typeof att.size === 'number', - typeof att.fileName === 'string', - ]; - const ok = checks.every(Boolean); - return { ok, detail: ok ? `url=${att.url} size=${att.size}` : 'url/size 格式不符' }; - }); - - // 获取下载链接 - await test('GET /api/ciphers/:id/attachment/:aid 下载链接', async () => { - if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const ok = body.object === 'attachment' && typeof body.url === 'string' && body.url.includes('token='); - if (ok) { - const u = new URL(body.url); - downloadToken = u.searchParams.get('token') || ''; - } - return { ok }; - }); - - // 公开下载 - await test('GET /api/attachments/:cid/:aid?token= 公开下载', async () => { - if (!downloadToken) return { ok: false, detail: '无下载令牌' }; - const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}?token=${downloadToken}`); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - // 安全性:无 token 的下载应被拒绝 - await test('公开下载无 token → 401', async () => { - const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}`); - return { ok: resp.status === 401, detail: `状态码=${resp.status}` }; - }); - - // 安全性:无效 token 的下载应被拒绝 - await test('公开下载无效 token → 401', async () => { - const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}?token=invalid-garbage`); - return { ok: resp.status === 401, detail: `状态码=${resp.status}` }; - }); - - // 旧版附件端点(旧版桌面客户端用这个路径) - await test('POST /api/ciphers/:id/attachment 旧版端点', async () => { - const { status } = await api(`/api/ciphers/${testCipherId}/attachment`, { - method: 'POST', body: { fileName: '2.旧版附件==', key: '2.key==', fileSize: 10 }, - }); - // 路由器已路由到同一 handler,应返回 200 - return { ok: status === 200, detail: `状态码=${status}` }; - }); - - // 删除附件 - await test('DELETE /api/ciphers/:id/attachment/:aid 删除', async () => { - if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; - const { status } = await api(`/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`, { - method: 'DELETE', - }); - return { ok: status === 200, detail: `状态码=${status}` }; - }); -} - -// ─── 12. Stub 端点 + 通知 + 子路径 ───────────────────────────────────────── -// 这些端点没有完整实现,但客户端会请求它们 -// 必须返回正确格式的空数据,否则客户端报错 - -async function suiteStubs() { - group('12 · Stub 端点(客户端兼容性)'); - - if (!accessToken) { skip('全部 Stub 测试', '未获取到访问令牌'); return; } - - const stubs: [string, string, string][] = [ - ['GET', '/api/collections', 'Collections(集合)'], - ['GET', '/api/organizations', 'Organizations(组织)'], - ['GET', '/api/sends', 'Sends(安全发送)'], - ['GET', '/api/policies', 'Policies(策略)'], - ['GET', '/api/auth-requests', 'Auth Requests(认证请求)'], - ['GET', '/api/devices', 'Devices(设备)'], - ]; - - for (const [method, path, label] of stubs) { - await test(`${method} ${path} → 空列表 stub(${label})`, async () => { - const { status, body } = await api(path, { method }); - const r = expectList(body); - return { ok: status === 200 && r.ok && body.data.length === 0, detail: r.detail ? r.detail : 'stub 占位' }; - }); - } - - // Stub 子路径测试(客户端可能请求带 ID 的子路径) - const subPaths: [string, string][] = [ - ['/api/organizations/00000000-0000-0000-0000-000000000000', '组织子路径'], - ['/api/collections/00000000-0000-0000-0000-000000000000', '集合子路径'], - ['/api/sends/00000000-0000-0000-0000-000000000000', '发送子路径'], - ['/api/policies/00000000-0000-0000-0000-000000000000', '策略子路径'], - ]; - - for (const [path, label] of subPaths) { - await test(`GET ${path}(${label})→ 不崩溃`, async () => { - const { status } = await api(path); - // 200 空列表或 404 都可以接受,关键是不能 500 - return { ok: status !== 500, detail: `状态码=${status}` }; - }); - } - - // 域名设置 - await test('GET /api/settings/domains → domains 对象', async () => { - const { status, body } = await api('/api/settings/domains'); - return { - ok: status === 200 && body?.object === 'domains' - && Array.isArray(body.equivalentDomains) - && Array.isArray(body.globalEquivalentDomains), - }; - }); - - await test('PUT /api/settings/domains 更新', async () => { - const { status, body } = await api('/api/settings/domains', { - method: 'PUT', body: { equivalentDomains: [], globalEquivalentDomains: [] }, - }); - return { ok: status === 200 && body?.object === 'domains' }; - }); - - // POST 别名(旧版客户端) - await test('POST /api/settings/domains(POST 别名)', async () => { - const { status, body } = await api('/api/settings/domains', { - method: 'POST', body: { equivalentDomains: [], globalEquivalentDomains: [] }, - }); - return { ok: status === 200 && body?.object === 'domains' }; - }); - - // 通知端点 — 桌面端和浏览器插件启动时必调 - await test('GET /notifications/hub → 200', async () => { - const resp = await fetch(`${BASE}/notifications/hub`); - return { ok: resp.status === 200 }; - }); - - // POST /notifications/hub/negotiate — SignalR 协商(桌面端/浏览器插件) - // 客户端启动时会发 POST 请求进行 SignalR 握手 - await test('POST /notifications/hub/negotiate → 200(SignalR 协商)', async () => { - const resp = await fetch(`${BASE}/notifications/hub/negotiate`, { method: 'POST' }); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - // POST /notifications/hub — SignalR WebSocket 回退 - await test('POST /notifications/hub → 200(SignalR 回退)', async () => { - const resp = await fetch(`${BASE}/notifications/hub`, { method: 'POST' }); - return { ok: resp.status === 200 }; - }); - - // 带查询参数的通知路径 - await test('GET /notifications/hub?id=xxx → 200(长轮询)', async () => { - const resp = await fetch(`${BASE}/notifications/hub?id=some-connection-id`); - return { ok: resp.status === 200 }; - }); - - // 设备已知检查 - await test('GET /api/devices/knowndevice → "true"', async () => { - const resp = await fetch(`${BASE}/api/devices/knowndevice`); - const text = await resp.text(); - return { ok: resp.status === 200 && text.trim() === 'true' }; - }); - - // 带设备头的 knowndevice(iOS/Android 会附加这些头) - await test('GET /api/devices/knowndevice + Device 头', async () => { - const resp = await fetch(`${BASE}/api/devices/knowndevice`, { - headers: { - 'X-Device-Identifier': 'selfcheck-device', - 'X-Request-Email': Buffer.from(EMAIL).toString('base64'), - }, - }); - const text = await resp.text(); - return { ok: resp.status === 200 && (text.trim() === 'true' || text.trim() === 'false') }; - }); -} - -// ─── 13. 图标代理 ────────────────────────────────────────────────────────── - -async function suiteIcons() { - group('13 · 图标代理'); - - await test('GET /icons/google.com/icon.png', async () => { - const resp = await fetch(`${BASE}/icons/google.com/icon.png`); - return { ok: resp.status === 200 || resp.status === 204, detail: `状态码=${resp.status}` }; - }); -} - -// ─── 14. 认证守卫 ────────────────────────────────────────────────────────── - -async function suiteAuthGuard() { - group('14 · 认证守卫'); - - await test('GET /api/sync 无令牌 → 401', async () => { - const { status } = await api('/api/sync', { auth: false }); - return { ok: status === 401 }; - }); - - await test('GET /api/ciphers 无效令牌 → 401', async () => { - const { status } = await api('/api/ciphers', { - auth: false, headers: { 'Authorization': 'Bearer invalid.jwt.token' }, - }); - return { ok: status === 401 }; - }); - - await test('GET /api/accounts/profile 无令牌 → 401', async () => { - const { status } = await api('/api/accounts/profile', { auth: false }); - return { ok: status === 401 }; - }); - - await test('POST /api/ciphers 无令牌 → 401', async () => { - const { status } = await api('/api/ciphers', { - method: 'POST', auth: false, body: { type: 1, name: 'x', reprompt: 0 }, - }); - return { ok: status === 401 }; - }); -} - -// ─── 15. 被阻止端点完整验证 ──────────────────────────────────────────────── -// 单用户模式下禁止修改密码和删除账户 -// 路由器阻止了多个路径 × 多种 HTTP 方法 - -async function suiteBlocked() { - group('15 · 被阻止端点(单用户模式)'); - - if (!accessToken) { skip('全部阻止测试', '未获取到访问令牌'); return; } - - // POST 方法 - const blockedPaths = [ - '/api/accounts/password', - '/api/accounts/change-password', - '/api/accounts/set-password', - '/api/accounts/master-password', - '/api/accounts/delete', - '/api/accounts/delete-account', - '/api/accounts/delete-vault', - ]; - - for (const path of blockedPaths) { - await test(`POST ${path} → 501`, async () => { - const { status } = await api(path, { method: 'POST', body: {} }); - return { ok: status === 501, detail: `状态码=${status}` }; - }); - } - - // PUT 和 DELETE 也应该被阻止(路由器检查 POST|PUT|DELETE) - await test('PUT /api/accounts/password → 501', async () => { - const { status } = await api('/api/accounts/password', { method: 'PUT', body: {} }); - return { ok: status === 501, detail: `状态码=${status}` }; - }); - - await test('DELETE /api/accounts/delete → 501', async () => { - const { status } = await api('/api/accounts/delete', { method: 'DELETE' }); - return { ok: status === 501, detail: `状态码=${status}` }; - }); -} - -// ─── 16. 响应结构合规性 ──────────────────────────────────────────────────── - -async function suiteResponseSchema() { - group('16 · 响应结构合规性'); - - await test('错误响应符合 Bitwarden ErrorModel 格式', async () => { - const { body } = await api('/api/ciphers/00000000-0000-0000-0000-000000000000'); - const ok = body?.ErrorModel && body.ErrorModel.Object === 'error' && typeof body.ErrorModel.Message === 'string'; - return { ok: !!ok, detail: ok ? 'ErrorModel 正确' : `内容=${JSON.stringify(body).substring(0, 100)}` }; - }); - - await test('Identity 错误响应符合 OAuth2 格式', async () => { - const { body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'password', username: EMAIL, password: 'wrong-hash' }, - }); - return { ok: typeof body?.error === 'string' && typeof body?.error_description === 'string' }; - }); - - // 404 端点也应返回 JSON(不是纯文本) - await test('404 返回 JSON ErrorModel', async () => { - const { status, body } = await api('/api/nonexistent-endpoint-12345'); - return { - ok: status === 404 && body?.ErrorModel?.Object === 'error', - detail: typeof body === 'string' ? 'HTML/纯文本' : 'JSON', - }; - }); - - // 401 端点返回 JSON - await test('401 返回 JSON ErrorModel', async () => { - const { body } = await api('/api/sync', { auth: false }); - return { ok: body?.ErrorModel?.Object === 'error' }; - }); -} - -// ─── 17. 清理 ────────────────────────────────────────────────────────────── - -async function suiteCleanup() { - group('17 · 清理与最终验证'); - - if (!accessToken) { skip('清理', '未获取到访问令牌'); return; } - - // Permanently delete ALL test-created ciphers to avoid "[error: cannot decrypt]" leftovers. - // Collect any remaining ciphers from a sync in case some IDs were not tracked (e.g. import). - try { - const { body } = await api('/api/sync'); - if (body?.ciphers) { - for (const c of body.ciphers) { - if (c?.id && !allCreatedCipherIds.includes(c.id)) { - // Check if this cipher has a fake encrypted name (our test marker) - const n = c.name || ''; - if (n.startsWith('2.') && (n.endsWith('==') || n.endsWith('='))) { - allCreatedCipherIds.push(c.id); - } - } - } - // Also find orphan test folders from import - for (const f of (body.folders || [])) { - if (f?.id && f.id !== testFolderId && !allCreatedFolderIds.includes(f.id)) { - const n = f.name || ''; - if (n.startsWith('2.') && (n.endsWith('==') || n.endsWith('='))) { - allCreatedFolderIds.push(f.id); - } - } - } - } - } catch { /* best effort */ } - - // Delete all tracked ciphers - const cipherIds = [...new Set(allCreatedCipherIds)]; - if (cipherIds.length > 0) { - await test(`永久删除所有测试密码项 (${cipherIds.length} 个)`, async () => { - let deleted = 0; - for (const id of cipherIds) { - // Soft-delete first (required for permanent delete if not already soft-deleted) - await api(`/api/ciphers/${id}`, { method: 'DELETE' }).catch(() => {}); - const { status } = await api(`/api/ciphers/${id}/delete`, { method: 'DELETE' }); - if (status === 204 || status === 200) deleted++; - } - return { ok: deleted > 0, detail: `已删除 ${deleted}/${cipherIds.length}` }; - }); - } - - // Delete test folder - if (testFolderId) { - await test('DELETE /api/folders/:id 删除测试文件夹', async () => { - const { status } = await api(`/api/folders/${testFolderId}`, { method: 'DELETE' }); - return { ok: status === 204 || status === 200 }; - }); - - await test('文件夹删除后 → 404', async () => { - const { status } = await api(`/api/folders/${testFolderId}`); - return { ok: status === 404 }; - }); - } - - // Delete any extra test folders (from import etc.) - const extraFolderIds = [...new Set(allCreatedFolderIds)]; - if (extraFolderIds.length > 0) { - await test(`删除导入的测试文件夹 (${extraFolderIds.length} 个)`, async () => { - let deleted = 0; - for (const id of extraFolderIds) { - const { status } = await api(`/api/folders/${id}`, { method: 'DELETE' }); - if (status === 204 || status === 200) deleted++; - } - return { ok: true, detail: `已删除 ${deleted}/${extraFolderIds.length}` }; - }); - } - - await test('最终同步一致性检查', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - return { ok: true, detail: `密码项=${body.ciphers?.length ?? '?'} 文件夹=${body.folders?.length ?? '?'}` }; - }); -} - -// ─── 18. 缺失端点差距分析 ────────────────────────────────────────────────── -// 列出 Bitwarden 全客户端可能调用但 NodeWarden 尚未实现的端点 -// 200=已实现, 501=明确未实现, 404=未路由, 400=端点存在但缺参数, 其他=需关注 - -async function suiteGapAnalysis() { - group('18 · 缺失端点差距分析'); - - const gaps: [string, string, string][] = [ - ['POST', '/api/two-factor/get-authenticator', 'TOTP 两步验证'], - ['POST', '/api/two-factor/get-email', '邮件两步验证'], - ['POST', '/api/two-factor/get-duo', 'Duo 两步验证'], - ['POST', '/api/two-factor/get-webauthn', 'WebAuthn 两步验证'], - ['GET', '/api/emergency-access/trusted', '紧急访问(受信任)'], - ['GET', '/api/emergency-access/granted', '紧急访问(已授权)'], - ['POST', '/api/sends', '安全发送(创建)'], - ['POST', '/api/organizations', '组织(创建)'], - ['GET', '/api/accounts/billing', '账单信息'], - ['GET', '/api/accounts/subscription', '订阅信息'], - ['GET', '/api/accounts/tax', '税务信息'], - ['POST', '/api/accounts/api-key', 'API 密钥管理'], - ['POST', '/api/accounts/rotate-api-key', '轮换 API 密钥'], - ['POST', '/api/ciphers/purge', '清空保管库'], - ['POST', '/api/ciphers/bulk-delete', '批量删除'], - ['POST', '/api/ciphers/restore', '批量恢复'], - ['POST', '/api/folders/delete', '批量删除文件夹'], - ['GET', '/api/ciphers/organization-details', '组织密码项详情'], - ['POST', '/api/accounts/email-token', '修改邮箱'], - ['POST', '/api/accounts/verify-email', '验证邮箱'], - ['PUT', '/api/devices/identifier/x/token', '推送令牌注册'], - ['DELETE', '/api/push/token', '注销推送'], - ]; - - for (const [method, path, label] of gaps) { - await test(`${method} ${path}(${label})`, async () => { - const { status } = await api(path, { method, body: method !== 'GET' && method !== 'DELETE' ? {} : undefined }); - if (status === 200) return { ok: true, detail: '✓ 已实现' }; - if (status === 400) return { ok: true, detail: '✓ 端点存在(缺参数 400)' }; - // 未实现的端点 → 标记为 WARN(黄色),不算 PASS 也不算 FAIL - if (status === 501) return { warn: true, ok: false, detail: '未实现 (501)' }; - if (status === 404) return { warn: true, ok: false, detail: '未路由 (404)' }; - if (status === 401) return { warn: true, ok: false, detail: '需认证 (401)' }; - return { warn: true, ok: false, detail: `状态码 ${status}` }; - }); - } -} - -// ─── 19. 设置页面禁用 ────────────────────────────────────────────────────── - -async function suiteSetupDisable() { - group('19 · 设置页面禁用(单向操作)'); - - if (!isNewRegistration) { - skip('POST /setup/disable', '非全新注册,跳过此破坏性操作'); - skip('GET / 禁用后 → 404', '跳过'); - return; - } - - await test('POST /setup/disable 禁用设置页面', async () => { - const { status, body } = await api('/setup/disable', { method: 'POST', auth: false }); - return { ok: status === 200 && body?.success === true }; - }); - - await test('GET / 禁用后 → 404', async () => { - const resp = await fetch(`${BASE}/`); - return { ok: resp.status === 404 }; - }); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 汇总报告 -// ═══════════════════════════════════════════════════════════════════════════ - -function printSummary(): number { - const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIP: 0 }; - for (const r of results) counts[r.status]++; - const total = results.length; - const totalMs = results.reduce((s, r) => s + r.ms, 0); - - console.log(`\n${c.bold}${c.white}${'═'.repeat(60)}${c.reset}`); - console.log(`${c.bold} NodeWarden 自查报告${c.reset}`); - console.log(`${'═'.repeat(60)}`); - console.log(` ${c.green}通过 ${counts.PASS}${c.reset} │ ${c.red}失败 ${counts.FAIL}${c.reset} │ ${c.yellow}未实现 ${counts.WARN}${c.reset} │ ${c.gray}跳过 ${counts.SKIP}${c.reset} │ 总计 ${total}`); - console.log(` 耗时: ${(totalMs / 1000).toFixed(2)}s`); - console.log(`${'─'.repeat(60)}`); - - // 失败项 - const failures = results.filter(r => r.status === 'FAIL'); - if (failures.length) { - console.log(`\n${c.red}${c.bold} 失败项:${c.reset}`); - for (const f of failures) { - console.log(` ${c.red}✘ [${f.group}] ${f.name}${c.reset}`); - if (f.detail) console.log(` ${c.dim}${f.detail}${c.reset}`); - } - } - - // 未实现项 - const warns = results.filter(r => r.status === 'WARN'); - if (warns.length) { - console.log(`\n${c.yellow}${c.bold} 尚未实现的功能(${warns.length} 项):${c.reset}`); - for (const w of warns) { - console.log(` ${c.yellow}⚠ ${w.name}${c.reset} ${c.dim}${w.detail || ''}${c.reset}`); - } - } - - console.log(`\n${c.bold} 分组汇总:${c.reset}`); - const groups = new Map(); - for (const r of results) { - if (!groups.has(r.group)) groups.set(r.group, { pass: 0, fail: 0, warn: 0, total: 0 }); - const g = groups.get(r.group)!; - g.total++; - if (r.status === 'PASS') g.pass++; - if (r.status === 'FAIL') g.fail++; - if (r.status === 'WARN') g.warn++; - } - for (const [name, g] of groups) { - const icon = g.fail > 0 ? `${c.red}✘` : g.warn > 0 ? `${c.yellow}⚠` : `${c.green}✔`; - const warnStr = g.warn > 0 ? ` ${c.yellow}${g.warn} 未实现${c.reset}` : ''; - console.log(` ${icon} ${c.reset}${name} ${c.dim}(${g.pass}/${g.total})${c.reset}${warnStr}`); - } - - console.log(`\n${'═'.repeat(60)}`); - if (counts.FAIL === 0 && counts.WARN === 0) { - console.log(`${c.green}${c.bold} ✔ 全部检查通过!NodeWarden 兼容全平台 Bitwarden 客户端。${c.reset}`); - } else if (counts.FAIL === 0) { - console.log(`${c.green}${c.bold} ✔ 已实现功能全部通过!${c.reset}${c.yellow} ⚠ ${counts.WARN} 个端点尚未实现。${c.reset}`); - } else { - console.log(`${c.red}${c.bold} ✘ ${counts.FAIL} 项检查未通过,请查看上方详情。${c.reset}`); - } - console.log(`${'═'.repeat(60)}\n`); - - return counts.FAIL; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 主流程 -// ═══════════════════════════════════════════════════════════════════════════ - -async function main() { - console.log(`\n${c.bold}${c.cyan}╔${'═'.repeat(58)}╗${c.reset}`); - console.log(`${c.bold}${c.cyan}║ NodeWarden 自查程序 · Bitwarden API 兼容性全面诊断 ║${c.reset}`); - console.log(`${c.bold}${c.cyan}╚${'═'.repeat(58)}╝${c.reset}`); - console.log(`${c.dim} 服务器 : ${BASE}${c.reset}`); - console.log(`${c.dim} 邮箱 : ${EMAIL}${c.reset}`); - console.log(`${c.dim} 密码 : ${'*'.repeat(PASSWORD.length)}${c.reset}`); - console.log(`${c.dim} 时间 : ${new Date().toISOString()}${c.reset}`); - - try { await fetch(`${BASE}/config`); } catch (e: any) { - console.error(`\n${c.red} ✘ 无法连接到服务器 ${BASE}${c.reset}`); - console.error(`${c.dim} 请先启动 NodeWarden: npm run dev${c.reset}`); - console.error(`${c.dim} ${e.message}${c.reset}\n`); - process.exit(1); - } - - await suiteConnectivity(); // 1. 连通性 + Config 深度 - await suiteCors(); // 2. CORS 深度验证 - await suiteRegistration(); // 3. 注册与设置 - await suiteAuth(); // 4. 认证(多平台 + JWT Claims) - await suiteRefresh(); // 5. 令牌刷新完整性 - await suiteEmptyVault(); // 6. 空保管库回归测试 - await suiteAccounts(); // 7. 账户端点 - await suiteSync(); // 8. 同步深度验证 - await suiteFolders(); // 9. 文件夹 - await suiteCiphers(); // 10. 密码项 - await suiteAttachments(); // 11. 附件 - await suiteStubs(); // 12. Stub 端点 + 通知 - await suiteIcons(); // 13. 图标代理 - await suiteAuthGuard(); // 14. 认证守卫 - await suiteBlocked(); // 15. 被阻止端点 - await suiteResponseSchema(); // 16. 响应格式合规 - await suiteCleanup(); // 17. 清理 - await suiteGapAnalysis(); // 18. 缺失端点分析 - await suiteSetupDisable(); // 19. 设置页面禁用 - - const failCount = printSummary(); - process.exit(failCount > 0 ? 1 : 0); -} - -main().catch(e => { - console.error(`\n${c.red}致命错误: ${e.message || e}${c.reset}\n`); - process.exit(2); -});