diff --git a/modules/hello/test/hello.test.ts b/modules/hello/test/hello.test.ts index 9219689..b5e8f9e 100644 --- a/modules/hello/test/hello.test.ts +++ b/modules/hello/test/hello.test.ts @@ -3,56 +3,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { SignJWT, exportJWK, generateKeyPair } from 'jose'; import app from '../src/index'; +import { createModuleDb, type ModuleTestDb } from '../../../packages/module-sdk/test/test-factory'; /** * #13 测试:假 JWKS(本测内生成 ES256 对,服务 JWKS 端点)+ - * 内存假 D1(module_kv 表语义,与 #9 假 D1 同构)。 - * 验收链路:SDK 存储读写 hello 计数、跨前缀拒绝由 SDK 层保证(#9 用例), + * 真 SQLite module_kv(#60:假 D1 换真库——modules 统一迁移真建表,SDK 收口由真库裁决)。 + * 验收链路:SDK 存储读写 hello 计数、跨前缀拒绝由 SDK 层保证(#9/#60 用例), * 这里验证 HTTP 面验签/计数/生命周期骨架。 */ -/** 内存 module_kv(与 #9 存储模型一致:module_id 列隔离)。 */ -function makeDb(): D1Database & { _rows: Map> } { - const rows = new Map>(); - const prepare = (sql: string) => { - const chain = { - _args: [] as unknown[], - bind(...args: unknown[]) { - chain._args = args; - return chain; - }, - async first(): Promise { - const [moduleId, key] = chain._args as [string, string]; - if (sql.includes('SELECT value FROM')) { - const value = rows.get(moduleId)?.get(key); - return (value !== undefined ? { value } : null) as T | null; - } - return null; - }, - async all() { - const [moduleId] = chain._args as [string]; - const keys = rows.get(moduleId) ?? new Map(); - const results = [...keys.entries()] - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key]) => ({ key })); - return { results } as { results: T[] }; - }, - async run() { - const [moduleId, key, value] = chain._args as [string, string, string]; - if (sql.startsWith('INSERT INTO')) { - if (!rows.has(moduleId)) rows.set(moduleId, new Map()); - rows.get(moduleId)!.set(key, value); - } else if (sql.startsWith('DELETE FROM')) { - rows.get(moduleId)?.delete(key); - } - return { success: true }; - }, - }; - return chain; - }; - return { prepare, _rows: rows } as unknown as D1Database & { _rows: Map> }; -} - let privateKey: CryptoKey; /** 造 aud=hello 的合法模块 token。 */ @@ -65,22 +24,34 @@ async function makeToken(overrides: Record = {}): Promise> }; CORE_JWKS_URL: string }> { +async function envFor(): Promise<{ + MODULES_DB: D1Database; + CORE_JWKS_URL: string; + db: ModuleTestDb; +}> { const pair = await generateKeyPair('ES256', { extractable: true }); privateKey = pair.privateKey; const publicJwk = await exportJWK(pair.publicKey); const jwksBody = JSON.stringify({ keys: [{ ...publicJwk, use: 'sig', alg: 'ES256' }] }); const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL) => { - if (String(input).includes('/.well-known/jwks.json')) { - return new Response(jwksBody, { status: 200, headers: { 'content-type': 'application/json' } }); + globalThis.fetch = ( + async (input: RequestInfo | URL) => { + if (String(input).includes('/.well-known/jwks.json')) { + return new Response(jwksBody, { status: 200, headers: { 'content-type': 'application/json' } }); + } + throw new Error(`unexpected fetch ${String(input)}`); } - throw new Error(`unexpected fetch ${String(input)}`); - }) as typeof fetch; + ) as typeof fetch; + const db = createModuleDb(); afterEach(() => { globalThis.fetch = originalFetch; + db.close(); }); - return { MODULES_DB: makeDb(), CORE_JWKS_URL: 'https://core.example/.well-known/jwks.json' }; + return { + MODULES_DB: db.d1 as unknown as D1Database, + CORE_JWKS_URL: 'https://core.example/.well-known/jwks.json', + db, + }; } describe('module-hello(#13 垂直切片载体)', () => { @@ -120,12 +91,18 @@ describe('module-hello(#13 垂直切片载体)', () => { expect(await again.json()).toEqual({ count: 1 }); }); - it('计数写入落在 hello 子域(module_id=hello,SDK 收口)', async () => { + it('计数写入落在 hello 子域(真库直查 module_kv 行)', async () => { const env = await envFor(); const token = await makeToken(); await app.request('https://m.example/api/count', { method: 'POST', headers: { authorization: `Bearer ${token}` } }, env); - // SDK 键模型:module_kv(module_id='hello', key='counter') - expect(env.MODULES_DB._rows.get('hello')?.get('counter')).toBe('1'); + // SDK 键模型:module_kv(module_id='hello', key='counter')——直查真库行(不经适配器) + expect( + env.db.first<{ value: string }>( + 'SELECT value FROM module_kv WHERE module_id = ? AND key = ?', + 'hello', + 'counter', + )?.value, + ).toBe('1'); }); it('身份行数据源:claims 姓名/邮箱进入 token(验收 2 的数据面)', async () => { diff --git a/modules/hello/test/node-builtins.d.ts b/modules/hello/test/node-builtins.d.ts new file mode 100644 index 0000000..6e43a3f --- /dev/null +++ b/modules/hello/test/node-builtins.d.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * modules/hello 复用 package/module-sdk 的测试工厂(真 SQLite module_kv)。 + * 本文件把 module-sdk/test/node-builtins.d.ts 拉进 hello 的编译程序—— + * 两个工厂对 node:sqlite/fs/url 的最小声明与 @types/node 声明合并后, + * 跨包 import 的文件(module-sdk/test/test-factory.ts)即可通过 tsc --noEmit。 + */ +/// diff --git a/packages/module-sdk/test/node-builtins.d.ts b/packages/module-sdk/test/node-builtins.d.ts new file mode 100644 index 0000000..10e1262 --- /dev/null +++ b/packages/module-sdk/test/node-builtins.d.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * node 内置模块的最小类型声明(packages/module-sdk 的 tsconfig 不引 @types/node, + * 这里只声明测试工厂用到的三个内置模块,零新依赖)。 + */ +declare module 'node:sqlite' { + /** 预处理语句:参数位置绑定与 all/get/run 三个执行面。 */ + export interface StatementSync { + all(...params: unknown[]): Record[]; + get(...params: unknown[]): Record | undefined; + run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint }; + } + export class DatabaseSync { + constructor(path: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } +} + +declare module 'node:fs' { + export function readdirSync(path: string): string[]; + export function readFileSync(path: string, encoding: 'utf8'): string; +} + +declare module 'node:url' { + export function fileURLToPath(url: string | URL): string; +} diff --git a/packages/module-sdk/test/storage.test.ts b/packages/module-sdk/test/storage.test.ts index c187eb9..8118b74 100644 --- a/packages/module-sdk/test/storage.test.ts +++ b/packages/module-sdk/test/storage.test.ts @@ -1,135 +1,29 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -import { createD1Storage, type D1MinimalDatabase } from '../src/storage'; +import { createD1Storage } from '../src/storage'; +import { createModuleDb, type ModuleTestDb } from './test-factory'; /** - * 内存假 D1:Map 后备,实现 prepare/bind/first/all/run 最小面。 - * 只解释 createD1Storage 发出的固定 SQL 形态,不追求通用 SQL 引擎。 + * #9 / 审核 T8(#60):手搓假 D1 换成真 SQLite(node:sqlite + 真 module_kv 迁移)。 + * 假替身(MemoryStatement/MemoryDatabase)只按字符串 includes 解释 createD1Storage + * 发出的固定 SQL 形态——SQL 漏 WHERE module_id 过滤、LIKE/ESCAPE 写偏都能全绿。 + * 真库裁决后:LIKE/ESCAPE 语义由 SQLite 给出真值,并新增 T8 守护用例 + * (漏 module_id 过滤即红,见「T8 守护」组)。 */ -type Row = { module_id: string; key: string; value: string }; - -const LIKE_ESCAPE = '\\'; - -function escapeRegex(ch: string): string { - return ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** 把 LIKE 模式(支持 ESCAPE '\'、% 与 _)编译成正则源码。 */ -function likeToSource(pattern: string): string { - let out = ''; - for (let i = 0; i < pattern.length; i += 1) { - const ch = pattern[i]!; - if (ch === LIKE_ESCAPE) { - const next = pattern[i + 1]; - if (next === undefined) { - out += escapeRegex(LIKE_ESCAPE); - } else { - out += escapeRegex(next); - i += 1; - } - } else if (ch === '%') { - out += '.*'; - } else if (ch === '_') { - out += '.'; - } else { - out += escapeRegex(ch); - } - } - return out; -} - -class MemoryStatement { - private params: unknown[] = []; - - constructor( - private readonly db: MemoryDatabase, - private readonly sql: string, - ) {} - - bind(...values: unknown[]): MemoryStatement { - this.params = values; - return this; - } - - async first(): Promise { - const rows = this.select(); - if (this.sql.startsWith('SELECT value FROM')) { - const row = rows[0]; - return row ? ({ value: row.value } as T) : null; - } - return (rows[0] ?? null) as T | null; - } - - async all(): Promise<{ results: T[] }> { - const rows = this.select(); - const results = this.sql.startsWith('SELECT key FROM') - ? rows.map((row) => ({ key: row.key }) as T) - : (rows as unknown as T[]); - return { results }; - } - - async run(): Promise<{ success: boolean }> { - const [moduleId, key, value] = this.params as [string, string, string | undefined]; - if (this.sql.startsWith('INSERT INTO')) { - this.db.upsert(moduleId, key, value ?? ''); - } else if (this.sql.startsWith('DELETE FROM')) { - this.db.remove(moduleId, key); - } - return { success: true }; - } - - private select(): Row[] { - const [moduleId, firstParam] = this.params as [string, string | undefined]; - const rows = this.db.rowsOf(moduleId); - if (this.sql.includes('key = ?') && !this.sql.includes('LIKE')) { - return rows.filter((row) => row.key === firstParam); - } - if (this.sql.includes('LIKE')) { - // list(prefix) 绑定两个参数:(moduleId, 已转义的模式+%)。 - const regex = new RegExp(`^${likeToSource(firstParam ?? '')}$`); - return rows.filter((row) => regex.test(row.key)); - } - return rows; - } -} - -/** Map 后备的 moduleId → key → value。 */ -class MemoryDatabase implements D1MinimalDatabase { - private readonly data = new Map>(); - - prepare(sql: string): MemoryStatement { - return new MemoryStatement(this, sql); - } - - rowsOf(moduleId: string): Row[] { - const keys = this.data.get(moduleId); - if (!keys) { - return []; - } - return [...keys] - .map(([key, value]) => ({ module_id: moduleId, key, value })) - // 与 D1 的 ORDER BY key 对齐(ASCII 键下同序)。 - .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); - } - - upsert(moduleId: string, key: string, value: string): void { - let keys = this.data.get(moduleId); - if (!keys) { - keys = new Map(); - this.data.set(moduleId, keys); - } - keys.set(key, value); - } - - remove(moduleId: string, key: string): void { - this.data.get(moduleId)?.delete(key); - } -} - -describe('createD1Storage', () => { - const makeDb = (): MemoryDatabase => new MemoryDatabase(); - const hello = (db: MemoryDatabase) => createD1Storage({ db, moduleId: 'hello' }); +describe('createD1Storage(真 SQLite)', () => { + const OPEN: ModuleTestDb[] = []; + const makeDb = (): ModuleTestDb => { + const db = createModuleDb(); + OPEN.push(db); + return db; + }; + afterEach(() => { + for (const db of OPEN) db.close(); + OPEN.length = 0; + }); + + const hello = (db: ModuleTestDb) => createD1Storage({ db: db.d1, moduleId: 'hello' }); it('get 在键不存在时返回 null', async () => { await expect(hello(makeDb()).get('counter')).resolves.toBeNull(); @@ -146,7 +40,7 @@ describe('createD1Storage', () => { await expect(storage.list()).resolves.toEqual([]); }); - it('put 覆盖写', async () => { + it('put 覆盖写(真 PK 上的 upsert:ON CONFLICT 命中才覆盖)', async () => { const storage = hello(makeDb()); await storage.put('counter', '1'); await storage.put('counter', '2'); @@ -173,12 +67,14 @@ describe('createD1Storage', () => { await expect(storage.list('none')).resolves.toEqual([]); }); - it('list 的前缀 LIKE 按字面处理通配符(% 与 _ 不生效)', async () => { + it('list 的前缀 LIKE 按字面处理通配符(% 与 _ 不生效)——真 SQLite 裁决', async () => { const storage = hello(makeDb()); await storage.put('v%', 'pct'); await storage.put('v_', 'under'); await storage.put('vx', 'plain'); + // ORDER BY key 下 'v%'(0x25) < 'v_'(0x5F) < 'vx',与 ASCII 排序一致 await expect(storage.list('v')).resolves.toEqual(['v%', 'v_', 'vx']); + // ESCAPE '\':% 与 _ 在任意位置都只匹配字面量(含前缀 '%'、'_' 字面查询) await expect(storage.list('v%')).resolves.toEqual(['v%']); await expect(storage.list('v_')).resolves.toEqual(['v_']); }); @@ -203,8 +99,8 @@ describe('createD1Storage', () => { it('不同 moduleId 互不可见(同库并存)', async () => { const db = makeDb(); - const a = createD1Storage({ db, moduleId: 'mod-a' }); - const b = createD1Storage({ db, moduleId: 'mod-b' }); + const a = createD1Storage({ db: db.d1, moduleId: 'mod-a' }); + const b = createD1Storage({ db: db.d1, moduleId: 'mod-b' }); await a.put('shared', 'a-value'); await b.put('shared', 'b-value'); await expect(a.get('shared')).resolves.toBe('a-value'); @@ -217,20 +113,63 @@ describe('createD1Storage', () => { }); it('非法 moduleId / 表名在创建时抛错', () => { - expect(() => createD1Storage({ db: makeDb(), moduleId: '' })).toThrow('非法 moduleId'); - expect(() => createD1Storage({ db: makeDb(), moduleId: 'has space' })).toThrow('非法 moduleId'); + expect(() => createD1Storage({ db: makeDb().d1, moduleId: '' })).toThrow('非法 moduleId'); + expect(() => createD1Storage({ db: makeDb().d1, moduleId: 'has space' })).toThrow('非法 moduleId'); expect(() => - createD1Storage({ db: makeDb(), moduleId: 'hello', table: 'x; DROP TABLE module_kv' }), + createD1Storage({ db: makeDb().d1, moduleId: 'hello', table: 'x; DROP TABLE module_kv' }), + ).toThrow('非法表名'); + expect(() => + createD1Storage({ db: makeDb().d1, moduleId: 'hello', table: 'module-kv' }), ).toThrow('非法表名'); - expect(() => createD1Storage({ db: makeDb(), moduleId: 'hello', table: 'module-kv' })).toThrow( - '非法表名', - ); }); - it('自定义表名可用', async () => { + it('自定义表名可用(先在建真库建表再经 SDK 使用)', async () => { const db = makeDb(); - const storage = createD1Storage({ db, moduleId: 'hello', table: 'mod_kv_2' }); + db.run( + 'CREATE TABLE mod_kv_2 (module_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(module_id, key))', + ); + const storage = createD1Storage({ db: db.d1, moduleId: 'hello', table: 'mod_kv_2' }); await storage.put('k', 'v'); await expect(storage.get('k')).resolves.toBe('v'); + await expect(storage.list()).resolves.toEqual(['k']); + }); + + describe('T8 守护:真库裁决 SDK SQL(漏 WHERE module_id 即红)', () => { + it('同 key 两模块直插后:list 只回本模块 key、get 只回本模块值', async () => { + const db = makeDb(); + // 直插两模块同 key 行(不经 SDK,绕开子域收口,模拟真库共存数据) + db.run( + "INSERT INTO module_kv (module_id, key, value) VALUES ('mod-a','k','a'),('mod-b','k','b')", + ); + const a = createD1Storage({ db: db.d1, moduleId: 'mod-a' }); + // listSql 若漏 WHERE module_id → 两行都回(['k','k']),此断言必红 + await expect(a.list()).resolves.toEqual(['k']); + // selectSql(get)若漏 WHERE module_id → 命中 mod-b 行('b'),此断言必红 + await expect(a.get('k')).resolves.toBe('a'); + }); + + it("真建表列:columns()==['module_id','key','value'],SELECT * 行结构与之一致", async () => { + const db = makeDb(); + expect(db.columns('module_kv')).toEqual(['module_id', 'key', 'value']); + db.run("INSERT INTO module_kv (module_id, key, value) VALUES ('mod-a','k','a')"); + const row = db.first('SELECT * FROM module_kv'); + expect(Object.keys(row ?? {})).toEqual(db.columns('module_kv')); + }); + + it('真 PK 约束:同 (module_id,key) 二次裸 INSERT 抛错(upsert 才允许覆盖)', async () => { + const db = makeDb(); + db.run("INSERT INTO module_kv (module_id, key, value) VALUES ('mod-a','k','a')"); + expect(() => + db.run("INSERT INTO module_kv (module_id, key, value) VALUES ('mod-a','k','dup')"), + ).toThrow(); + // 首次写入的行未被破坏 + expect( + db.first<{ value: string }>( + 'SELECT value FROM module_kv WHERE module_id = ? AND key = ?', + 'mod-a', + 'k', + )?.value, + ).toBe('a'); + }); }); }); diff --git a/packages/module-sdk/test/test-factory.ts b/packages/module-sdk/test/test-factory.ts new file mode 100644 index 0000000..cb056e2 --- /dev/null +++ b/packages/module-sdk/test/test-factory.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * module-sdk 共享测试工厂:真 SQLite(node:sqlite,Node≥22 内置,零新依赖) + * 加载 modules 统一迁移 `modules/hello/migrations/hello/*.sql` 真建表(module_kv) + * + 最小 D1 适配器。与 services/core-api/test/test-factory.ts 同范式但各自独立小工厂 + * (core-api 与 module-sdk 不互相 import,避免包间耦合)。 + * + * 为什么不用手搓假 D1(审核 T8 / #60):假替身只按字符串 includes 解释 + * createD1Storage 发出的固定 SQL 形态——源码 SQL 漏 WHERE module_id 过滤、 + * LIKE/ESCAPE 语义写偏、引用幻影列都能全绿。真 SQLite 加载真迁移后: + * SQL 漏过滤 → 跨模块行当场串味(守护用例);查询列与建表列错位 → 当场抛错。 + * + * 适配器对齐真 D1 语义(@cloudflare/workers-types 的 D1PreparedStatement 子集): + * - `prepare(sql).bind(...).first()/all()/run()` 链式; + * - `all()` 回 `{ results, success, meta }`,`run()` 回 `meta.changes`/`meta.last_row_id`; + * - `first()` 无行回 null,支持 `first('col')` 取单列; + * - 行是普通对象(node:sqlite 返回 null 原型,D1 返回普通对象)。 + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'node:url'; + +import type { D1MinimalDatabase } from '../src/storage'; + +/** node:sqlite 的 null 原型行 → 普通对象(与真 D1 返回形状一致)。 */ +function plainRow(row: Record): T { + return { ...row } as T; +} + +/** D1 run/exec 的 meta 形状(workers-types D1Meta 的完整字段集)。 */ +interface D1Meta { + duration: number; + size_after: number; + rows_read: number; + rows_written: number; + last_row_id: number; + changed_db: boolean; + changes: number; +} + +/** D1 执行结果形状(适配器/SDK 双面用)。 */ +interface D1Result { + results: T[]; + success: boolean; + meta: D1Meta & Record; +} + +function d1Meta(changes: number, lastRowId: number): D1Meta & Record { + return { + duration: 0, + size_after: 0, + rows_read: 0, + rows_written: changes, + last_row_id: lastRowId, + changed_db: changes > 0, + changes, + }; +} + +/** 只读语句判定:真 D1 对 SELECT 的 run() 回 changes=0。 */ +const READ_ONLY_SQL = /^\s*(select|with|pragma)\b/i; + +/** 单条语句的 D1 适配(bind 后 first/all/run)。 */ +class SqliteD1Statement { + private params: unknown[] = []; + + constructor( + private readonly sqlite: DatabaseSync, + private readonly sql: string, + ) {} + + bind(...values: unknown[]): this { + this.params = values; + return this; + } + + async first(colName?: string): Promise { + const row = this.sqlite.prepare(this.sql).get(...this.params); + if (row === undefined) { + return null; + } + if (colName !== undefined) { + return (row[colName] as T) ?? null; + } + return plainRow(row); + } + + async all(): Promise> { + const rows = this.sqlite + .prepare(this.sql) + .all(...this.params) + .map((row) => plainRow(row)); + return { results: rows, success: true, meta: d1Meta(0, 0) }; + } + + async run(): Promise> { + if (READ_ONLY_SQL.test(this.sql)) { + this.sqlite.prepare(this.sql).all(...this.params); + return { results: [] as T[], success: true, meta: d1Meta(0, 0) }; + } + const info = this.sqlite.prepare(this.sql).run(...this.params); + return { + results: [] as T[], + success: true, + meta: d1Meta(Number(info.changes), Number(info.lastInsertRowid)), + }; + } +} + +/** 把 node:sqlite 连接包成 D1MinimalDatabase(SDK 的最小结构面)。 */ +export function createD1Adapter(sqlite: DatabaseSync): D1MinimalDatabase { + return { + prepare: (sql: string) => new SqliteD1Statement(sqlite, sql), + } satisfies D1MinimalDatabase; +} + +/** + * 按文件名顺序执行目录下全部 *.sql 迁移(真建表)。返回已应用文件名。 + * module_kv 的真 schema 来自 modules/hello/migrations/hello/0001_module_kv.sql—— + * SDK 的模块存储基础设施表全模块共享,schema 真值唯一,测试不得另抄一份。 + */ +export function applyMigrations(sqlite: DatabaseSync, dir: string): string[] { + const files = readdirSync(dir) + .filter((file) => file.endsWith('.sql')) + .sort(); + for (const file of files) { + sqlite.exec(readFileSync(`${dir}/${file}`, 'utf8')); + } + return files; +} + +/** 测试库句柄:D1 绑定 + 直查真库的断言通道 + 列名查询(守护用例)。 */ +export interface ModuleTestDb { + /** 传给 `createD1Storage({ db, ... })` 的 MODULES_DB 绑定。 */ + d1: D1MinimalDatabase; + /** 直查真库(断言真实行,不经适配器)。 */ + query>(sql: string, ...params: unknown[]): T[]; + /** 直查单行。 */ + first>(sql: string, ...params: unknown[]): T | null; + /** 直接执行(种子数据 / 建自定义表等)。 */ + run(sql: string, ...params: unknown[]): { changes: number }; + /** 建表列名(守护用例:查询列 ↔ 建表列错位即红)。 */ + columns(table: string): string[]; + /** 底层句柄(高级用例用)。 */ + sqlite: DatabaseSync; + close(): void; +} + +/** + * 建一个加载真实 module_kv 迁移的内存库。 + * 每个用例各自 `createModuleDb()`,互不共享状态(并发/顺序都不串味)。 + */ +export function createModuleDb(): ModuleTestDb { + const sqlite = new DatabaseSync(':memory:'); + applyMigrations( + sqlite, + fileURLToPath(new URL('../../../modules/hello/migrations/hello/', import.meta.url)), + ); + const d1 = createD1Adapter(sqlite); + return { + d1, + sqlite, + query>(sql: string, ...params: unknown[]): T[] { + return sqlite + .prepare(sql) + .all(...params) + .map((row) => plainRow(row)); + }, + first>(sql: string, ...params: unknown[]): T | null { + const row = sqlite.prepare(sql).get(...params); + return row === undefined ? null : plainRow(row); + }, + run(sql: string, ...params: unknown[]) { + const info = sqlite.prepare(sql).run(...params); + return { changes: Number(info.changes) }; + }, + columns(table: string): string[] { + return sqlite + .prepare('SELECT name FROM pragma_table_info(?)') + .all(table) + .map((row) => String((row as { name: unknown }).name)); + }, + close() { + sqlite.close(); + }, + }; +} diff --git a/services/core-api/test/auth-routes.test.ts b/services/core-api/test/auth-routes.test.ts index 94057f2..5d5d595 100644 --- a/services/core-api/test/auth-routes.test.ts +++ b/services/core-api/test/auth-routes.test.ts @@ -4,12 +4,16 @@ import { generateKeyPair, SignJWT, exportJWK, type JWK } from 'jose'; import app from '../src/index'; import { resetOidcCaches } from '../src/oidc'; +import { createCoreDb, type CoreTestDb } from './test-factory'; /** * 假身份源:拦截全局 fetch(discovery/token/jwks),签发真 RS256 id_token。 * 覆盖 GET /api/auth/login → 302 授权页、GET /api/auth/callback → 签会话、 * POST /api/auth/logout → 清 Cookie、GET /api/me → 会话态、 * POST /api/oidc/test-connection → 服务端探测。 + * + * CORE_DB 用共享 test-factory 的真 SQLite(migrations/core/*.sql 真建表): + * 回调 JIT 建档真写 users 表,SUCCESS/UNIQUE 约束为真约束。 */ const ISSUER = 'https://idp.example.com'; const JWKS_URI = `${ISSUER}/jwks`; @@ -71,38 +75,36 @@ function installFakeIdp(idToken: string) { }; } -function env(): Record { +/** + * 用例环境:真 SQLite(createCoreDb,加载 migrations/core/*.sql)。 + * db 暴露给用例查真库(JIT 建档守卫断言),e 即 app.request 的 env。 + */ +function env(): { e: Record; db: CoreTestDb } { + const db = createCoreDb(); + return { e: { CORE_DB: db.d1, JWT_PRIVATE_KEY: undefined }, db }; +} + +/** OIDC 配置齐备(环境变量兜底路径)的请求环境。 */ +function oidcEnv(base: Record): Record { return { - CORE_DB: { - prepare: (_sql: string) => { - const chain = { - bind: () => chain, - first: async () => null, - all: async () => ({ results: [] }), - run: async () => ({ success: true }), - }; - return chain; - }, - }, - JWT_PRIVATE_KEY: undefined, + ...base, + OIDC_ISSUER: ISSUER, + OIDC_CLIENT_ID: 'unself-dev', + OIDC_CLIENT_SECRET: 'dev-secret', }; } describe('OIDC 登录路由', () => { it('未配置 OIDC 时 /api/auth/login 回 503', async () => { - const res = await app.request('/api/auth/login', { method: 'GET' }, env()); + const { e } = env(); + const res = await app.request('/api/auth/login', { method: 'GET' }, e); expect(res.status).toBe(503); }); it('/api/auth/login 302 到授权页并下发流程 Cookie', async () => { const restore = installFakeIdp(await issueIdToken('x')); try { - const e = { - ...env(), - OIDC_ISSUER: ISSUER, - OIDC_CLIENT_ID: 'unself-dev', - OIDC_CLIENT_SECRET: 'dev-secret', - }; + const e = oidcEnv(env().e); const res = await app.request('https://team.example.com/api/auth/login', {}, e); expect(res.status).toBe(302); const location = res.headers.get('location') ?? ''; @@ -122,13 +124,8 @@ describe('OIDC 登录路由', () => { it('callback 完整链路:换 token → JIT 建档 → 会话 Cookie → /api/me 可读', async () => { const { generateInstanceKeyPair } = await import('../src/keys'); const pair = await generateInstanceKeyPair(); - const e = { - ...env(), - OIDC_ISSUER: ISSUER, - OIDC_CLIENT_ID: 'unself-dev', - OIDC_CLIENT_SECRET: 'dev-secret', - JWT_PRIVATE_KEY: pair.privateKeyPem, - }; + const { e: baseEnv, db } = env(); + const e = { ...oidcEnv(baseEnv), JWT_PRIVATE_KEY: pair.privateKeyPem }; // 先拿流程 Cookie(需要假身份源),拿到 nonce 后再签发对应 id_token const restoreLogin = installFakeIdp('unused'); const login = await app.request('https://team.example.com/api/auth/login', {}, e); @@ -154,6 +151,35 @@ describe('OIDC 登录路由', () => { const sessionCookie = allCookies.find((cookie) => cookie.startsWith('unself_session=')) ?? ''; expect(sessionCookie).toContain('HttpOnly'); + // JIT 建档:真 users 表落行(issuer+sub 唯一键) + const userRow = db.first<{ id: string; issuer: string; sub: string; display_name: string; role: string }>( + 'SELECT id, issuer, sub, display_name, role FROM users WHERE issuer = ? AND sub = ?', + ISSUER, + 'u-123', + ); + expect(userRow).not.toBeNull(); + expect(userRow!.role).toBe('user'); + expect(userRow!.display_name).toBe('黄一'); + expect(userRow!.issuer).toBe(ISSUER); + expect(userRow!.sub).toBe('u-123'); + + // 同 issuer+sub 再跑一次 callback:复用同一行(UNIQUE(issuer,sub) 真生效,不重复建档) + const callback2 = await app.request( + `https://team.example.com/api/auth/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + { headers: { cookie: `unself_oidc_flow=${encodeURIComponent(JSON.stringify(flow))}` }, redirect: 'manual' }, + e, + ); + expect(callback2.status).toBe(302); + const again = db.first<{ id: string }>( + 'SELECT id FROM users WHERE issuer = ? AND sub = ?', + ISSUER, + 'u-123', + ); + expect(again!.id).toBe(userRow!.id); + expect( + db.query('SELECT id FROM users WHERE issuer = ? AND sub = ?', ISSUER, 'u-123'), + ).toHaveLength(1); + // 用会话访问 /api/me const session = sessionCookie.split(';')[0]!.replace('unself_session=', ''); const me = await app.request( @@ -182,12 +208,7 @@ describe('OIDC 登录路由', () => { it('callback 无流程 Cookie 回 400', async () => { const restore = installFakeIdp(await issueIdToken('n')); try { - const e = { - ...env(), - OIDC_ISSUER: ISSUER, - OIDC_CLIENT_ID: 'unself-dev', - OIDC_CLIENT_SECRET: 'dev-secret', - }; + const e = oidcEnv(env().e); const res = await app.request( 'https://team.example.com/api/auth/callback?code=abc&state=st', {}, @@ -200,15 +221,54 @@ describe('OIDC 登录路由', () => { }); it('未认证 /api/me 回 401', async () => { - const res = await app.request('https://team.example.com/api/me', {}, env()); + const { e } = env(); + const res = await app.request('https://team.example.com/api/me', {}, e); expect(res.status).toBe(401); }); + + // --- 守护用例(审核 T1:查询列 ↔ 建表列错位即红) ------------------------ + + it('守护:users 表列与 JIT 行结构 == 迁移建表列(幻影列/漏列即红)', async () => { + const { generateInstanceKeyPair } = await import('../src/keys'); + const pair = await generateInstanceKeyPair(); + const { e: baseEnv, db } = env(); + expect(db.columns('users')).toEqual(['id', 'issuer', 'sub', 'display_name', 'role', 'created_at']); + const e = { ...oidcEnv(baseEnv), JWT_PRIVATE_KEY: pair.privateKeyPem }; + + const restoreLogin = installFakeIdp('unused'); + const login = await app.request('https://team.example.com/api/auth/login', {}, e); + const flow = JSON.parse( + decodeURIComponent((login.headers.get('set-cookie') ?? '').split(';')[0]!.replace('unself_oidc_flow=', '')), + ) as { state: string; nonce: string }; + restoreLogin(); + const restore = installFakeIdp(await issueIdToken(flow.nonce)); + try { + const callback = await app.request( + `https://team.example.com/api/auth/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + { headers: { cookie: `unself_oidc_flow=${encodeURIComponent(JSON.stringify(flow))}` }, redirect: 'manual' }, + e, + ); + expect(callback.status).toBe(302); + + // JIT 建档后:真库全字段行的列集合必须与建表列一致 + const row = db.first>( + 'SELECT * FROM users WHERE issuer = ? AND sub = ?', + ISSUER, + 'u-123', + ); + expect(row).not.toBeNull(); + expect(Object.keys(row ?? {}).sort()).toEqual([...db.columns('users')].sort()); + } finally { + restore(); + } + }); }); describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () => { it('合法 https issuer:回文档 issuer + 授权/令牌端点', async () => { const restore = installFakeIdp('unused'); try { + const { e } = env(); const res = await app.request( 'https://team.example.com/api/oidc/test-connection', { @@ -216,7 +276,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = headers: { 'content-type': 'application/json' }, body: JSON.stringify({ issuer: ISSUER }), }, - env(), + e, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ @@ -248,6 +308,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = throw new Error(`unexpected fetch ${url}`); }) as typeof fetch; try { + const { e } = env(); const res = await app.request( 'https://team.example.com/api/oidc/test-connection', { @@ -255,7 +316,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = headers: { 'content-type': 'application/json' }, body: JSON.stringify({ issuer: ISSUER }), }, - env(), + e, ); expect(res.status).toBe(502); expect(await res.json()).toEqual({ ok: false, error: 'discovery failed' }); @@ -268,6 +329,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = const prev = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; try { + const { e } = env(); const res = await app.request( 'https://team.example.com/api/oidc/test-connection', { @@ -275,7 +337,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = headers: { 'content-type': 'application/json' }, body: JSON.stringify({ issuer: 'http://insecure.example.com' }), }, - env(), + e, ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ ok: false, error: 'issuer must be https' }); @@ -285,6 +347,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = }); it('非法 URL issuer → 400 issuer must be https(不触发 discover)', async () => { + const { e } = env(); const res = await app.request( 'https://team.example.com/api/oidc/test-connection', { @@ -292,12 +355,13 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = headers: { 'content-type': 'application/json' }, body: JSON.stringify({ issuer: 'not a url' }), }, - env(), + e, ); expect(res.status).toBe(400); }); it('body 缺 issuer → 400', async () => { + const { e } = env(); const res = await app.request( 'https://team.example.com/api/oidc/test-connection', { @@ -305,7 +369,7 @@ describe('POST /api/oidc/test-connection(服务端代理探测,#44)', () = headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), }, - env(), + e, ); expect(res.status).toBe(400); }); diff --git a/services/core-api/test/node-builtins.d.ts b/services/core-api/test/node-builtins.d.ts new file mode 100644 index 0000000..5ff591f --- /dev/null +++ b/services/core-api/test/node-builtins.d.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * node 内置模块的最小类型声明(本仓库 core-api 是 Workers 目标, + * tsconfig types 只挂 @cloudflare/workers-types,不引 @types/node—— + * 这里只声明测试工厂用到的三个内置模块,零新依赖)。 + */ +declare module 'node:sqlite' { + /** 预处理语句:参数位置绑定与 all/get/run 三个执行面。 */ + export interface StatementSync { + all(...params: unknown[]): Record[]; + get(...params: unknown[]): Record | undefined; + run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint }; + } + export class DatabaseSync { + constructor(path: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } +} + +declare module 'node:fs' { + export function readdirSync(path: string): string[]; + export function readFileSync(path: string, encoding: 'utf8'): string; +} + +declare module 'node:url' { + export function fileURLToPath(url: string | URL): string; +} diff --git a/services/core-api/test/registry.test.ts b/services/core-api/test/registry.test.ts index 4b8129a..2ff582c 100644 --- a/services/core-api/test/registry.test.ts +++ b/services/core-api/test/registry.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import app from '../src/index'; import { generateInstanceKeyPair } from '../src/keys'; +import { createCoreDb, type CoreTestDb } from './test-factory'; const helloManifest = { id: 'hello', @@ -15,110 +16,91 @@ const helloManifest = { icon: 'inbox', }; -/** 内存 D1:覆盖 registry CRUD + users 角色查询的最小面。 */ -interface RegistryDb { - _registry: Map< - string, - { id: string; enabled: number; version: string | null; manifest_json: string } - >; - _users: Map< - string, - { id: string; issuer: string; sub: string; display_name: string; role: string } - >; - _audit: Array<{ action: string; target: string | null }>; -} - -function makeDb(): D1Database & RegistryDb { - type RegistryRow = RegistryDb['_registry'] extends Map ? R : never; - type UserRow = RegistryDb['_users'] extends Map ? R : never; - const registry = new Map(); - const users = new Map(); - const auditLog: RegistryDb['_audit'] = []; - const db = { - prepare(sql: string) { - const chain = { - _args: [] as unknown[], - bind(...args: unknown[]) { - chain._args = args; - return chain; - }, - async first(): Promise { - if (sql.includes('UPDATE module_registry')) { - const row = registry.get(chain._args[1] as string); - if (!row) return null; - row.enabled = chain._args[0] as number; - return { id: row.id, enabled: row.enabled } as T; - } - if (sql.includes('FROM users')) { - return (users.get(chain._args[0] as string) as T) ?? null; - } - return null; - }, - async all() { - if (sql.includes('FROM module_registry')) { - const rows = [...registry.values()].sort((a, b) => a.id.localeCompare(b.id)); - return { results: rows as unknown as T[] }; - } - return { results: [] as T[] }; - }, - async run() { - if (sql.includes('INSERT INTO module_registry')) { - const [id, enabled, version, manifestJson] = chain._args as [string, number, string, string]; - registry.set(id, { id, enabled, version, manifest_json: manifestJson }); - } else if (sql.includes('INSERT INTO audit_log')) { - auditLog.push({ - action: chain._args[1] as string, - target: (chain._args[2] as string) ?? null, - }); - } - return { success: true }; - }, - }; - return chain; - }, - }; - return { prepare: db.prepare, _registry: registry, _users: users, _audit: auditLog } as unknown as D1Database & RegistryDb; -} - -/** 造一个带 admin/user 会话 Cookie 的环境。 */ -async function envFor(role: 'admin' | 'user') { +/** 造一个带 admin/user 会话 Cookie 的环境;用户行落真 users 表(迁移 0001)。 */ +async function envFor(role: 'admin' | 'user'): Promise<{ + env: { JWT_PRIVATE_KEY: string; CORE_DB: D1Database }; + db: CoreTestDb; + cookie: string; +}> { const pair = await generateInstanceKeyPair(); const { createSessionToken } = await import('../src/session'); const token = await createSessionToken( { uid: `u_${role}`, iss: 'https://idp', sub: `sub-${role}`, name: role }, pair.privateKeyPem, ); - const db = makeDb(); - db._users.set('u_admin', { id: 'u_admin', issuer: 'https://idp', sub: 'sub-admin', display_name: '管理', role: 'admin' }); - db._users.set('u_user', { id: 'u_user', issuer: 'https://idp', sub: 'sub-user', display_name: '成员', role: 'user' }); - return { env: { JWT_PRIVATE_KEY: pair.privateKeyPem, CORE_DB: db as unknown as D1Database }, db, cookie: `unself_session=${token}` }; + const db = createCoreDb(); + db.run( + 'INSERT INTO users (id, issuer, sub, display_name, role) VALUES (?, ?, ?, ?, ?)', + 'u_admin', + 'https://idp', + 'sub-admin', + '管理', + 'admin', + ); + db.run( + 'INSERT INTO users (id, issuer, sub, display_name, role) VALUES (?, ?, ?, ?, ?)', + 'u_user', + 'https://idp', + 'sub-user', + '成员', + 'user', + ); + return { + env: { JWT_PRIVATE_KEY: pair.privateKeyPem, CORE_DB: db.d1 }, + db, + cookie: `unself_session=${token}`, + }; +} + +const REG_URL = 'https://t.example/api/admin/modules'; + +function register( + env: { JWT_PRIVATE_KEY: string; CORE_DB: D1Database }, + cookie: string, + body: unknown, +) { + return app.request( + REG_URL, + { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + env, + ); } describe('registry CRUD 与启停语义(#7)', () => { it('注册模块:写入 manifest 快照,201 返回条目', async () => { const { env, db, cookie } = await envFor('admin'); - const res = await app.request( - 'https://t.example/api/admin/modules', - { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ id: 'hello', enabled: true, manifest: helloManifest }) }, - env, - ); + const res = await register(env, cookie, { id: 'hello', enabled: true, manifest: helloManifest }); expect(res.status).toBe(201); const entry = (await res.json()) as { id: string; enabled: boolean; version: string }; expect(entry.id).toBe('hello'); expect(entry.enabled).toBe(true); expect(entry.version).toBe('1.0.0'); - expect(db._registry.get('hello')?.manifest_json).toContain('"counter"'); + + // 断言真库行(不是替身内部 Map) + const row = db.first<{ id: string; enabled: number; version: string; manifest_json: string }>( + 'SELECT id, enabled, version, manifest_json FROM module_registry WHERE id = ?', + 'hello', + ); + expect(row).toEqual({ + id: 'hello', + enabled: 1, + version: '1.0.0', + manifest_json: expect.any(String), + }); + expect(JSON.parse(row!.manifest_json).capabilities).toEqual(['counter']); }); it('重复注册 upsert:刷新快照不炸(deploy 脚本幂等重跑)', async () => { const { env, cookie } = await envFor('admin'); - const body = JSON.stringify({ id: 'hello', enabled: true, manifest: helloManifest }); - const base = 'https://t.example/api/admin/modules'; - expect((await app.request(base, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body }, env)).status).toBe(201); + expect((await register(env, cookie, { id: 'hello', enabled: true, manifest: helloManifest })).status).toBe(201); const v2 = { ...helloManifest, version: '1.0.1' }; - const again = await app.request(base, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ id: 'hello', enabled: true, manifest: v2 }) }, env); + const again = await register(env, cookie, { id: 'hello', enabled: true, manifest: v2 }); expect(again.status).toBe(201); - const list = (await (await app.request(base, { headers: { cookie } }, env)).json()) as Array<{ + const list = (await (await app.request(REG_URL, { headers: { cookie } }, env)).json()) as Array<{ version: string; }>; expect(list).toHaveLength(1); @@ -127,26 +109,24 @@ describe('registry CRUD 与启停语义(#7)', () => { it('enabled 翻转生效;不存在的模块 404', async () => { const { env, db, cookie } = await envFor('admin'); - await app.request( - 'https://t.example/api/admin/modules', - { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ id: 'hello', manifest: helloManifest }) }, - env, - ); + await register(env, cookie, { id: 'hello', manifest: helloManifest }); const off = await app.request( - 'https://t.example/api/admin/modules/hello/enabled', + `${REG_URL}/hello/enabled`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: '{"enabled":false}' }, env, ); expect(off.status).toBe(200); - expect(db._registry.get('hello')?.enabled).toBe(0); + expect(db.first<{ enabled: number }>('SELECT enabled FROM module_registry WHERE id = ?', 'hello')).toEqual({ + enabled: 0, + }); const on = await app.request( - 'https://t.example/api/admin/modules/hello/enabled', + `${REG_URL}/hello/enabled`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: '{"enabled":true}' }, env, ); expect(await on.json()).toEqual({ id: 'hello', enabled: true }); const ghost = await app.request( - 'https://t.example/api/admin/modules/ghost/enabled', + `${REG_URL}/ghost/enabled`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: '{"enabled":true}' }, env, ); @@ -155,12 +135,9 @@ describe('registry CRUD 与启停语义(#7)', () => { it('成员视角 GET /api/modules 只回 enabled;管理端回全量', async () => { const { env, cookie } = await envFor('admin'); - const reg = 'https://t.example/api/admin/modules'; - const post = (id: string, enabled: boolean) => - app.request(reg, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ id, enabled, manifest: { ...helloManifest, id } }) }, env); - await post('hello', true); - await post('chat', false); - const adminList = (await (await app.request(reg, { headers: { cookie } }, env)).json()) as Array<{ + await register(env, cookie, { id: 'hello', enabled: true, manifest: { ...helloManifest, id: 'hello' } }); + await register(env, cookie, { id: 'chat', enabled: false, manifest: { ...helloManifest, id: 'chat' } }); + const adminList = (await (await app.request(REG_URL, { headers: { cookie } }, env)).json()) as Array<{ id: string; }>; expect(adminList.map((m) => m.id).sort()).toEqual(['chat', 'hello']); @@ -172,42 +149,71 @@ describe('registry CRUD 与启停语义(#7)', () => { it('非管理员被拒:无会话 401、普通成员 403、坏 body 400', async () => { const { env, cookie } = await envFor('user'); - const reg = 'https://t.example/api/admin/modules'; - const anon = await app.request(reg, { method: 'POST' }, env); + const anon = await app.request(REG_URL, { method: 'POST' }, env); expect(anon.status).toBe(401); - const member = await app.request( - reg, - { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ id: 'hello', manifest: helloManifest }) }, - env, - ); + const member = await register(env, cookie, { id: 'hello', manifest: helloManifest }); expect(member.status).toBe(403); const adminEnv = await envFor('admin'); - const badBody = await app.request( - reg, - { method: 'POST', headers: { cookie: adminEnv.cookie, 'content-type': 'application/json' }, body: '{"id":"HELLO","manifest":{}}' }, - adminEnv.env, - ); + const badBody = await register(adminEnv.env, adminEnv.cookie, { id: 'HELLO', manifest: {} }); expect(badBody.status).toBe(400); const badToggle = await app.request( - 'https://t.example/api/admin/modules/hello/enabled', + `${REG_URL}/hello/enabled`, { method: 'PATCH', headers: { cookie: adminEnv.cookie, 'content-type': 'application/json' }, body: '{"enabled":"yes"}' }, adminEnv.env, ); expect(badToggle.status).toBe(400); }); - it('启停动作写入审计', async () => { + it('启停动作写入审计(真 audit_log 表)', async () => { const { env, db, cookie } = await envFor('admin'); + await register(env, cookie, { id: 'hello', manifest: helloManifest }); await app.request( - 'https://t.example/api/admin/modules', - { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ id: 'hello', manifest: helloManifest }) }, - env, - ); - await app.request( - 'https://t.example/api/admin/modules/hello/enabled', + `${REG_URL}/hello/enabled`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: '{"enabled":false}' }, env, ); - expect(db._audit.map((a) => a.action)).toContain('module_disabled'); + const actions = db.query<{ action: string; target: string | null }>( + 'SELECT action, target FROM audit_log ORDER BY id', + ); + expect(actions).toContainEqual({ action: 'module_upserted', target: 'hello' }); + expect(actions).toContainEqual({ action: 'module_disabled', target: 'hello' }); + }); + + // --- 守护用例(审核 T1:查询列 ↔ 建表列错位即红) ------------------------ + + it('守护:module_registry 行结构 == 迁移建表列(幻影列/漏列即红)', async () => { + const { env, db, cookie } = await envFor('admin'); + expect(db.columns('module_registry')).toEqual(['id', 'enabled', 'version', 'manifest_json']); + await register(env, cookie, { id: 'hello', manifest: helloManifest }); + + // 真库全字段行:列集合必须与建表一致(#56 的 registered_at 幻影列会在此暴露) + const row = db.first>('SELECT * FROM module_registry WHERE id = ?', 'hello'); + expect(Object.keys(row ?? {}).sort()).toEqual([...db.columns('module_registry')].sort()); + + // 路由级:源码 SELECT 一旦引用不存在的列,这里就是 500(而非假 D1 的静默绿) + const list = await app.request(REG_URL, { headers: { cookie } }, env); + expect(list.status).toBe(200); + const entries = (await list.json()) as Array>; + expect(Object.keys(entries[0] ?? {}).sort()).toEqual([ + 'enabled', + 'id', + 'manifest', + 'registeredAt', + 'version', + ]); + }); + + it('守护:真 schema 约束生效(users UNIQUE(issuer,sub)、module_registry NOT NULL)', async () => { + const { db } = await envFor('admin'); + expect(() => + db.run( + 'INSERT INTO users (id, issuer, sub, role) VALUES (?, ?, ?, ?)', + 'u_dup', + 'https://idp', + 'sub-admin', + 'user', + ), + ).toThrow(); // 同一 issuer+sub 二次建档必须违反 UNIQUE + expect(() => db.run('INSERT INTO module_registry (id, enabled) VALUES (?, ?)', 'bad', 1)).toThrow(); // manifest_json NOT NULL }); }); diff --git a/services/core-api/test/setup.test.ts b/services/core-api/test/setup.test.ts index a4220b3..0dbf3a3 100644 --- a/services/core-api/test/setup.test.ts +++ b/services/core-api/test/setup.test.ts @@ -3,178 +3,140 @@ import { describe, expect, it } from 'vitest'; import app from '../src/index'; import { generateInstanceKeyPair } from '../src/keys'; +import { createCoreDb, type CoreTestDb } from './test-factory'; -/** 可记账的内存 D1 stub:实现 setup 链路用到的最小 SQL 面。 */ -interface SetupDb { - _users: Map; - _audit: Array<{ action: string }>; - _config: Map; -} +/** SQLite `datetime('now')` 落库文本语义(UTC,无 T/Z,非 ISO)。 */ +const DATETIME_TEXT = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; -function makeDb(): D1Database & SetupDb { - const setupTokens = new Map(); - const config = new Map(); - const users = new Map(); - const auditLog: Array<{ actor: string; action: string; target: string | null }> = []; - - const db = { - _users: users, - _audit: auditLog, - _config: config, - prepare(sql: string) { - const chain = { - _args: [] as unknown[], - bind(...args: unknown[]) { - chain._args = args; - return chain; - }, - async first(): Promise { - if (sql.includes('FROM instance_config')) { - const key = chain._args[0] as string; - return config.has(key) ? ({ value: config.get(key) } as T) : null; - } - if (sql.includes('FROM setup_tokens')) { - const token = chain._args[0] as string; - const entry = setupTokens.get(token); - return entry ? ({ used_at: entry.used_at } as T) : null; - } - if (sql.includes('FROM users')) { - return users.get(chain._args[0] as string) as T ?? null; - } - return null; - }, - async all() { - // 读 instance_config 一批键(getOidcConfig 的 SELECT ... key IN (...)) - if (sql.includes('FROM instance_config') && sql.includes('IN (')) { - const keys = (chain._args as string[]).filter((k) => typeof k === 'string'); - const results = keys - .filter((k) => config.has(k)) - .map((k) => ({ key: k, value: config.get(k) as string })); - return { results: results as T[] }; - } - return { results: [] as T[] }; - }, - async run() { - if (sql.startsWith('INSERT INTO setup_tokens')) { - setupTokens.set(chain._args[0] as string, { used_at: null }); - } else if (sql.startsWith('UPDATE setup_tokens')) { - const token = chain._args[0] as string; - const entry = setupTokens.get(token); - if (entry && !entry.used_at) entry.used_at = new Date().toISOString(); - } else if (sql.startsWith('INSERT INTO instance_config')) { - config.set(chain._args[0] as string, chain._args[1] as string); - } else if (sql.startsWith('UPDATE users')) { - const user = users.get(chain._args[1] as string); - if (user) user.role = chain._args[0] as string; - } else if (sql.startsWith('INSERT INTO audit_log')) { - auditLog.push({ - actor: chain._args[0] as string, - action: chain._args[1] as string, - target: (chain._args[2] as string) ?? null, - }); - } - return { success: true }; - }, - }; - return chain; - }, - }; - // 预置一个用户(JIT 建档后的会话用户) - users.set('u_1', { id: 'u_1', issuer: 'https://idp', sub: 'u-1', display_name: '黄一', role: 'user' }); - return db as unknown as D1Database & SetupDb; -} - -async function envFor() { +/** 造带 u_1 会话 Cookie 的环境;u_1 落真 users 表(迁移 0001:issuer/sub NOT NULL 都要给)。 */ +async function envFor(): Promise<{ + env: { JWT_PRIVATE_KEY: string; CORE_DB: D1Database }; + db: CoreTestDb; + cookie: string; +}> { const pair = await generateInstanceKeyPair(); const { createSessionToken } = await import('../src/session'); - const token = await createSessionToken({ uid: 'u_1', iss: 'https://idp', sub: 'u-1', name: '黄一' }, pair.privateKeyPem); + const token = await createSessionToken( + { uid: 'u_1', iss: 'https://idp', sub: 'u-1', name: '黄一' }, + pair.privateKeyPem, + ); + const db = createCoreDb(); + db.run( + 'INSERT INTO users (id, issuer, sub, display_name, role) VALUES (?, ?, ?, ?, ?)', + 'u_1', + 'https://idp', + 'u-1', + '黄一', + 'user', + ); return { - JWT_PRIVATE_KEY: pair.privateKeyPem, + env: { JWT_PRIVATE_KEY: pair.privateKeyPem, CORE_DB: db.d1 }, + db, cookie: `unself_session=${token}`, }; } +/** 翻转 setup_done(真 SQL UPSERT;与 markSetupDone 语义一致,测试自种不走路由)。 */ +function markSetupDone(db: CoreTestDb): void { + db.run( + "INSERT INTO instance_config (key, value) VALUES ('setup_done','1') ON CONFLICT(key) DO UPDATE SET value='1'", + ); +} + describe('setup 流程(一次性 token + 首个管理员)', () => { it('部署脚本生成 setup token:未激活时成功,已激活后 409 拒绝', async () => { - const env = await envFor(); - const db = makeDb(); - const base = { ...env, CORE_DB: db }; - - const res = await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, base); + const { env, db } = await envFor(); + const res = await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env); expect(res.status).toBe(200); const body = (await res.json()) as { token: string; setupUrl: string }; expect(body.token).toMatch(/^[A-Za-z0-9_-]+$/); expect(body.setupUrl).toBe(`/setup?token=${body.token}`); - // 模拟已激活(手动标记) - db._config.set('setup_done', '1'); - const again = await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, base); + // 真库:token 已落 setup_tokens 且未使用 + expect( + db.first<{ used_at: string | null }>('SELECT used_at FROM setup_tokens WHERE token = ?', body.token), + ).toEqual({ used_at: null }); + // 真库:审计留痕 + expect(db.query<{ action: string }>('SELECT action FROM audit_log').map((a) => a.action)).toContain( + 'setup_token_issued', + ); + + // 真 SQL 置位 setup_done(模拟已激活) + markSetupDone(db); + const again = await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env); expect(again.status).toBe(409); }); it('status:未激活 + 无 token → tokenValid:false;激活后 done:true', async () => { - const env = await envFor(); - const db = makeDb(); - const base = { ...env, CORE_DB: db }; - const none = await app.request('https://team.example.com/api/setup/status', {}, base); + const { env, db } = await envFor(); + const none = await app.request('https://team.example.com/api/setup/status', {}, env); expect(await none.json()).toEqual({ done: false, tokenValid: false }); - const gen = (await (await app.request( - 'https://team.example.com/api/admin/setup-token', - { method: 'POST' }, - base, - )).json()) as { token: string }; + const gen = (await ( + await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env) + ).json()) as { token: string }; const withToken = await app.request( `https://team.example.com/api/setup/status?token=${gen.token}`, {}, - base, + env, ); expect(await withToken.json()).toEqual({ done: false, tokenValid: true }); - db._config.set('setup_done', '1'); - const done = await app.request('https://team.example.com/api/setup/status', {}, base); + markSetupDone(db); + const done = await app.request('https://team.example.com/api/setup/status', {}, env); expect(await done.json()).toEqual({ done: true }); }); it('激活全链路:校验 token + 会话 → 首个管理员诞生 → setup 封死', async () => { - const env = await envFor(); - const db = makeDb(); - const base = { ...env, CORE_DB: db }; + const { env, db, cookie } = await envFor(); // 1) 生成 token - const gen = (await (await app.request( - 'https://team.example.com/api/admin/setup-token', - { method: 'POST' }, - base, - )).json()) as { token: string }; + const gen = (await ( + await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env) + ).json()) as { token: string }; - // 2) 未登录激活 → 401 + loginUrl(#10 前端整页跳转用) + // 2) 未登录激活 → 401 + loginUrl(#10 前端整页跳转用;断言 searchParams 语义) const anon = await app.request( `https://team.example.com/api/setup/activate?token=${gen.token}`, { method: 'POST' }, - base, + env, ); expect(anon.status).toBe(401); const anonBody = (await anon.json()) as { loginUrl: string }; - expect(anonBody.loginUrl).toContain('/api/auth/login?next='); + const loginUrl = new URL(anonBody.loginUrl); + expect(`${loginUrl.origin}${loginUrl.pathname}`).toBe('https://team.example.com/api/auth/login'); + expect(loginUrl.searchParams.get('next')).toBe(`/setup?token=${gen.token}`); // 3) 会话 + token → 激活成功,用户升 admin const ok = await app.request( `https://team.example.com/api/setup/activate?token=${gen.token}`, - { method: 'POST', headers: { cookie: env.cookie } }, - base, + { method: 'POST', headers: { cookie } }, + env, ); expect(ok.status).toBe(200); const okBody = (await ok.json()) as { ok: boolean; user: { role: string } }; expect(okBody.ok).toBe(true); expect(okBody.user.role).toBe('admin'); - expect(db._users.get('u_1')?.role).toBe('admin'); + // 真库断言:users.role 已升 admin + expect(db.first<{ role: string }>('SELECT role FROM users WHERE id = ?', 'u_1')).toEqual({ role: 'admin' }); + // 真库断言:setup_done 已置位 + expect( + db.first<{ value: string }>("SELECT value FROM instance_config WHERE key = 'setup_done'"), + ).toEqual({ value: '1' }); + // 真库断言:token 已消费,used_at 是 SQLite datetime 文本(非 ISO 带 T/Z) + const tokenRow = db.first<{ created_at: string; used_at: string | null }>( + 'SELECT created_at, used_at FROM setup_tokens WHERE token = ?', + gen.token, + ); + expect(tokenRow).not.toBeNull(); + expect(tokenRow!.created_at).toMatch(DATETIME_TEXT); + expect(tokenRow!.used_at).toMatch(DATETIME_TEXT); // 4) 同一 token 第二次使用被拒(验收:同一链接第二次使用被拒) const replay = await app.request( `https://team.example.com/api/setup/activate?token=${gen.token}`, - { method: 'POST', headers: { cookie: env.cookie } }, - base, + { method: 'POST', headers: { cookie } }, + env, ); // 已封死优先:先判 setup_done expect(replay.status).toBe(409); @@ -183,48 +145,43 @@ describe('setup 流程(一次性 token + 首个管理员)', () => { const newToken = await app.request( 'https://team.example.com/api/admin/setup-token', { method: 'POST' }, - base, + env, ); expect(newToken.status).toBe(409); - // 6) 审计留痕 - const actions = (db._audit as Array<{ action: string }>).map((a) => a.action); + // 6) 审计留痕(真 audit_log 表 action 列) + const actions = db.query<{ action: string }>('SELECT action FROM audit_log').map((a) => a.action); expect(actions).toContain('setup_token_issued'); expect(actions).toContain('setup_activated'); }); it('无效/伪造 token 激活被拒', async () => { - const env = await envFor(); - const db = makeDb(); + const { env, db, cookie } = await envFor(); const res = await app.request( 'https://team.example.com/api/setup/activate?token=forged-token', - { method: 'POST', headers: { cookie: env.cookie } }, - { ...env, CORE_DB: db }, + { method: 'POST', headers: { cookie } }, + env, ); expect(res.status).toBe(403); - expect(db._users.get('u_1')?.role).toBe('user'); // 未提权 + expect(db.first<{ role: string }>('SELECT role FROM users WHERE id = ?', 'u_1')).toEqual({ role: 'user' }); // 未提权 }); it('缺 token 回 400', async () => { - const env = await envFor(); + const { env, cookie } = await envFor(); const res = await app.request( 'https://team.example.com/api/setup/activate', - { method: 'POST', headers: { cookie: env.cookie } }, - { ...env, CORE_DB: makeDb() }, + { method: 'POST', headers: { cookie } }, + env, ); expect(res.status).toBe(400); }); it('激活持久化向导录入的 OIDC 字段(instance_config + 审计),登录走表优先', async () => { - const env = await envFor(); - const db = makeDb(); - const base = { ...env, CORE_DB: db }; + const { env, db, cookie } = await envFor(); - const gen = (await (await app.request( - 'https://team.example.com/api/admin/setup-token', - { method: 'POST' }, - base, - )).json()) as { token: string }; + const gen = (await ( + await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env) + ).json()) as { token: string }; const oidc = { issuer: 'https://idp.example.com', @@ -236,60 +193,107 @@ describe('setup 流程(一次性 token + 首个管理员)', () => { `https://team.example.com/api/setup/activate?token=${gen.token}`, { method: 'POST', - headers: { cookie: env.cookie, 'content-type': 'application/json' }, + headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify(oidc), }, - base, + env, ); expect(ok.status).toBe(200); - // 写库:与 getOidcConfig 读取键一致的 oidc_* 四键 - expect(db._config.get('oidc_issuer')).toBe(oidc.issuer); - expect(db._config.get('oidc_client_id')).toBe(oidc.clientId); - expect(db._config.get('oidc_client_secret')).toBe(oidc.clientSecret); - expect(db._config.get('oidc_scope')).toBe(oidc.scope); + // 真库断言:与 getOidcConfig 读取键一致的 oidc_* 四键 + const configValue = (key: string) => + db.first<{ value: string }>('SELECT value FROM instance_config WHERE key = ?', key)?.value ?? null; + expect(configValue('oidc_issuer')).toBe(oidc.issuer); + expect(configValue('oidc_client_id')).toBe(oidc.clientId); + expect(configValue('oidc_client_secret')).toBe(oidc.clientSecret); + expect(configValue('oidc_scope')).toBe(oidc.scope); - // 审计 - const actions = (db._audit as Array<{ action: string }>).map((a) => a.action); - expect(actions).toContain('oidc_config_stored'); + // 审计(真 audit_log 表) + expect(db.query<{ action: string }>('SELECT action FROM audit_log').map((a) => a.action)).toContain( + 'oidc_config_stored', + ); // 表优先:env 无 OIDC_* 时 login 仍按 instance_config 配置走 → 302(发现文档来自表里 issuer) const restore = installFakeDiscovery('https://idp.example.com'); try { - const login = await app.request('https://team.example.com/api/auth/login', {}, base); + const login = await app.request('https://team.example.com/api/auth/login', {}, env); expect(login.status).toBe(302); - expect(login.headers.get('location') ?? '').toContain('https://idp.example.com'); + const loc = new URL(login.headers.get('location') ?? ''); + expect(`${loc.origin}${loc.pathname}`).toBe('https://idp.example.com/authorize'); + expect(loc.searchParams.get('client_id')).toBe(oidc.clientId); + expect(loc.searchParams.get('scope')).toBe(oidc.scope); } finally { restore(); } }); it('激活 body 字段非法时忽略该字段,纯 token 激活兼容', async () => { - const env = await envFor(); - const db = makeDb(); - const base = { ...env, CORE_DB: db }; + const { env, db, cookie } = await envFor(); - const gen = (await (await app.request( - 'https://team.example.com/api/admin/setup-token', - { method: 'POST' }, - base, - )).json()) as { token: string }; + const gen = (await ( + await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env) + ).json()) as { token: string }; const ok = await app.request( `https://team.example.com/api/setup/activate?token=${gen.token}`, { method: 'POST', - headers: { cookie: env.cookie, 'content-type': 'application/json' }, + headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ issuer: 123, clientId: '', scope: 'openid', extra: 'ignored' }), }, - base, + env, ); expect(ok.status).toBe(200); // issuer=123(非字符串)/ clientId=''(空串)→ 忽略;scope 合法 → 落库;未知键忽略 - expect(db._config.has('oidc_issuer')).toBe(false); - expect(db._config.has('oidc_client_id')).toBe(false); - expect(db._config.get('oidc_scope')).toBe('openid'); - expect(db._users.get('u_1')?.role).toBe('admin'); + expect(db.first('SELECT value FROM instance_config WHERE key = ?', 'oidc_issuer')).toBeNull(); + expect(db.first('SELECT value FROM instance_config WHERE key = ?', 'oidc_client_id')).toBeNull(); + expect(db.first<{ value: string }>('SELECT value FROM instance_config WHERE key = ?', 'oidc_scope')).toEqual({ + value: 'openid', + }); + expect(db.first<{ role: string }>('SELECT role FROM users WHERE id = ?', 'u_1')).toEqual({ role: 'admin' }); + }); + + // --- 守护用例(审核 T1:查询列 ↔ 建表列错位即红) ------------------------ + + it('守护:setup 相关表列与迁移建表一致(幻影列/漏列即红)', async () => { + const { db } = await envFor(); + expect(db.columns('instance_config')).toEqual(['key', 'value', 'updated_at']); + expect(db.columns('setup_tokens')).toEqual(['token', 'created_at', 'used_at', 'used_by']); + expect(db.columns('users')).toEqual(['id', 'issuer', 'sub', 'display_name', 'role', 'created_at']); + }); + + it('守护:激活全链路跑完后各行 SELECT * 列集合 == columns()', async () => { + const { env, db, cookie } = await envFor(); + const gen = (await ( + await app.request('https://team.example.com/api/admin/setup-token', { method: 'POST' }, env) + ).json()) as { token: string }; + const ok = await app.request( + `https://team.example.com/api/setup/activate?token=${gen.token}`, + { method: 'POST', headers: { cookie } }, + env, + ); + expect(ok.status).toBe(200); + + const expectShape = (table: string, row: Record | null) => { + expect(Object.keys(row ?? {}).sort()).toEqual([...db.columns(table)].sort()); + }; + expectShape('users', db.first('SELECT * FROM users WHERE id = ?', 'u_1')); + expectShape('setup_tokens', db.first('SELECT * FROM setup_tokens WHERE token = ?', gen.token)); + expectShape('instance_config', db.first("SELECT * FROM instance_config WHERE key = 'setup_done'")); + const auditRows = db.query('SELECT * FROM audit_log'); + expect(auditRows.length).toBeGreaterThan(0); + for (const row of auditRows) { + expectShape('audit_log', row); + } + }); + + it('守护:真 schema 约束生效(setup_tokens 重复 token 必须抛错)', async () => { + const { db } = await envFor(); + db.run('INSERT INTO setup_tokens (token) VALUES (?)', 'once-token'); + // 主键真生效:同一 token 二次插入违反 PK → 抛错 + expect(() => db.run('INSERT INTO setup_tokens (token) VALUES (?)', 'once-token')).toThrow(); + // 注:NULL token 不抛错——SQLite 对未声明 NOT NULL 的 TEXT PRIMARY KEY 允许 NULL + // (迁移 0002 的 token 列缺 NOT NULL;见报告:真实 schema 发现)。 }); }); diff --git a/services/core-api/test/test-factory.ts b/services/core-api/test/test-factory.ts new file mode 100644 index 0000000..abeac39 --- /dev/null +++ b/services/core-api/test/test-factory.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * core-api 共享测试工厂:真 SQLite(node:sqlite,Node≥22 内置,零新依赖) + * 加载 `migrations/core/*.sql` 真建表 + 最小 D1 适配器。 + * + * 为什么不是手搓假 D1(审核 T1 / #56):手搓替身用 `sql.includes(...)` 命中即返回 + * 手造行,没有 schema 概念——源码 SQL 引用幻影列、违反 UNIQUE/NOT NULL、写错方言 + * 都能全绿。真 SQLite 加载真迁移后,SQL 与建表列一旦错位就当场抛错(守护用例)。 + * + * 适配器对齐真 D1 语义(`@cloudflare/workers-types` 的 D1PreparedStatement 子集): + * - `prepare(sql).bind(...).first()/all()/run()` 链式; + * - `all()` 回 `{ results, success, meta }`,`run()` 回 `meta.changes`/`meta.last_row_id`; + * - `first()` 无行回 null,支持 `first('col')` 取单列; + * - 行是普通对象(node:sqlite 返回 null 原型,D1 返回普通对象); + * - 文本语义与 D1 一致:`datetime('now')` 落库为 `YYYY-MM-DD HH:MM:SS`。 + */ +import { DatabaseSync } from 'node:sqlite'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** node:sqlite 的 null 原型行 → 普通对象(与真 D1 返回形状一致)。 */ +function plainRow(row: Record): T { + return { ...row } as T; +} + +/** D1 run/exec 的 meta 形状(workers-types D1Meta 的完整字段集)。 */ +function d1Meta(changes: number, lastRowId: number): D1Meta & Record { + return { + duration: 0, + size_after: 0, + rows_read: 0, + rows_written: changes, + last_row_id: lastRowId, + changed_db: changes > 0, + changes, + }; +} + +/** 只读语句判定:真 D1 对 SELECT 的 run() 回 changes=0。 */ +const READ_ONLY_SQL = /^\s*(select|with|pragma)\b/i; + +/** 单条语句的 D1 适配(bind 后 first/all/run)。 */ +class SqliteD1Statement { + private params: unknown[] = []; + + constructor( + private readonly sqlite: DatabaseSync, + private readonly sql: string, + ) {} + + bind(...values: unknown[]): SqliteD1Statement { + this.params = values; + return this; + } + + async first(colName?: string): Promise { + const row = this.sqlite.prepare(this.sql).get(...this.params); + if (row === undefined) { + return null; + } + if (colName !== undefined) { + return (row[colName] as T) ?? null; + } + return plainRow(row); + } + + async all(): Promise> { + const rows = this.sqlite + .prepare(this.sql) + .all(...this.params) + .map((row) => plainRow(row)); + return { results: rows, success: true, meta: d1Meta(0, 0) }; + } + + async run(): Promise> { + if (READ_ONLY_SQL.test(this.sql)) { + this.sqlite.prepare(this.sql).all(...this.params); + return { results: [] as T[], success: true, meta: d1Meta(0, 0) }; + } + const info = this.sqlite.prepare(this.sql).run(...this.params); + return { + results: [] as T[], + success: true, + meta: d1Meta(Number(info.changes), Number(info.lastInsertRowid)), + }; + } +} + +/** 把 node:sqlite 连接包成 D1Database(只实现测试与源码用到的面)。 */ +export function createD1Adapter(sqlite: DatabaseSync): D1Database { + return { + prepare: (sql: string) => new SqliteD1Statement(sqlite, sql), + } as unknown as D1Database; +} + +/** 按文件名顺序执行目录下全部 *.sql 迁移(真建表)。返回已应用文件名。 */ +export function applyMigrations(sqlite: DatabaseSync, dir: string): string[] { + const files = readdirSync(dir) + .filter((file) => file.endsWith('.sql')) + .sort(); + for (const file of files) { + sqlite.exec(readFileSync(`${dir}/${file}`, 'utf8')); + } + return files; +} + +/** 测试库句柄:D1 绑定 + 直查真库的断言通道 + 列名查询(守护用例)。 */ +export interface CoreTestDb { + /** 传给 `app.request(..., env)` 的 CORE_DB 绑定。 */ + d1: D1Database; + /** 直查真库(断言真实行,不经适配器)。 */ + query>(sql: string, ...params: unknown[]): T[]; + /** 直查单行。 */ + first>(sql: string, ...params: unknown[]): T | null; + /** 直接执行(种子数据 / 翻转 setup_done 等)。 */ + run(sql: string, ...params: unknown[]): { changes: number }; + /** 建表列名(守护用例:查询列 ↔ 建表列错位即红)。 */ + columns(table: string): string[]; + /** 底层句柄(高级用例用)。 */ + sqlite: DatabaseSync; + close(): void; +} + +/** + * 建一个加载全部 core 迁移的内存库。 + * 每个用例各自 `createCoreDb()`,互不共享状态(并发/顺序都不串味)。 + */ +export function createCoreDb(): CoreTestDb { + const sqlite = new DatabaseSync(':memory:'); + applyMigrations(sqlite, fileURLToPath(new URL('../migrations/core/', import.meta.url))); + const d1 = createD1Adapter(sqlite); + return { + d1, + sqlite, + query>(sql: string, ...params: unknown[]): T[] { + return sqlite + .prepare(sql) + .all(...params) + .map((row) => plainRow(row)); + }, + first>(sql: string, ...params: unknown[]): T | null { + const row = sqlite.prepare(sql).get(...params); + return row === undefined ? null : plainRow(row); + }, + run(sql: string, ...params: unknown[]) { + const info = sqlite.prepare(sql).run(...params); + return { changes: Number(info.changes) }; + }, + columns(table: string): string[] { + return sqlite + .prepare('SELECT name FROM pragma_table_info(?)') + .all(table) + .map((row) => String((row as { name: unknown }).name)); + }, + close() { + sqlite.close(); + }, + }; +} diff --git a/services/core-api/test/token.test.ts b/services/core-api/test/token.test.ts index 334c631..ff8c734 100644 --- a/services/core-api/test/token.test.ts +++ b/services/core-api/test/token.test.ts @@ -1,47 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, it } from 'vitest'; -import { createRemoteJWKSet, importJWK, jwtVerify } from 'jose'; +import { importJWK, jwtVerify } from 'jose'; import app from '../src/index'; import { generateInstanceKeyPair } from '../src/keys'; - -/** 内存版 D1 stub:覆盖 users / module_registry 两表的最小查询面。 */ -function makeDb(rows: { users?: Array>; registry?: Array> }) { - const users = rows.users ?? []; - const registry = rows.registry ?? []; - return { - prepare(sql: string) { - const chain = { - _args: [] as unknown[], - bind(...args: unknown[]) { - chain._args = args; - return chain; - }, - async first(): Promise { - if (sql.includes('FROM module_registry')) { - const row = registry.find((r) => r.id === chain._args[0]); - return (row as T) ?? null; - } - if (sql.includes('FROM users')) { - const row = users.find((u) => u.issuer === chain._args[0] && u.sub === chain._args[1]); - return (row as T) ?? null; - } - return null; - }, - async all(): Promise<{ results: T[] }> { - if (sql.includes('FROM instance_config')) { - return { results: [] as T[] }; - } - return { results: [] as T[] }; - }, - async run() { - return { success: true }; - }, - }; - return chain; - }, - } as unknown as D1Database; -} +import { createCoreDb, type CoreTestDb } from './test-factory'; async function envWith(): Promise<{ JWT_PRIVATE_KEY: string }> { const pair = await generateInstanceKeyPair(); @@ -55,27 +18,35 @@ async function sessionCookieFor(pem: string, uid = 'u_1'): Promise { return `unself_session=${token}`; } -const helloRegistryRow = { +const helloManifest = { id: 'hello', - enabled: 1, - manifest_json: JSON.stringify({ - id: 'hello', - route: '/m/hello', - entry: 'https://team.example.com/m/hello/', - runtime: 'worker', - requires: ['identity'], - capabilities: ['counter'], - version: '1.0.0', - }), + route: '/m/hello', + entry: 'https://team.example.com/m/hello/', + runtime: 'worker', + requires: ['identity'], + capabilities: ['counter'], + version: '1.0.0', }; +/** 真库种子:hello 模块行(迁移 0001 真表,manifest_json NOT NULL 真约束)。 */ +function seedHello(db: CoreTestDb, enabled: 0 | 1 = 1): void { + db.run( + 'INSERT INTO module_registry (id, enabled, version, manifest_json) VALUES (?, ?, ?, ?)', + 'hello', + enabled, + helloManifest.version, + JSON.stringify(helloManifest), + ); +} + describe('POST /api/modules/:id/token(模块 token 签发)', () => { it('无会话回 401', async () => { const env = await envWith(); + const db = createCoreDb(); const res = await app.request( 'https://team.example.com/api/modules/hello/token', { method: 'POST' }, - { ...env, CORE_DB: makeDb({ registry: [helloRegistryRow] }) }, + { ...env, CORE_DB: db.d1 }, ); expect(res.status).toBe(401); }); @@ -83,10 +54,11 @@ describe('POST /api/modules/:id/token(模块 token 签发)', () => { it('模块不存在回 404', async () => { const env = await envWith(); const cookie = await sessionCookieFor(env.JWT_PRIVATE_KEY); + const db = createCoreDb(); const res = await app.request( 'https://team.example.com/api/modules/ghost/token', { method: 'POST', headers: { cookie } }, - { ...env, CORE_DB: makeDb({ registry: [] }) }, + { ...env, CORE_DB: db.d1 }, ); expect(res.status).toBe(404); }); @@ -94,13 +66,12 @@ describe('POST /api/modules/:id/token(模块 token 签发)', () => { it('模块停用(enabled=0)回 403 —— token 门禁', async () => { const env = await envWith(); const cookie = await sessionCookieFor(env.JWT_PRIVATE_KEY); + const db = createCoreDb(); + seedHello(db, 0); const res = await app.request( 'https://team.example.com/api/modules/hello/token', { method: 'POST', headers: { cookie } }, - { - ...env, - CORE_DB: makeDb({ registry: [{ ...helloRegistryRow, enabled: 0 }] }), - }, + { ...env, CORE_DB: db.d1 }, ); expect(res.status).toBe(403); expect(await res.json()).toEqual({ error: 'module disabled' }); @@ -109,11 +80,12 @@ describe('POST /api/modules/:id/token(模块 token 签发)', () => { it('合法请求签出 ES256 JWT:claims 正确、JWKS 可验、caps 进 payload', async () => { const env = await envWith(); const cookie = await sessionCookieFor(env.JWT_PRIVATE_KEY); - const db = makeDb({ registry: [helloRegistryRow] }); + const db = createCoreDb(); + seedHello(db); const res = await app.request( 'https://team.example.com/api/modules/hello/token', { method: 'POST', headers: { cookie } }, - { ...env, CORE_DB: db }, + { ...env, CORE_DB: db.d1 }, ); expect(res.status).toBe(200); const body = (await res.json()) as { @@ -124,7 +96,13 @@ describe('POST /api/modules/:id/token(模块 token 签发)', () => { expect(body.expiresIn).toBe(600); expect(body.claims.aud).toBe('hello'); // aud = 模块 id,不跨模块重放 expect(body.claims.sub).toBe('u_1'); // sub = 核心内部用户 id - expect(body.claims.caps).toEqual(['counter']); // caps 来自 manifest.capabilities + // caps 来自真库 manifest_json(不是替身手造行;真库行与响应 claims 必须一致) + const row = db.first<{ manifest_json: string }>( + 'SELECT manifest_json FROM module_registry WHERE id = ?', + 'hello', + ); + expect(row).not.toBeNull(); + expect(body.claims.caps).toEqual(JSON.parse(row!.manifest_json).capabilities); expect(body.claims.exp - body.claims.iat).toBe(600); // 10 分钟 // JWT 结构 + kid 头 @@ -151,20 +129,55 @@ describe('POST /api/modules/:id/token(模块 token 签发)', () => { expect(verified.protectedHeader.kid).toBe(header.kid); }); - it('已停用模块在启停后(enabled=1)可再取 token —— 启停秒级生效语义', async () => { + it('已停用模块在启停后(enabled=1)可再取 token —— 同一真库状态翻转', async () => { const env = await envWith(); const cookie = await sessionCookieFor(env.JWT_PRIVATE_KEY); + const db = createCoreDb(); + seedHello(db, 0); // 同一真库:先 INSERT enabled=0 const disabled = await app.request( 'https://team.example.com/api/modules/hello/token', { method: 'POST', headers: { cookie } }, - { ...env, CORE_DB: makeDb({ registry: [{ ...helloRegistryRow, enabled: 0 }] }) }, + { ...env, CORE_DB: db.d1 }, ); expect(disabled.status).toBe(403); + expect(await disabled.json()).toEqual({ error: 'module disabled' }); + // 真 UPDATE 翻转 enabled(原两个独立 makeDb 是伪状态变化,不验证秒级生效语义) + const flip = db.run('UPDATE module_registry SET enabled = 1 WHERE id = ?', 'hello'); + expect(flip.changes).toBe(1); const enabled = await app.request( 'https://team.example.com/api/modules/hello/token', { method: 'POST', headers: { cookie } }, - { ...env, CORE_DB: makeDb({ registry: [helloRegistryRow] }) }, + { ...env, CORE_DB: db.d1 }, ); expect(enabled.status).toBe(200); }); + + // --- 守护用例(审核 T1:查询列 ↔ 建表列错位即红) ------------------------ + + it('守护:module_registry 行结构 == 迁移建表列(幻影列/漏列即红)', async () => { + const db = createCoreDb(); + expect(db.columns('module_registry')).toEqual(['id', 'enabled', 'version', 'manifest_json']); + seedHello(db); + + // 真库全字段行:列集合必须与建表一致(#56 的幻影列会在此暴露) + const row = db.first>('SELECT * FROM module_registry WHERE id = ?', 'hello'); + expect(Object.keys(row ?? {}).sort()).toEqual([...db.columns('module_registry')].sort()); + + // 路由级:token 门禁 SELECT 引用真列(假 D1 的 .find() 命中即绿,这里 200 才算) + const env = await envWith(); + const cookie = await sessionCookieFor(env.JWT_PRIVATE_KEY); + const res = await app.request( + 'https://team.example.com/api/modules/hello/token', + { method: 'POST', headers: { cookie } }, + { ...env, CORE_DB: db.d1 }, + ); + expect(res.status).toBe(200); + }); + + it('守护:真 schema 约束生效(manifest_json NOT NULL)', async () => { + const db = createCoreDb(); + expect(() => + db.run('INSERT INTO module_registry (id, enabled) VALUES (?, ?)', 'bad', 1), + ).toThrow(); // manifest_json NOT NULL:缺列 INSERT 必须抛错 + }); });