Files
Arcrun/cypher-executor/tests/portal-auth.test.ts
T
uncle6me-web dd65b06a11 portal-auth P2(#24 #25):portal_user 模型+認證 API(不 merge,待總管審)
- lib/portal-auth.ts:PBKDF2-SHA256 600k(WebCrypto deriveBits)、pbkdf2-sha256$… 格式、
  常數時間比對(UI session 登入,console-auth 同類先例,非 workflow credential 原語)
- lib/portal-seeds.ts + init-seed:portal_user / portal_library template 冪等種入(零新表)
- routes/portal.ts:login/logout/session/me-password/bootstrap(console owner 閘)/
  admin users CRUD + reset-password / 庫目錄 CRUD;帳號資料全寫 {tenant}::portal 子 namespace
  (D-2)+ email head entry O(1) 查找(§2.3);session KV 只存 record_id、每請求回讀 record
  → 停用即時生效;絕不回租戶字串;登入節流 5 次/15 分(KV TTL)
- [vars] PORTAL_SESSION_TTL(預設 604800)+ types.ts
- 順修 kbdb record-crud updateRecord grow-slot 漏 owner_id(design §2.2)+回歸測試

驗證:kbdb 20/20、cypher 133/134(1 失敗 stash 複驗=pre-existing)、雙包 tsc 0;
本機雙 worker 端到端 33/33(含搜尋隔離實測:租戶 ns 搜 email/雜湊 0 筆、對照組
leo::portal 搜得到;明碼落地抽查 0 筆)。console-auth 既有行為未動;demo 8787/8788 未碰。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:10:03 +08:00

400 lines
18 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.
/**
* portal-auth P2 測試(design §2/§4/§5Gitea #24/#25
*
* 覆蓋(=tasks.md P2 測試項):
* 1. KDFpbkdf2-sha256$600000$… 格式、驗證對錯、壞格式誠實 false
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace
* 已有 admin → 409
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
* 4. 節流:5 次失敗 → 429KV TTL 計數)
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
* 6. 改密碼:驗舊密;新 hash 以 600k 格式落 slot
* 7. role 閘:非 admin 打 admin 端點 → 403admin 列表**剝除 password_hash**
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
const KBDB = 'https://kbdb.test';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
const EMAIL = 'user@example.com';
const PASSWORD = 'correct-horse-9';
// 測試用低迭代 hashverify 從儲存格式解析 iterations → 舊/低參數 hash 也驗得動=漸進遷移特性)
let storedHash: string;
beforeAll(async () => {
fetchMock.activate();
fetchMock.disableNetConnect();
storedHash = await hashPassword(PASSWORD, 10_000);
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
// ── KBDB mock helpers ──────────────────────────────────────────────────────
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1 */
function mockHeadLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
.get(KBDB)
.intercept({
path: (p: string) =>
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
})
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
}
function mockListByTemplate(template: string, records: { record_id: string; values: Record<string, string> }[]) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith(`/records/by-template/${template}`), method: 'GET' })
.reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl' })), count: records.length });
}
function mockTemplatesExist() {
for (const name of ['portal_user', 'portal_library']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
}
}
function activeUserValues(overrides: Record<string, string> = {}): Record<string, string> {
return {
email: EMAIL,
display_name: '測試同仁',
status: 'active',
role: 'user',
password_hash: storedHash,
libraries: '["general","finance"]',
created_at: '2026-07-14T00:00:00.000Z',
updated_at: '2026-07-14T00:00:00.000Z',
...overrides,
};
}
async function seedPortalSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
// ═══════════════ 1. KDF 單元 ═══════════════
describe('PBKDF2 模組(lib/portal-auth', () => {
it('hashPassword 預設格式 = pbkdf2-sha256$600000$salt$hash,且驗證通過', async () => {
const h = await hashPassword('some-password-123');
const parts = h.split('$');
expect(parts.length).toBe(4);
expect(parts[0]).toBe('pbkdf2-sha256');
expect(parts[1]).toBe(String(PBKDF2_ITERATIONS));
expect(PBKDF2_ITERATIONS).toBe(600_000);
expect(await verifyPassword('some-password-123', h)).toBe(true);
expect(await verifyPassword('wrong-password-x', h)).toBe(false);
});
it('壞格式 / 前綴不認得 / 被竄改 → false(誠實拒絕不拋錯)', async () => {
expect(await verifyPassword('x', '')).toBe(false);
expect(await verifyPassword('x', 'bcrypt$10$abc$def')).toBe(false);
expect(await verifyPassword('x', 'pbkdf2-sha256$notanumber$AA$BB')).toBe(false);
const tampered = storedHash.slice(0, -4) + 'AAA=';
expect(await verifyPassword(PASSWORD, tampered)).toBe(false);
});
});
// ═══════════════ 2. bootstrap 閘 ═══════════════
describe('POST /portal/admin/bootstrap', () => {
it('無 console owner session → 401,不碰 KBDB', async () => {
const res = await json('POST', '/portal/admin/bootstrap', { email: 'a@b.co', password: 'longenough' });
expect(res.status).toBe(401);
});
it('console session OK → 建第一個 adminrecord + head entry 都寫 {tenant}::portal 子 namespace', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []); // 尚無 admin
mockHeadLookup('admin@example.com', null); // email 未占用
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: {} } };
});
let headBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, (opts) => {
headBody = String(opts.body);
return { success: true, entry: { id: 'e_head' } };
});
const res = await json(
'POST',
'/portal/admin/bootstrap',
{ email: 'Admin@Example.com', password: 'bootstrap-pw-1', display_name: '管理員' },
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.success).toBe(true);
expect(data.record_id).toBe('rec_admin');
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string>; template: string };
expect(rec.template).toBe('portal_user');
expect(rec.owner_id).toBe(NS); // ← D-2 子 namespace 機械斷言
expect(rec.values.role).toBe('admin');
expect(rec.values.status).toBe('active');
expect(rec.values.libraries).toBe('["*"]');
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(recordBody).not.toContain('bootstrap-pw-1'); // 明碼絕不落 KBDB
const head = JSON.parse(headBody) as Record<string, string>;
expect(head.owner_id).toBe(NS);
expect(head.entry_type).toBe('portal_user');
expect(head.page_name).toBe('admin@example.com');
expect(head.content).toBe('rec_admin');
});
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', [{ record_id: 'rec_a', values: activeUserValues({ role: 'admin' }) }]);
const res = await json(
'POST',
'/portal/admin/bootstrap',
{ email: 'x@y.co', password: 'whatever-123' },
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(409);
});
});
// ═══════════════ 3. 登入對錯 ═══════════════
describe('POST /portal/login', () => {
it('成功:發 session token;回 display_name/role/libraries**無任何租戶字串欄位**', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.success).toBe(true);
expect(typeof data.session_token).toBe('string');
expect(data.display_name).toBe('測試同仁');
expect(data.role).toBe('user');
expect(data.libraries).toEqual(['general', 'finance']);
expect('tenant' in data).toBe(false); // design §3.3Portal 絕不下發租戶字串
expect(JSON.stringify(data)).not.toContain('leo'); // 連值都不含租戶字串
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
expect(sess).toBeTruthy();
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
});
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
const res = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-password' });
expect(res.status).toBe(401);
const raw = await env.SESSIONS_KV.get(`portal_lockfail:${EMAIL}`);
expect(raw).toBeTruthy();
expect((JSON.parse(raw!) as { count: number }).count).toBe(1);
});
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
mockHeadLookup('ghost@example.com', null);
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
expect(data.error).toBe('email 或密碼錯誤');
});
it('停用帳號 → 403(正確密碼也拒)', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues({ status: 'disabled' }));
const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(403);
});
it('節流:計數達 5 → 429,不碰 KBDB;正確密碼也擋', async () => {
await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 5 }), { expirationTtl: 900 });
const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(429);
});
it('第 5 次失敗後下一次直接 429(KV 計數累加)', async () => {
await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 4 }), { expirationTtl: 900 });
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
const res5 = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-again' });
expect(res5.status).toBe(401);
const res6 = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
expect(res6.status).toBe(429); // 第 6 次不再打 KBDB(無 pending interceptor 可證)
});
});
// ═══════════════ 4. session 回讀(真相源=record)═══════════════
describe('GET /portal/session', () => {
it('有效 session → 回 display_name/role/libraries,無租戶字串', async () => {
await seedPortalSession('tok-1', 'rec_1');
mockGetRecord('rec_1', activeUserValues());
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-1' });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.valid).toBe(true);
expect(data.libraries).toEqual(['general', 'finance']);
expect('tenant' in data).toBe(false);
});
it('帳號被停用 → 既有 session 立即失效(403)且 KV session 被清', async () => {
await seedPortalSession('tok-2', 'rec_1');
mockGetRecord('rec_1', activeUserValues({ status: 'disabled' }));
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-2' });
expect(res.status).toBe(403);
expect(await env.SESSIONS_KV.get('portal_sess:tok-2')).toBeNull();
});
it('無 token / 壞 token → 401', async () => {
expect((await json('GET', '/portal/session')).status).toBe(401);
expect((await json('GET', '/portal/session', undefined, { Authorization: 'Bearer nope' })).status).toBe(401);
});
});
// ═══════════════ 5. 改自己密碼 ═══════════════
describe('POST /portal/me/password', () => {
it('舊密碼錯 → 401,不發 PATCH', async () => {
await seedPortalSession('tok-3', 'rec_1');
mockGetRecord('rec_1', activeUserValues());
const res = await json(
'POST',
'/portal/me/password',
{ current: 'wrong-old', new: 'new-password-1' },
{ Authorization: 'Bearer tok-3' },
);
expect(res.status).toBe(401);
});
it('舊密碼對 → PATCH 新 hash600k 格式、非明碼、與舊 hash 不同)', async () => {
await seedPortalSession('tok-4', 'rec_1');
mockGetRecord('rec_1', activeUserValues());
let patched = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_1', method: 'PATCH' })
.reply(200, (opts) => {
patched = String(opts.body);
return { success: true, record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues() } };
});
const res = await json(
'POST',
'/portal/me/password',
{ current: PASSWORD, new: 'brand-new-pw-1' },
{ Authorization: 'Bearer tok-4' },
);
expect(res.status).toBe(200);
const sent = JSON.parse(patched) as { values: Record<string, string> };
expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(sent.values.password_hash).not.toBe(storedHash);
expect(patched).not.toContain('brand-new-pw-1'); // 明碼不落 KBDB
});
});
// ═══════════════ 6. role 閘+admin 列表剝敏 ═══════════════
describe('admin 端點 role 閘', () => {
it('一般 user 打 GET /portal/admin/users → 403', async () => {
await seedPortalSession('tok-5', 'rec_1');
mockGetRecord('rec_1', activeUserValues({ role: 'user' }));
const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-5' });
expect(res.status).toBe(403);
});
it('admin 列表 → 200 且**每筆都不含 password_hash**', async () => {
await seedPortalSession('tok-6', 'rec_admin');
mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
mockListByTemplate('portal_user', [
{ record_id: 'rec_admin', values: activeUserValues({ role: 'admin', email: 'admin@example.com' }) },
{ record_id: 'rec_1', values: activeUserValues() },
]);
const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-6' });
expect(res.status).toBe(200);
const data = (await res.json()) as { users: Record<string, unknown>[] };
expect(data.users.length).toBe(2);
for (const u of data.users) {
expect('password_hash' in u).toBe(false);
expect(Array.isArray(u.libraries)).toBe(true);
}
expect(JSON.stringify(data)).not.toContain('pbkdf2-sha256'); // 整包回應無雜湊外洩
});
it('admin 停用同仁:PATCH status=disabled → 經 head entry 成員驗證後改 record', async () => {
await seedPortalSession('tok-7', 'rec_admin');
mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
mockGetRecord('rec_1', activeUserValues()); // assertPortalUserRecord 回讀目標
mockHeadLookup(EMAIL, 'rec_1'); // head 指回同 record → 成員資格成立
let patched = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_1', method: 'PATCH' })
.reply(200, (opts) => {
patched = String(opts.body);
return {
success: true,
record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues({ status: 'disabled' }) },
};
});
const res = await json(
'PATCH',
'/portal/admin/users/rec_1',
{ status: 'disabled' },
{ Authorization: 'Bearer tok-7' },
);
expect(res.status).toBe(200);
const sent = JSON.parse(patched) as { values: Record<string, string> };
expect(sent.values.status).toBe('disabled');
const data = (await res.json()) as { user: { status: string } };
expect(data.user.status).toBe('disabled');
});
it('head entry 指向別的 record(成員資格不符)→ 404 不 PATCH', async () => {
await seedPortalSession('tok-8', 'rec_admin');
mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
mockGetRecord('rec_evil', activeUserValues({ email: EMAIL }));
mockHeadLookup(EMAIL, 'rec_1'); // head 指 rec_1 ≠ rec_evil
const res = await json(
'PATCH',
'/portal/admin/users/rec_evil',
{ status: 'disabled' },
{ Authorization: 'Bearer tok-8' },
);
expect(res.status).toBe(404);
});
});