Files
BPOS-Service-Gateway/test/integration/proxy.test.ts
T

127 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { gzipSync } from 'node:zlib';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../src/app/app';
import { makeToken, setupTestJwks } from '../support/token';
// C5:路由的目标地址取自 config,而 config 在模块求值时一次性读 env——
// 注入必须早于 import(vi.hoisted 会被提升到所有 import 之前)。
vi.hoisted(() => {
process.env.TASK_SERVICE_URL = 'http://127.0.0.1:18811';
process.env.DATA_SERVICE_URL = 'http://127.0.0.1:1'; // 保证连不上:验 502
});
let downstream: ReturnType<typeof serve>;
let captured: { auth?: string; fake?: string; path?: string; method?: string; body?: string } = {};
/** 主机保全见证:只有真打到"配置的下游"这台服务的请求才会记进来(C1 回归用) */
const hits: string[] = [];
describe('gateway proxy', () => {
let token = '';
beforeAll(async () => {
await setupTestJwks();
token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const echo = new Hono();
// 下游压缩响应:undici 会替网关解码 body,content-encoding 不能原样回传(D9)
echo.get('/gzip', () => {
const body = gzipSync(JSON.stringify({ from: 'downstream' }));
return new Response(body, {
headers: {
'content-encoding': 'gzip',
'content-type': 'application/json',
'content-length': String(body.byteLength),
},
});
});
echo.all('*', async (c) => {
captured = {
auth: c.req.header('authorization') ?? '',
fake: c.req.header('x-user-id') ?? '',
path: c.req.path,
method: c.req.method,
body: await c.req.text(),
};
return c.json({ from: 'downstream' });
});
// D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上
// 在路由之前记账:不管 echo 内部匹不匹得上这条路径,只要连上了这台服务就算"主机没被换掉"
const witnessed = (req: Request): Response | Promise<Response> => {
hits.push(new URL(req.url).pathname);
return echo.fetch(req);
};
downstream = await new Promise<ReturnType<typeof serve>>((resolve) => {
const s = serve({ fetch: witnessed, port: 18811 }, () => resolve(s));
});
});
afterAll(() => {
downstream.close();
});
it('无 token → 401,不打扰下游', async () => {
captured = {};
const res = await buildApp().request('/api/task/task/t-1/view');
expect(res.status).toBe(401);
expect(captured.path).toBeUndefined();
});
it('带 token 转发:前缀剥除、Authorization 原样、伪造内部头被剥', async () => {
const res = await buildApp().request('/api/task/task/t-1/view', {
headers: { authorization: `Bearer ${token}`, 'x-user-id': 'forged-user' },
});
expect(res.status).toBe(200);
expect(captured.path).toBe('/task/t-1/view');
expect(captured.auth).toBe(`Bearer ${token}`);
expect(captured.fake).toBe('');
});
it('POST 转发:方法与请求体原样到下游(F1)', async () => {
const res = await buildApp().request('/api/task/task/t-1/claim', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ note: 'hello' }),
});
expect(res.status).toBe(200);
expect(captured.method).toBe('POST');
expect(captured.path).toBe('/task/t-1/claim');
expect(JSON.parse(captured.body ?? '')).toEqual({ note: 'hello' });
expect(await res.json()).toEqual({ from: 'downstream' });
});
it('下游不可达 → 502 统一错误形状,点名是哪个下游(C3)', async () => {
const res = await buildApp().request('/api/data/ledger/domains', {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).toBe(502);
const body = (await res.json()) as { error: string; message: string; hint: string };
expect(body.error).toBe('bad_gateway');
expect(body.message).toContain('http://127.0.0.1:1');
expect(body.hint).toBeTruthy();
});
// C1 回归:剥前缀后余下的路径若以 // 开头就是协议相对 URL,旧写法 new URL(path, target)
// 会让它顶掉 target 的 host——Authorization 与请求体直接外泄到攻击者选定的主机。
// 两个变体都选"必然连不上"的主机:位移一旦发生就是 502(端口 1 拒连 / 域名解析失败),
// 而 hits 里没有记录;主机保全则相反:不是 502,且配置的下游确实收到了这条路径。
it('路径双斜杠不得位移下游主机:Authorization 不外泄(C1)', async () => {
for (const injected of ['//127.0.0.1:1/x', '//evil.example.com/x']) {
hits.length = 0;
const res = await buildApp().request(`/api/task${injected}`, {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).not.toBe(502); // 502=网关真去连了被注入的主机
expect(hits).toEqual([injected]); // 配置的下游收到了=host 没被换掉
}
});
it('下游 gzip 响应:解码后的 body 不得再带 content-encoding(D9)', async () => {
const res = await buildApp().request('/api/task/gzip', {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
expect(res.headers.get('content-encoding')).toBeNull();
expect(res.headers.get('content-length')).toBeNull();
expect(await res.json()).toEqual({ from: 'downstream' });
});
});