Files
nodewarden/tests/selfcheck.ts
T
shuaiplus 2a747c996d feat(pagination): add pagination utility functions for handling page size and continuation tokens
- Introduced `PaginationRequest` interface to define pagination parameters.
- Implemented `parsePagination` function to extract and validate pagination parameters from a URL.
- Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens.
- Ensured that pagination respects maximum page size limits defined in configuration.
2026-02-18 20:59:46 +08:00

1773 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env npx tsx
/**
* ╔══════════════════════════════════════════════════════════════╗
* ║ NodeWarden 自查程序 — Bitwarden API 兼容性全面诊断 ║
* ╚══════════════════════════════════════════════════════════════╝
*
* 功能:自动验证 NodeWarden 服务端的所有 API 端点,确保兼容
* Bitwarden 全平台客户端(Windows / Android / iOS / 浏览器
* 插件 / Linux / macOS / CLI)。
*
* 核心特性:
* · 内置 Bitwarden 标准 KDFPBKDF2-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 派生 → masterKey32字节)
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<string, any> | 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<string, string>;
auth? : boolean;
headers? : Record<string, string>;
};
/**
* 统一 API 请求封装
* @param path - 请求路径
* @param opt - 选项:method、bodyJSON)、form(表单)、auth(是否附加令牌)、headers
*/
async function api(path: string, opt: FetchOpt = {}): Promise<{ status: number; body: any; raw: Response }> {
const url = `${BASE}${path}`;
const headers: Record<string, string> = { '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<void> {
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('<svg'), detail: ct };
});
await test('GET /favicon.svg 返回 SVG 图标', async () => {
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,移动端用 mobileCLI 用 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: '令牌已轮换' };
});
// 刷新响应必须包含 UserDecryptionOptionsAndroid 空 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 非 nullsize 为数字
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/domainsPOST 别名)', 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 → 200SignalR 协商)', 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 → 200SignalR 回退)', 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' };
});
// 带设备头的 knowndeviceiOS/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<string, { pass: number; fail: number; warn: number; total: number }>();
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);
});