From edef773dcc8ea84139493bf9aac3b11a35c19187 Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 19:09:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(deploy):=20buildManifestSnapshot=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20YAML=20list=EF=BC=8C=E5=BF=AB=E7=85=A7=20r?= =?UTF-8?q?eflects=20=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核 T2:requires/capabilities 是 YAML list,旧逐行正则只读标量, 快照硬编码 ['identity']/['demo'] → 模块自定义 capabilities 时 token caps 授牌错误。 先写红测(非默认值 list manifest 全字段断言,修复前 3 条红), 再以最小解析(标量正则 + list 项归属最近空值顶层 key)修正,并让 description 进快照。 Ref #60 Signed-off-by: HandyWote --- deploy/cloudflare/src/registry.ts | 42 +++++++++---- .../cloudflare/test/registry-manifest.test.ts | 60 +++++++++++++++++++ 2 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 deploy/cloudflare/test/registry-manifest.test.ts diff --git a/deploy/cloudflare/src/registry.ts b/deploy/cloudflare/src/registry.ts index 74125c9..5a4aadf 100644 --- a/deploy/cloudflare/src/registry.ts +++ b/deploy/cloudflare/src/registry.ts @@ -17,16 +17,36 @@ ON CONFLICT(id) DO UPDATE SET manifest_json = excluded.manifest_json`; } -/** 最小 manifest.yaml 读取:顶层 key: value 行(id/route/version/icon 等标量)。 */ -function manifestScalarFields(text: string): Record { - const out: Record = {}; +/** + * 最小 manifest.yaml 读取(§5.5 快照所需子集,不引入完整 YAML 解析): + * - 顶层标量 `key: value` 行(id/route/version/icon/description 等); + * - 缩进 list 项 ` - item`(requires/capabilities),归属最近一个「key: 空值」的顶层 key(行尾注释剥除)。 + */ +function manifestTopLevelFields( + text: string, +): { scalars: Record; lists: Record } { + const scalars: Record = {}; + const lists: Record = {}; + let currentListKey: string | null = null; for (const line of text.split('\n')) { - const m = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*(?:#.*)?$/.exec(line); - if (m && m[2] !== undefined && m[2] !== '') { - out[m[1]!] = m[2]!; + const scalar = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*(?:#.*)?$/.exec(line); + if (scalar) { + const value = scalar[2]!; + if (value !== '') { + scalars[scalar[1]!] = value; + currentListKey = null; + } else { + // 顶层 key 空值 → 后续缩进 list 项归属该 key + currentListKey = scalar[1]!; + } + continue; + } + const item = /^\s*-\s+(.+?)\s*(?:#.*)?$/.exec(line); + if (item && currentListKey) { + (lists[currentListKey] ??= []).push(item[1]!); } } - return out; + return { scalars, lists }; } /** manifest.yaml 文本 → ModuleManifest(§5.5 快照 + §5.3 entry 重写为实例 URL)。 */ @@ -36,7 +56,7 @@ export function buildManifestSnapshot(input: { /** 实例 base URL(https://domain 或 workers.dev);空字符串 = workers.dev 占位。 */ baseUrl: string; }): ModuleManifest { - const fields = manifestScalarFields(input.manifestText); + const { scalars: fields, lists } = manifestTopLevelFields(input.manifestText); const host = input.baseUrl || 'https://unself-module-placeholder.workers.dev'; const candidate = { id: fields.id ?? input.moduleId, @@ -44,9 +64,11 @@ export function buildManifestSnapshot(input: { // 部署后模块实际从实例根相对路径装载(同域路径制 §5.3) entry: `${host}/m/${input.moduleId}/`, runtime: 'worker' as const, - requires: ['identity' as const], - capabilities: ['demo'], + // 契约 requires min(1):清单缺失时回退 identity;capabilities 缺失为空(不再硬编码 'demo') + requires: (lists.requires?.length ? lists.requires : ['identity']) as Array<'identity'>, + capabilities: lists.capabilities ?? [], version: fields.version ?? '0.0.0', + ...(fields.description ? { description: fields.description } : {}), ...(fields.icon ? { icon: fields.icon } : {}), }; return ModuleManifestSchema.parse(candidate); diff --git a/deploy/cloudflare/test/registry-manifest.test.ts b/deploy/cloudflare/test/registry-manifest.test.ts new file mode 100644 index 0000000..63e7cfe --- /dev/null +++ b/deploy/cloudflare/test/registry-manifest.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { describe, expect, it } from 'vitest'; +import { buildManifestSnapshot } from '../src/registry'; + +/** 非默认值 list 形式 manifest:requires/capabilities 必须是 YAML list、capabilities 非默认 'demo'。 */ +const DEMO_MANIFEST = `# SPDX-License-Identifier: AGPL-3.0-only +id: demo-mod +route: /m/demo-mod +version: 1.2.3 +icon: chart-bar +description: 计数器演示 +requires: + - identity +capabilities: + - counter + - notify +`; + +describe('buildManifestSnapshot(§5.5 快照 · list 形式 YAML)', () => { + it('capabilities/requires 来自清单,全字段(含 description/icon/version)与清单一致', () => { + const manifest = buildManifestSnapshot({ + manifestText: DEMO_MANIFEST, + moduleId: 'demo-mod', + baseUrl: 'https://team.example.com', + }); + expect(manifest.capabilities).toEqual(['counter', 'notify']); + expect(manifest.requires).toEqual(['identity']); + expect(manifest.id).toBe('demo-mod'); + expect(manifest.route).toBe('/m/demo-mod'); + expect(manifest.version).toBe('1.2.3'); + expect(manifest.icon).toBe('chart-bar'); + expect(manifest.description).toBe('计数器演示'); + // entry 仍重写为实例 URL(§5.3 同域路径制) + expect(manifest.entry).toBe('https://team.example.com/m/demo-mod/'); + }); + + it('缺失时回退:requires 默认 identity(契约 min(1))、capabilities 默认空', () => { + const manifest = buildManifestSnapshot({ + manifestText: 'id: bare\nroute: /m/bare\nversion: 0.1.0\n', + moduleId: 'bare', + baseUrl: 'https://team.example.com', + }); + expect(manifest.requires).toEqual(['identity']); + expect(manifest.capabilities).toEqual([]); + }); + + it('list 项剥掉行尾注释、缩进无关', () => { + const manifest = buildManifestSnapshot({ + manifestText: `id: demo-mod # 演示模块 +capabilities: + - counter # 加计数 + - notify +`, + moduleId: 'demo-mod', + baseUrl: 'https://x.example', + }); + expect(manifest.id).toBe('demo-mod'); + expect(manifest.capabilities).toEqual(['counter', 'notify']); + }); +}); From 92c230e69f8545dc0e739e5f2e5640d313efba4d Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 19:09:09 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(deploy):=20r2=20bucket=20list=20fake?= =?UTF-8?q?=20=E6=94=B9=E7=9C=9F=E5=AE=9E=E6=96=87=E6=9C=AC=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=20+=20parseR2BucketList=20=E7=9B=B4=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核 T4:wrangler v4 `r2 bucket list` 输出 formatLabelledValues 文本 (name: 对齐 + creation_date,无 --json),旧 fake 只喂 JSON → 真实解析分支零测试, 真机格式一变就重复 create(already exists)。 抽 parseR2BucketList 并直测文本/空/旧 JSON/去重/ANSI/CRLF,steps fake 同步改文本。 Ref #60 Signed-off-by: HandyWote --- deploy/cloudflare/src/provision.ts | 54 +++++++++++++------ .../test/provision-registry.test.ts | 39 +++++++++++++- deploy/cloudflare/test/steps.test.ts | 13 ++++- 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/deploy/cloudflare/src/provision.ts b/deploy/cloudflare/src/provision.ts index 0ac9f76..4a86da6 100644 --- a/deploy/cloudflare/src/provision.ts +++ b/deploy/cloudflare/src/provision.ts @@ -83,6 +83,44 @@ export async function ensureDatabases( return { core: ids.core!, modules: ids.modules! }; } +/** + * 解析 `wrangler r2 bucket list` 输出,返回桶名列表(去重)。 + * + * wrangler v4 的 bucket list 无 --json,用 formatLabelledValues 输出文本,每桶形如 + * ``` + * name: unself-storage + * creation_date: Wed, 01 Jan 2025 00:00:00 GMT + * ``` + * (label 对齐空格不定;桶间空行分隔)。兼容旧 JSON 数组输出;空行与 creation_date 行忽略。 + */ +export function parseR2BucketList(stdout: string): string[] { + const trimmed = stdout.trim(); + if (!trimmed) return []; + // 剥离 ANSI:wrangler 在 TTY / FORCE_COLOR 下用 chalk 为 label 与值着色(\x1b[37mname:\x1b[39m …) + const plain = trimmed.replace(/\x1b\[[0-9;]*m/g, ''); + const names: string[] = []; + // 旧形态:JSON 数组([{"name":"…"}]) + if (plain.startsWith('[')) { + try { + const parsed: unknown = JSON.parse(plain); + if (Array.isArray(parsed)) { + for (const row of parsed) { + const name = (row as Record).name; + if (typeof name === 'string' && name) names.push(name); + } + } + } catch { + // 落到文本解析 + } + } + // 文本格式:"name: <桶名>" 行(对齐空格数量不定;容 CRLF 的 \r)。 + // 首尾 \s* 容忍前导空格与行尾 \r;creation_date 行与空行天然不匹配。 + for (const m of plain.matchAll(/^\s*name:\s+(\S+)\s*$/gm)) { + names.push(m[1]!); + } + return [...new Set(names)]; +} + /** R2 桶查漏(步骤⑥):provider=r2 时确保桶存在。 */ export async function ensureR2Bucket( wrangler: Wrangler, @@ -92,21 +130,7 @@ export async function ensureR2Bucket( const res = await wrangler.tryRun(['r2', 'bucket', 'list']); if (res.ok) { try { - // wrangler v4 的 bucket list 无 --json:成功输出形如 "name: <桶名>";JSON 旧形态兼容 - const textNames = [...res.stdout.matchAll(/^name:\s+(\S+)$/gm)].map((m) => m[1]!); - const jsonNames = (() => { - const trimmed = res.stdout.trim(); - if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return [] as string[]; - try { - const parsed: unknown = JSON.parse(trimmed); - return Array.isArray(parsed) - ? parsed.map((b) => (b as Record).name as string).filter(Boolean) - : []; - } catch { - return []; - } - })(); - const names = [...new Set([...textNames, ...jsonNames])]; + const names = parseR2BucketList(res.stdout); if (names.includes(bucket)) { log(`R2 桶 ${bucket} 已存在`); return 'exists'; diff --git a/deploy/cloudflare/test/provision-registry.test.ts b/deploy/cloudflare/test/provision-registry.test.ts index eacfd58..64bad63 100644 --- a/deploy/cloudflare/test/provision-registry.test.ts +++ b/deploy/cloudflare/test/provision-registry.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, it } from 'vitest'; -import { ensureDatabases, parseD1List } from '../src/provision'; +import { ensureDatabases, parseD1List, parseR2BucketList } from '../src/provision'; import { registryCommands, registryDisableCommand, registryUpsertCommand, sqlString } from '../src/registry'; import { buildManifestSnapshot } from '../src/registry'; import { ModuleManifestSchema } from '@unself/contracts'; @@ -27,6 +27,43 @@ describe('parseD1List', () => { }); }); +describe('parseR2BucketList(真实文本 · issue #60)', () => { + // wrangler v4 formatLabelledValues:valuesAlignment=14、spacer=2 → name: 后 11 空格、creation_date: 后 2 空格 + const block = (name: string) => + `name:${' '.repeat(11)}${name}\n` + + `creation_date:${' '.repeat(2)}Wed, 01 Jan 2025 00:00:00 GMT`; + const listOf = (...names: string[]) => `${names.map(block).join('\n\n')}\n`; + + it('多桶真实文本 → [a,b](对齐空格、creation_date 行、桶间空行不误判)', () => { + expect(parseR2BucketList(listOf('a', 'b'))).toEqual(['a', 'b']); + }); + + it('空输出 / 纯空白 → []', () => { + expect(parseR2BucketList('')).toEqual([]); + expect(parseR2BucketList('\n\n')).toEqual([]); + }); + + it('旧 JSON 数组 → 名称', () => { + expect(parseR2BucketList('[{"name":"a"},{"name":"b"}]')).toEqual(['a', 'b']); + }); + + it('重复名去重(文本与 JSON 混合重复)', () => { + expect(parseR2BucketList(listOf('a', 'b', 'a'))).toEqual(['a', 'b']); + expect(parseR2BucketList('[{"name":"a"},{"name":"a"}]')).toEqual(['a']); + }); + + it('含 ANSI 着色(TTY/FORCE_COLOR)仍可解析', () => { + const colored = + `\x1b[37mname:\x1b[39m${' '.repeat(11)}\x1b[90ma\x1b[39m\n` + + `\x1b[37mcreation_date:\x1b[39m \x1b[90mWed, 01 Jan 2025 00:00:00 GMT\x1b[39m\n`; + expect(parseR2BucketList(colored)).toEqual(['a']); + }); + + it('CRLF(\r\n)与尾行空白不破坏解析', () => { + expect(parseR2BucketList(listOf('a').replace(/\n/g, '\r\n'))).toEqual(['a']); + }); +}); + describe('ensureDatabases(①幂等)', () => { it('两库都不存在 → 创建两次;都存在 → 零创建', async () => { const uuid = 'a1b2c3d4-0000-0000-0000-000000000001'; diff --git a/deploy/cloudflare/test/steps.test.ts b/deploy/cloudflare/test/steps.test.ts index ebdce41..6c3a570 100644 --- a/deploy/cloudflare/test/steps.test.ts +++ b/deploy/cloudflare/test/steps.test.ts @@ -43,7 +43,18 @@ function makeFakeWrangler(options?: { existingD1?: string[]; existingBuckets?: s const sub = rest[0]; if (sub === 'bucket') { const op = rest[1]; - if (op === 'list') return json([...state.buckets].map((name) => ({ name }))); + if (op === 'list') { + // wrangler v4 真机格式:formatLabelledValues 输出(label 对齐、桶间空行、无 --json) + return okOut( + [...state.buckets] + .map( + (name) => + `name:${' '.repeat(11)}${name}\n` + + `creation_date:${' '.repeat(2)}Wed, 01 Jan 2025 00:00:00 GMT`, + ) + .join('\n\n'), + ); + } if (op === 'create') { const name = rest[2]!; if (state.buckets.has(name)) return fail(`bucket exists: ${name}`); From 23d4cab5c03030b4a08d2a53273dc82ce527db0c Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 19:09:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(ui,sdk,hello):=20=E6=B8=85=20G1=20?= =?UTF-8?q?=E6=81=92=E7=9C=9F/=E8=87=AA=E8=AF=81=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E8=A1=8C=E4=B8=BA=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核 §2.2 G1: - ui.test.ts 删「导出存在性 + 版本号」恒真,改 vue/server-renderer 真渲染 (UButton 变体/disabled、UErrorCard requestId/详情折叠、UInput、UCard); - sdk.test.ts 无 window 用例不再只断言 not.toThrow,改为观测不得触达 parent, 装 window 后投递仍生效(证明守卫是运行时条件而非 no-op 桩); - hello.test.ts 身份行自证循环改为验签行为断言:正签 token 200、同签名篡改 payload → 401(claims 只能来自签发方签过的 token)。 Ref #60 Signed-off-by: HandyWote --- modules/hello/test/hello.test.ts | 32 ++++++++-- packages/module-sdk/test/sdk.test.ts | 31 ++++++++-- packages/ui/test/ui.test.ts | 93 +++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 27 deletions(-) diff --git a/modules/hello/test/hello.test.ts b/modules/hello/test/hello.test.ts index b5e8f9e..03b6eae 100644 --- a/modules/hello/test/hello.test.ts +++ b/modules/hello/test/hello.test.ts @@ -105,13 +105,29 @@ describe('module-hello(#13 垂直切片载体)', () => { ).toBe('1'); }); - it('身份行数据源:claims 姓名/邮箱进入 token(验收 2 的数据面)', async () => { + it('身份行数据源:claims 姓名/邮箱真值只能来自服务端验签通过的 token(签名载荷不可篡改)', async () => { const env = await envFor(); - const token = await makeToken(); - const { decodeJwtPayload } = await import('@unself/module-sdk'); - const claims = decodeJwtPayload(token) as { name?: string; email?: string }; - expect(claims.name).toBe('黄一'); - expect(claims.email).toBe('huang@example.com'); + const token = await makeToken(); // 真实 ES256 签名,payload 含 name=黄一 / email=huang@example.com + + // 正签 token:服务端真实验签(jose + JWKS)通过——验签接受的 claims 即签名载荷。 + const ok = await app.request('https://m.example/api/count', { + headers: { authorization: `Bearer ${token}` }, + }, env); + expect(ok.status).toBe(200); + + // 同一签名换 payload(姓名/邮箱被改写,其余字段不变):签名不再匹配,服务端必须拒绝。 + // 证明身份行可展示的 claims(姓名/邮箱)不能由客户端任意注入,只能来自签发方签名过的 token。 + const [header, payloadB64, signature] = token.split('.'); + const payload = JSON.parse( + Buffer.from(payloadB64!, 'base64url').toString('utf8'), + ) as Record; + const forged = `${header}.${Buffer.from( + JSON.stringify({ ...payload, name: '黑客', email: 'evil@example.com' }), + ).toString('base64url')}.${signature}`; + const forgedRes = await app.request('https://m.example/api/count', { + headers: { authorization: `Bearer ${forged}` }, + }, env); + expect(forgedRes.status).toBe(401); }); it('GET /life/export 返回契约形状 ExportBundle', async () => { @@ -151,6 +167,10 @@ describe('module-hello(#13 垂直切片载体)', () => { expect(html).toContain('+1'); expect(html).toContain('createModuleSDK'); expect(html).toContain('viewport'); + // 身份行数据源接线:token claims 经 decodeContext 填入 who/email(与上一条验签用例呼应)。 + expect(html).toContain('sdk.decodeContext'); + expect(html).toContain('claims.name ?? claims.sub'); + expect(html).toContain('claims.email'); }); it('页面内 fetch/import 不用根相对路径(部署挂载在 /m// 子路径,#14 装配前提)', async () => { diff --git a/packages/module-sdk/test/sdk.test.ts b/packages/module-sdk/test/sdk.test.ts index a6d4936..fece3d7 100644 --- a/packages/module-sdk/test/sdk.test.ts +++ b/packages/module-sdk/test/sdk.test.ts @@ -132,14 +132,33 @@ describe('outbound messages(ready / navigate / notify / theme)', () => { expect(() => sdk.theme('sepia')).toThrow(/invalid theme mode/); }); - it('outbound messages are no-ops without a window', () => { - const sdk = createModuleSDK({ moduleId: 'mod-a' }); - expect(() => { + it('outbound guards are runtime-conditional: no window → parent untouched; window → real postMessage', () => { + // 无 window 环境:globalThis.parent 换成带 spy 的 getter。 + // 若守卫失效,出站调用要么直接命中 spy,要么对 undefined 解包抛错——两者都判失败。 + const parentGetter = vi.fn(() => undefined); + Object.defineProperty(globalThis, 'parent', { configurable: true, get: parentGetter }); + try { + const sdk = createModuleSDK({ moduleId: 'mod-a', coreOrigin: CORE_ORIGIN }); + expect(() => { + sdk.ready(); + sdk.navigate('/m/a'); + sdk.notify('x'); + sdk.theme('dark'); + }).not.toThrow(); + expect(parentGetter).not.toHaveBeenCalled(); + + // 同一 SDK:装上浏览器式 window 后必须真的走 postMessage 通道(守卫是运行时条件,不是 no-op 桩)。 + const fake = installFakeWindow(); sdk.ready(); + expect(fake.postMessage).toHaveBeenCalledWith({ type: 'ready' }, CORE_ORIGIN); sdk.navigate('/m/a'); - sdk.notify('x'); - sdk.theme('dark'); - }).not.toThrow(); + expect(fake.postMessage).toHaveBeenCalledWith( + { type: 'navigate', path: '/m/a' }, + CORE_ORIGIN, + ); + } finally { + Reflect.deleteProperty(globalThis, 'parent'); + } }); }); diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index c8fd75b..dc23999 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -1,16 +1,79 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { describe, expect, it } from 'vitest' -import * as ui from '../src/index' - -describe('@unself/ui', () => { - it('导出基元版本号', () => { - expect(ui.UI_VERSION).toBe('0.1.0') - }) - - it('导出四个共享基元', () => { - const names = ['UButton', 'UInput', 'UCard', 'UErrorCard'] as const - for (const name of names) { - expect(ui[name], `缺基元 ${name}`).toBeTruthy() - } - }) -}) +import { createSSRApp, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it } from 'vitest'; + +import { UButton, UCard, UErrorCard, UInput } from '../src/index'; + +/** + * 基元行为断言(G1 清理:原「导出存在性 + UI_VERSION 恒真」已删): + * 用 vue/server-renderer 真实 SSR 渲染组件,断言其可观测输出 + * (class 契约、slot 文案、属性透传),而不是只问「模块上有东西」。 + */ + +describe('@unself/ui 基元 SSR 行为', () => { + it('UButton:默认渲染 type=button + u-btn/u-btn-primary/u-btn-md,slot 文案出现', async () => { + const html = await renderToString( + createSSRApp({ render: () => h(UButton, null, () => '提交') }), + ); + expect(html).toContain(' { + const html = await renderToString( + createSSRApp({ render: () => h(UButton, { disabled: true }, () => '禁用') }), + ); + expect(html).toMatch(/]*\bdisabled\b/); + }); + + it('UButton:variant="outline" 时 class 含 u-btn-outline', async () => { + const html = await renderToString( + createSSRApp({ render: () => h(UButton, { variant: 'outline' }, () => '边框') }), + ); + expect(html).toContain('class="u-btn u-btn-outline u-btn-md"'); + }); + + it('UErrorCard:title 与「请求编号:」透传展示', async () => { + const html = await renderToString( + createSSRApp({ + render: () => h(UErrorCard, { title: '同步失败', requestId: 'req-7a1' }), + }), + ); + expect(html).toContain('同步失败'); + expect(html).toContain('请求编号:req-7a1'); + }); + + it('UErrorCard:传 detail 时出现「技术详情」按钮(默认折叠)', async () => { + const html = await renderToString( + createSSRApp({ + render: () => h(UErrorCard, { title: '同步失败', detail: 'stack trace' }), + }), + ); + expect(html).toContain('技术详情'); + expect(html).toContain('aria-expanded="false"'); + }); + + it('UInput:label/placeholder 呈现,error 时 u-input-error 且错误文案可见', async () => { + const html = await renderToString( + createSSRApp({ + render: () => + h(UInput, { label: '关键词', placeholder: '搜点东西', error: '不能为空' }), + }), + ); + expect(html).toContain('关键词'); + expect(html).toContain('placeholder="搜点东西"'); + expect(html).toContain('u-input-error'); + expect(html).toContain('不能为空'); + }); + + it('UCard:默认 padding=md 输出 u-card u-card-md 且 slot 内容呈现', async () => { + const html = await renderToString( + createSSRApp({ render: () => h(UCard, null, () => '卡片内容') }), + ); + expect(html).toContain('class="u-card u-card-md"'); + expect(html).toContain('卡片内容'); + }); +}); From ffc6eeff4a5a653718092daace68cd6b0c0ee34a Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 19:09:21 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(core-api):=20x-request-id=20=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6=20+=20OIDC=20callback=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=80=81=20302=20=E5=9B=9E=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核 D8/T6:壳侧三处消费响应头 x-request-id(ErrorCard 展示 request id), core-api 全源码零产出 → requestId 恒 undefined,死功能。 新增全局中间件每请求生成 req-<16hex> 并回写所有响应(含 401/403/503)。 审核 §2.3 OIDC 失败态:callback 换 token/验签抛错此前未捕获 → 500。 改为清流程 Cookie + 302 /login?error=<短码>(state 不匹配/过期/IdP error 各有码), Cookie 缺失/损坏仍 400;成功链路不变。 新增路由级用例:request-id.test.ts(auth/registry/setup/token 四类路由 + id 唯一/格式)、 oidc-callback-failures.test.ts(5 条失败态)。CORE_DB 复用共享 test-factory 真 sqlite。 Ref #60 Signed-off-by: HandyWote --- services/core-api/src/index.ts | 49 ++++- .../test/oidc-callback-failures.test.ts | 192 ++++++++++++++++++ services/core-api/test/request-id.test.ts | 81 ++++++++ 3 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 services/core-api/test/oidc-callback-failures.test.ts create mode 100644 services/core-api/test/request-id.test.ts diff --git a/services/core-api/src/index.ts b/services/core-api/src/index.ts index accfb7a..2e7c28d 100644 --- a/services/core-api/src/index.ts +++ b/services/core-api/src/index.ts @@ -7,6 +7,7 @@ import { discover, exchangeAuthorizationCode, type AuthorizationRequest, + type CallbackResult, type OidcClientConfig, } from './oidc'; import { deriveSigningRuntime, generateInstanceKeyPair, type SigningRuntime } from './keys'; @@ -78,6 +79,17 @@ export async function getSigningRuntime(jwtPrivateKey: string | undefined): Prom const app = new Hono<{ Bindings: Bindings }>(); +/** + * 全局请求 ID(#60 T3):每个请求生成唯一 `req-` + 16 位 hex, + * 回写所有响应(含 401/403/503/错误)的 x-request-id,排障对账用。 + * 必须在所有路由之前注册,且最后设置头部以覆盖错误/404 等非 c.* 构造的响应。 + */ +app.use('*', async (c, next) => { + const requestId = `req-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + await next(); + c.header('x-request-id', requestId); +}); + app.get('/api/health', (c) => c.json({ ok: true, service: 'core-api' })); /** 当前会话用户(shell 判断登录态 / #10-13 前端用)。 */ @@ -149,6 +161,25 @@ app.get('/api/auth/login', async (c) => { return c.redirect(flow.authorizeUrl); }); +/** + * OIDC callback 失败 → 短错误码(#60 T4): + * 只映射到固定枚举,不把内部细节(如 IdP 返回的 error_description)带进 URL。 + * LoginView.vue 消费 route.query.error 仅展示人话("登录校验失败…")。 + */ +function oidcErrorCode(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('state mismatch')) return 'oidc_state_mismatch'; + if (message.includes('id_token expired')) return 'oidc_token_expired'; + if (message.includes('provider returned')) return 'oidc_provider_error'; + return 'oidc_failed'; +} + +/** 失败态统一出口:清流程 Cookie(该轮流程作废)+ 302 回登录页带短错误码。 */ +function redirectToLoginError(c: Context<{ Bindings: Bindings }>, err: unknown): Response { + deleteCookie(c, FLOW_COOKIE, { path: '/' }); + return c.redirect(`/login?error=${encodeURIComponent(oidcErrorCode(err))}`); +} + /** 授权回调:state/PKCE/nonce 校验 → 换 token → JIT 建档 → 签会话 Cookie。 */ app.get('/api/auth/callback', async (c) => { const config = await getOidcConfig(c); @@ -165,12 +196,18 @@ app.get('/api/auth/callback', async (c) => { } catch { return c.json({ error: 'corrupted login flow cookie' }, 400); } - const metadata = await discover(config.issuer); - const result = await exchangeAuthorizationCode(config, metadata, c.req.url, { - state: flow.state, - nonce: flow.nonce, - codeVerifier: flow.codeVerifier, - }); + // 换 token/验签失败态:不抛未捕获异常(避免 500),统一 302 回登录页(§6.5)。 + let result: CallbackResult; + try { + const metadata = await discover(config.issuer); + result = await exchangeAuthorizationCode(config, metadata, c.req.url, { + state: flow.state, + nonce: flow.nonce, + codeVerifier: flow.codeVerifier, + }); + } catch (err) { + return redirectToLoginError(c, err); + } deleteCookie(c, FLOW_COOKIE, { path: '/' }); // JIT 建档(requirements #20:OIDC 首登自动建档复用;issuer+sub 映射只存核心) diff --git a/services/core-api/test/oidc-callback-failures.test.ts b/services/core-api/test/oidc-callback-failures.test.ts new file mode 100644 index 0000000..f4fab4d --- /dev/null +++ b/services/core-api/test/oidc-callback-failures.test.ts @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { exportJWK, generateKeyPair, SignJWT, type JWK } from 'jose'; + +import app from '../src/index'; +import { resetOidcCaches } from '../src/oidc'; +import { createCoreDb, type CoreTestDb } from './test-factory'; + +/** + * OIDC callback 失败态(#60 T4): + * exchangeAuthorizationCode 抛错(state 不匹配 / id_token 过期 / IdP 回跳 error) + * 不得变 500,统一 302 回 /login?error=<短错误码>(LoginView.vue 消费 route.query.error); + * 流程 Cookie 缺失 / JSON 损坏仍保持 400。 + * 假 IdP + 真 RS256 密钥(jose)签发 id_token;resetOidcCaches 隔离发现/JWKS 缓存。 + */ + +const ISSUER = 'https://idp.example.com'; +const JWKS_URI = `${ISSUER}/jwks`; +let privateKey: CryptoKey; +let publicJwk: JWK; +const kid = 'test-key'; + +beforeEach(async () => { + resetOidcCaches(); // 发现文档 + JWKS 集合按 issuer 缓存,换钥用例必须重置 + const pair = await generateKeyPair('RS256', { extractable: true }); + privateKey = pair.privateKey; + publicJwk = await exportJWK(pair.publicKey); +}); + +/** 签发 id_token(exp 可传过去时间模拟过期;jose 真签名,走 RS256 验签)。 */ +async function issueIdToken(opts: { nonce: string; exp?: number; sub?: string }): Promise { + return new SignJWT({ name: '黄一', email: 'huang@example.com', nonce: opts.nonce }) + .setProtectedHeader({ alg: 'RS256', kid }) + .setIssuer(ISSUER) + .setAudience('unself-dev') + .setIssuedAt() + .setExpirationTime(opts.exp ?? Math.floor(Date.now() / 1000) + 600) + .setSubject(opts.sub ?? 'u-123') + .sign(privateKey); +} + +/** 假身份源:拦截全局 fetch(discovery/token/jwks),与 auth-routes 测试同模式(自复制)。 */ +function installFakeIdp(idToken: string) { + const original = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/.well-known/openid-configuration')) { + return new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: JWKS_URI, + scopes_supported: ['openid'], + }), + { status: 200 }, + ); + } + if (url === JWKS_URI) { + return new Response( + JSON.stringify({ keys: [{ ...publicJwk, kid, alg: 'RS256', use: 'sig' }] }), + { status: 200 }, + ); + } + if (url === `${ISSUER}/token`) { + return new Response( + JSON.stringify({ access_token: 'at', token_type: 'Bearer', id_token: idToken }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + return () => { + globalThis.fetch = original; + }; +} + +/** 共享 test-factory 真库(#60 标准):每个用例独立 sqlite + 真迁移,不手搓假 D1。 */ +const openDbs: CoreTestDb[] = []; + +/** 真库 + 环境变量注入 OIDC 配置(instance_config 为空表时走 env 兑底)。 */ +function env(): Record { + const db = createCoreDb(); + openDbs.push(db); + return { + CORE_DB: db.d1, + OIDC_ISSUER: ISSUER, + OIDC_CLIENT_ID: 'unself-dev', + OIDC_CLIENT_SECRET: 'dev-secret', + JWT_PRIVATE_KEY: undefined, + }; +} + +afterEach(() => { + while (openDbs.length > 0) openDbs.pop()!.close(); +}); + +/** 走一次真实登录流程(假 IdP)取流程 Cookie 与其中 state/nonce。 */ +async function acquireFlowCookie(e: Record): Promise<{ cookie: string; flow: { state: string; nonce: string } }> { + const login = await app.request('https://team.example.com/api/auth/login', {}, e); + expect(login.status).toBe(302); + const cookie = (login.headers.get('set-cookie') ?? '').split(';')[0] ?? ''; + expect(cookie).toContain('unself_oidc_flow='); + const flow = JSON.parse(decodeURIComponent(cookie.replace('unself_oidc_flow=', ''))) as { + state: string; + nonce: string; + }; + return { cookie, flow }; +} + +describe('GET /api/auth/callback 失败态(#60 T4)', () => { + it('state 不匹配 → 302 /login?error=oidc_state_mismatch', async () => { + const restore = installFakeIdp('unused'); + try { + const e = env(); + const { cookie } = await acquireFlowCookie(e); + const res = await app.request( + `https://team.example.com/api/auth/callback?code=abc&state=forged-state`, + { headers: { cookie } }, + e, + ); + expect(res.status).toBe(302); + const location = res.headers.get('location') ?? ''; + expect(location).toContain('/login?error='); + expect(location).toContain('oidc_state_mismatch'); + } finally { + restore(); + } + }); + + it('id_token 过期(exp 在过去)→ 302 /login?error=oidc_token_expired', async () => { + // 先拿流程 Cookie(换钥后伪造的过期 token 不能复用登录时的签发) + const restoreLogin = installFakeIdp('unused'); + const e = env(); + const { cookie, flow } = await acquireFlowCookie(e); + restoreLogin(); + const stale = await issueIdToken({ nonce: flow.nonce, exp: Math.floor(Date.now() / 1000) - 120 }); + const restore = installFakeIdp(stale); + try { + const res = await app.request( + `https://team.example.com/api/auth/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + { headers: { cookie } }, + e, + ); + expect(res.status).toBe(302); + const location = res.headers.get('location') ?? ''; + expect(location).toContain('/login?error='); + expect(location).toContain('oidc_token_expired'); + } finally { + restore(); + } + }); + + it('IdP 直接回跳 error 参数(access_denied)→ 302 /login?error=oidc_provider_error,不透传细节', async () => { + const restore = installFakeIdp('unused'); + try { + const e = env(); + const { cookie } = await acquireFlowCookie(e); + const res = await app.request( + 'https://team.example.com/api/auth/callback?error=access_denied&error_description=user+canceled', + { headers: { cookie } }, + e, + ); + expect(res.status).toBe(302); + const location = res.headers.get('location') ?? ''; + expect(location).toContain('/login?error='); + expect(location).toContain('oidc_provider_error'); + expect(location).not.toContain('access_denied'); + expect(location).not.toContain('user+canceled'); + } finally { + restore(); + } + }); + + it('流程 Cookie 是损坏 JSON → 400(保持现状)', async () => { + const res = await app.request( + 'https://team.example.com/api/auth/callback?code=abc&state=st', + { headers: { cookie: 'unself_oidc_flow=%7Bnot-json' } }, + env(), + ); + expect(res.status).toBe(400); + }); + + it('缺流程 Cookie → 400(保持现状)', async () => { + const res = await app.request( + 'https://team.example.com/api/auth/callback?code=abc&state=st', + {}, + env(), + ); + expect(res.status).toBe(400); + }); +}); diff --git a/services/core-api/test/request-id.test.ts b/services/core-api/test/request-id.test.ts new file mode 100644 index 0000000..9e3d502 --- /dev/null +++ b/services/core-api/test/request-id.test.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { afterEach, describe, expect, it } from 'vitest'; + +import app from '../src/index'; +import { createCoreDb, type CoreTestDb } from './test-factory'; + +/** + * x-request-id 全局中间件(#60 T3):所有响应(含 401/403/503/错误) + * 都必须带 `x-request-id`(req- + 16 位 hex),排障对账用。 + * 走 app.request 集成:auth / registry / setup / token 四类路由各一条断言 + + * 两次请求 id 不同 + 安全字符集。 + * CORE_DB 用共享 test-factory(真 sqlite + 真迁移),不手搓假 D1(#60 标准)。 + */ + +const openDbs: CoreTestDb[] = []; + +/** 每个用例独立真库;路由只读/空表即可走通(OIDC 未配置、无会话、无签名密钥)。 */ +function env(): Record { + const db = createCoreDb(); + openDbs.push(db); + return { CORE_DB: db.d1, JWT_PRIVATE_KEY: undefined }; +} + +afterEach(() => { + while (openDbs.length > 0) openDbs.pop()!.close(); +}); + +const SAFE_ID = /^[A-Za-z0-9_-]+$/; +const HEX_ID = /^req-[0-9a-f]{16}$/; + +describe('x-request-id 全局中间件', () => { + it('auth:未配置 OIDC 的 503 也带 x-request-id', async () => { + const res = await app.request('https://team.example.com/api/auth/login', {}, env()); + expect(res.status).toBe(503); + const id = res.headers.get('x-request-id'); + expect(id).toBeTruthy(); + expect(id).toMatch(SAFE_ID); + }); + + it('registry:无会话的 401 也带 x-request-id', async () => { + const res = await app.request('https://team.example.com/api/admin/modules', {}, env()); + expect(res.status).toBe(401); + const id = res.headers.get('x-request-id'); + expect(id).toBeTruthy(); + expect(id).toMatch(SAFE_ID); + }); + + it('setup:GET /api/setup/status 带 x-request-id', async () => { + const res = await app.request('https://team.example.com/api/setup/status', {}, env()); + expect(res.status).toBe(200); + const id = res.headers.get('x-request-id'); + expect(id).toBeTruthy(); + expect(id).toMatch(SAFE_ID); + }); + + it('token:无签名密钥的 503 也带 x-request-id', async () => { + const res = await app.request( + 'https://team.example.com/api/modules/hello/token', + { method: 'POST' }, + env(), + ); + expect(res.status).toBe(503); + const id = res.headers.get('x-request-id'); + expect(id).toBeTruthy(); + expect(id).toMatch(SAFE_ID); + }); + + it('两次请求 id 不同,且格式为 req- + 16 位 hex', async () => { + const [a, b] = await Promise.all([ + app.request('https://team.example.com/api/setup/status', {}, env()), + app.request('https://team.example.com/api/setup/status', {}, env()), + ]); + const idA = a.headers.get('x-request-id'); + const idB = b.headers.get('x-request-id'); + expect(idA).toBeTruthy(); + expect(idB).toBeTruthy(); + expect(idA).not.toBe(idB); + expect(idA).toMatch(HEX_ID); + expect(idB).toMatch(HEX_ID); + }); +});