405 lines
18 KiB
TypeScript
405 lines
18 KiB
TypeScript
/**
|
||
* portal-auth P2 測試(design §2/§4/§5,Gitea #24/#25)
|
||
*
|
||
* 覆蓋(=tasks.md P2 測試項):
|
||
* 1. KDF:pbkdf2-sha256$600000$… 格式、驗證對錯、壞格式誠實 false
|
||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace;
|
||
* 已有 admin → 409
|
||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
|
||
* 6. 改密碼:驗舊密;新 hash 以 600k 格式落 slot
|
||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||
*
|
||
* KBDB 打 fetchMock 假 host(wrangler.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';
|
||
|
||
// 測試用低迭代 hash(verify 從儲存格式解析 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 → 建第一個 admin:record + 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.3:Portal 絕不下發租戶字串
|
||
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());
|
||
// P3:session 多回 graph_allowed(D-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(600k 格式、非明碼、與舊 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);
|
||
});
|
||
});
|