Files
Arcrun/cypher-executor/tests/portal-auth.test.ts
T

417 lines
19 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$100000$… 格式(CF Workers runtime 上限 100k2026-07-14 真雲實撞)、
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
* 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 以 100k 格式落 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$100000$salt$hashCF runtime 上限),且驗證通過', 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));
// CF Workers 正式 runtime PBKDF2 封頂 100k600k 在真雲 deriveBits 直接拒絕 → bootstrap 500
expect(PBKDF2_ITERATIONS).toBe(100_000);
expect(PBKDF2_ITERATIONS).toBeLessThanOrEqual(100_000);
expect(await verifyPassword('some-password-123', h)).toBe(true);
expect(await verifyPassword('wrong-password-x', h)).toBe(false);
});
it('600k 舊 hash 相容:verify 從儲存值解析迭代數,仍驗得動(miniflare 無平台上限可造)', async () => {
// 誠實註:此相容性在 miniflare / 未來平台放寬時成立;真 CF runtime 對 deriveBits 一律封頂
// 100k600k hash 在真雲仍會被拒——但真雲 bootstrap 本身就 500,雲端不存在 600k hash,無遷移面。
const legacy = await hashPassword(PASSWORD, 600_000);
expect(legacy.startsWith('pbkdf2-sha256$600000$')).toBe(true);
expect(await verifyPassword(PASSWORD, legacy)).toBe(true);
expect(await verifyPassword('wrong-password-x', legacy)).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());
// P3session 多回 graph_allowedD-4)——非 ["*"] 用戶要查庫目錄算 graph 來源庫
mockListByTemplate('portal_library', []);
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);
// P3 能力欄位:來源庫預設 general、本用戶有 general → graph 放行;workflows 預設 admin-only
expect(data.graph_allowed).toBe(true);
expect(data.workflows_visible).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 新 hash(現行 100k 格式、非明碼、與舊 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);
});
});