Archived
启动自检 issuer 不一致拒绝启动;路由覆盖 fail-closed;Keycloak 配置解析测试(ADR-012)
上一个任务把统一鉴权中心接上了,但缺配置照常起、issuer 配错也照常起 —— 每个请求都 401 还查不出原因。boot() 开头补两道保险:缺 KEYCLOAK_URL/REALM/CLIENT_ID 直接拒绝启动;配了就拉一次 .well-known/openid-configuration 核对 issuer,不一致同样拒绝启动 (话里带期望/实际/KC_HOSTNAME,照后端接入手册第 5 步)。 另加路由覆盖 it 测试:Hono 里每条注册路由都必须能在 appRouteRegistry() 里查到归属,查不到就是一个没人管的权限口子。真库跑通,96 条路由全部 有归属、0 条查不到——把上一个任务里人工核对的结论固化成测试。
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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<void> } | 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user