From 61622f94d550a148bcf7a82ae592b63136aa4c78 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 18:04:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=AF=E5=8A=A8=E8=87=AA=E6=A3=80=20issuer?= =?UTF-8?q?=20=E4=B8=8D=E4=B8=80=E8=87=B4=E6=8B=92=E7=BB=9D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8;=E8=B7=AF=E7=94=B1=E8=A6=86=E7=9B=96=20fail-closed;Ke?= =?UTF-8?q?ycloak=20=E9=85=8D=E7=BD=AE=E8=A7=A3=E6=9E=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?(ADR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个任务把统一鉴权中心接上了,但缺配置照常起、issuer 配错也照常起 —— 每个请求都 401 还查不出原因。boot() 开头补两道保险:缺 KEYCLOAK_URL/REALM/CLIENT_ID 直接拒绝启动;配了就拉一次 .well-known/openid-configuration 核对 issuer,不一致同样拒绝启动 (话里带期望/实际/KC_HOSTNAME,照后端接入手册第 5 步)。 另加路由覆盖 it 测试:Hono 里每条注册路由都必须能在 appRouteRegistry() 里查到归属,查不到就是一个没人管的权限口子。真库跑通,96 条路由全部 有归属、0 条查不到——把上一个任务里人工核对的结论固化成测试。 --- .../src/app/__tests__/config-keycloak.test.ts | 31 +++++++ .../src/app/__tests__/keycloak-check.test.ts | 30 +++++++ .../app/__tests__/route-coverage.it.test.ts | 87 +++++++++++++++++++ apps/api/src/app/keycloak-check.ts | 22 +++++ apps/api/src/app/main.ts | 15 ++++ 5 files changed, 185 insertions(+) create mode 100644 apps/api/src/app/__tests__/config-keycloak.test.ts create mode 100644 apps/api/src/app/__tests__/keycloak-check.test.ts create mode 100644 apps/api/src/app/__tests__/route-coverage.it.test.ts create mode 100644 apps/api/src/app/keycloak-check.ts diff --git a/apps/api/src/app/__tests__/config-keycloak.test.ts b/apps/api/src/app/__tests__/config-keycloak.test.ts new file mode 100644 index 0000000..1dda212 --- /dev/null +++ b/apps/api/src/app/__tests__/config-keycloak.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { loadConfig } from '../config.ts'; + +describe('Keycloak 配置(ADR-012)', () => { + it('三样齐了才有 keycloak 段;尾斜杠去掉;issuer / jwks 拼好', () => { + const c = loadConfig({ KEYCLOAK_URL: 'https://kc.test/', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'api-gateway' }); + expect(c.keycloak).toEqual({ + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', + }); + }); + it('缺一样就没有 keycloak 段(main.ts 据此拒绝启动)', () => { + expect(loadConfig({ KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp' }).keycloak).toBeUndefined(); + expect(loadConfig({ KEYCLOAK_URL: ' ', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }).keycloak).toBeUndefined(); + }); + it('provisioning 两样都给才带', () => { + const base = { KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }; + expect( + loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning', KEYCLOAK_PROVISIONING_CLIENT_SECRET: 's3' }).keycloak + ?.provisioning, + ).toEqual({ clientId: 'provisioning', secret: 's3' }); + expect(loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning' }).keycloak?.provisioning).toBeUndefined(); + }); + it('测试账号逗号分隔、去空白、丢空项', () => { + expect(loadConfig({ JCAI_TEST_ACCOUNTS: ' ai, chaihao ,, ' }).testAccounts).toEqual(['ai', 'chaihao']); + expect(loadConfig({}).testAccounts).toEqual([]); + }); +}); diff --git a/apps/api/src/app/__tests__/keycloak-check.test.ts b/apps/api/src/app/__tests__/keycloak-check.test.ts new file mode 100644 index 0000000..c728f2d --- /dev/null +++ b/apps/api/src/app/__tests__/keycloak-check.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { KeycloakConfig } from '../config.ts'; +import { checkKeycloakConnection } from '../keycloak-check.ts'; + +const kc: KeycloakConfig = { + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', +}; +const reply = (status: number, body: unknown): typeof fetch => async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('启动自检(手册第 5 步)', () => { + it('issuer 一致 → 回 issuer 与 clientId', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: kc.issuer }))).resolves.toEqual({ + issuer: kc.issuer, + clientId: 'api-gateway', + }); + }); + it('issuer 不一致 → 抛,话里带期望 / 实际 / KC_HOSTNAME', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: 'http://localhost:8080/realms/corp' }))).rejects.toThrow( + /KC_HOSTNAME/, + ); + }); + it('发现文档拉不到 → 抛带状态码', async () => { + await expect(checkKeycloakConnection(kc, reply(503, {}))).rejects.toThrow(/503/); + }); +}); diff --git a/apps/api/src/app/__tests__/route-coverage.it.test.ts b/apps/api/src/app/__tests__/route-coverage.it.test.ts new file mode 100644 index 0000000..9c81f0b --- /dev/null +++ b/apps/api/src/app/__tests__/route-coverage.it.test.ts @@ -0,0 +1,87 @@ +/** + * 路由覆盖(fail-closed,ADR-012):Hono 里每一条注册了的路由都得在路由注册表里有归属。 + * 漏一条 = 一个没人管的口子,这里把它变成测试红。要真容器只是为了把路由都挂上,不发请求。 + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from '../config.ts'; +import { createContainer } from '../container.ts'; +import type { Container } from '../container.ts'; +import { appRouteRegistry, createServer } from '../server.ts'; + +const IT_DB = 'jcai_it_routes'; + +async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { + const explicit = process.env['JCAI_TEST_DATABASE_URL']; + if (explicit !== undefined) return { url: explicit, stop: async () => undefined }; + const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; + try { + const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); + const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); + if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); + await admin.end(); + return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; + } catch { + /* 开发实例连不上,试 Testcontainers */ + } + try { + const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); + const started = await new PostgreSqlContainer('postgres:16-alpine').start(); + return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; + } catch (error) { + process.stderr.write( + `⚠️ 路由覆盖 it 跳过:连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, + ); + return undefined; + } +} + +function migrationsFolder(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i += 1) { + const candidate = join(dir, 'db', 'migrations'); + if (existsSync(candidate)) return candidate; + const parent = resolve(dir, '..'); + if (parent === dir) break; + dir = parent; + } + throw new Error('找不到 db/migrations'); +} + +const database = await provisionDatabase(); +let pool: Pool | undefined; +let container: Container | undefined; + +beforeAll(async () => { + if (database === undefined) return; + pool = new Pool({ connectionString: database.url, max: 4 }); + for (const schema of ['sys', 'ledger', 'mastra', 'queue']) { + await pool.query(`drop schema if exists "${schema}" cascade`); + await pool.query(`create schema "${schema}"`); + } + await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); + container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); +}, 180_000); + +afterAll(async () => { + await container?.close(); + await pool?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('路由覆盖', () => { + it('Hono 里每条注册的路由都在归属注册表里', () => { + const app = createServer(container!); + const registry = appRouteRegistry(); + const missing = app.routes + .filter((r) => r.method !== 'ALL') + .filter((r) => !registry.has(r.method, r.path)) + .map((r) => `${r.method} ${r.path}`); + expect(missing, '这些路由没有声明归哪个端(契约加 access,或 EXTRA_ROUTES 补入)').toEqual([]); + }); +}); diff --git a/apps/api/src/app/keycloak-check.ts b/apps/api/src/app/keycloak-check.ts new file mode 100644 index 0000000..9b7cab1 --- /dev/null +++ b/apps/api/src/app/keycloak-check.ts @@ -0,0 +1,22 @@ +/** + * 启动自检(手册第 5 步;ADR-012):拉 `.well-known/openid-configuration`, + * issuer 与本地拼出来的不一致就别起 —— 配错了每个请求都 401 还查不出原因,不如启动时就说清楚。 + */ +import type { KeycloakConfig } from './config.ts'; + +export async function checkKeycloakConnection( + kc: KeycloakConfig, + fetchImpl: typeof fetch = fetch, +): Promise<{ issuer: string; clientId: string }> { + const url = `${kc.issuer}/.well-known/openid-configuration`; + const resp = await fetchImpl(url, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); + const conf = (await resp.json()) as { issuer?: unknown }; + if (conf.issuer !== kc.issuer) { + throw new Error( + `issuer 不一致!\n 期望: ${kc.issuer}\n 实际: ${String(conf.issuer)}\n` + + ' → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了', + ); + } + return { issuer: kc.issuer, clientId: kc.clientId }; +} diff --git a/apps/api/src/app/main.ts b/apps/api/src/app/main.ts index 75b5997..27590a2 100644 --- a/apps/api/src/app/main.ts +++ b/apps/api/src/app/main.ts @@ -5,6 +5,7 @@ import { serve } from '@hono/node-server'; import { loadConfig } from './config.ts'; import { createContainer } from './container.ts'; +import { checkKeycloakConnection } from './keycloak-check.ts'; import { createServer } from './server.ts'; import { createConsumers } from './workers/consumers.ts'; import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts'; @@ -13,6 +14,20 @@ import { registerPulse } from './workers/pulse.ts'; export async function boot(): Promise { const config = loadConfig(); + if (config.keycloak === undefined) { + process.stderr.write( + '✗ 缺 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID —— 统一鉴权中心是唯一的登录途径,缺了谁也进不来(ADR-012)\n', + ); + process.exit(1); + } + try { + const info = await checkKeycloakConnection(config.keycloak); + process.stdout.write(`✓ Keycloak 连通 · issuer=${info.issuer} · client=${info.clientId}\n`); + } catch (error) { + // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因(手册第 5 步) + process.stderr.write(`✗ Keycloak 连不上或配置有误:${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } const container = await createContainer(config); await container.org.ensureSeeded();