/** * 接口冒烟测试:对着真的服务和真的内核,把每类响应走一遍。 * * 重点在认证和边界——这两块出错的后果比功能不好用严重得多:令牌校验写歪了等于没有认证, * 透传白名单漏了等于把内核的全部接口暴露出去。 * * 前置条件: * 1. 本地测试内核在跑(见 .env.example) * 2. Patchbay 在跑:bun run server/index.ts * * 用法:bun run scripts/api-smoke.ts [令牌] * 不给令牌就从配置文件旁边的 .patchbay-token 读。 */ import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { readEnv } from '../server/env.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' const env = readEnv() const base = `http://${env.host}:${String(env.port)}` const token = process.argv[2] ?? (await readFile(join(dirname(env.configPath), '.patchbay-token'), 'utf8')).trim() let failures = 0 function check(name: string, actual: unknown, expected: unknown) { if (JSON.stringify(actual) === JSON.stringify(expected)) { console.log(`✓ ${name}`) } else { failures += 1 console.log(`✗ ${name}\n 期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`) } } async function call(method: string, path: string, body?: unknown, auth = true) { const res = await fetch(base + path, { method, headers: { ...(auth ? { Authorization: `Bearer ${token}` } : {}), 'Content-Type': 'application/json', }, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await res.text() return { status: res.status, body: text ? (JSON.parse(text) as Record) : null } } const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject // ── 认证与边界 ────────────────────────────────────────── check('没带令牌被拒', (await call('GET', '/api/config', undefined, false)).status, 401) check( '令牌不对被拒', (await fetch(`${base}/api/config`, { headers: { Authorization: 'Bearer wrong' } })).status, 401, ) check('白名单外的透传被拒', (await call('GET', '/api/kernel/configs/geo')).status, 403) check('不存在的接口', (await call('GET', '/api/nope')).status, 404) check('非 api 路径', (await call('GET', '/index.html')).status, 404) // ── 配置读写 ──────────────────────────────────────────── const snapshot = await call('GET', '/api/config') check('读配置', snapshot.status, 200) const data = snapshot.body!.data as ConfigObject const revision = snapshot.body!.revision as string const stale = clone(data) stale.mode = 'global' check( '基准过时被拒', (await call('PUT', '/api/config', { data: stale, baseRevision: '过时的指纹' })).status, 409, ) const dangling = clone(data) dangling.listeners = [{ name: '冒烟-断引用', type: 'mixed', port: 17899, proxy: '不存在的出站' }] const danglingResult = await call('PUT', '/api/config', { data: dangling, baseRevision: revision }) check('断引用被自己的检查拦下', danglingResult.status, 422) check('拦在 self-check 这一步', danglingResult.body?.stage, 'self-check') const badType = clone(data) badType.listeners = [{ name: '冒烟-怪类型', type: '不是合法类型', port: 17898 }] const badTypeResult = await call('PUT', '/api/config', { data: badType, baseRevision: revision }) check('非法类型被内核拦下', badTypeResult.status, 422) check('拦在 kernel-check 这一步', badTypeResult.body?.stage, 'kernel-check') const good = clone(data) good.listeners = [{ name: '冒烟-正常', type: 'mixed', port: 17896, proxy: 'PROXY' }] const goodResult = await call('PUT', '/api/config', { data: good, baseRevision: revision }) check('正常写入', goodResult.status, 200) check('内核已重载', goodResult.body?.reloaded, true) // ── 其余 ──────────────────────────────────────────────── check('备份列表可读', (await call('GET', '/api/config/backups')).status, 200) check('内核状态可读', (await call('GET', '/api/kernel')).body?.online, true) check('内核透传可用', (await call('GET', '/api/kernel/version')).status, 200) check( '字段表可读', Object.keys(((await call('GET', '/api/meta/listener-fields')).body?.types ?? {}) as object).length, 20, ) console.log(failures === 0 ? '\n全部通过' : `\n${String(failures)} 项没通过`) process.exit(failures === 0 ? 0 : 1)