feat: 网关按域路由——验签拦门、剥伪造内部头、Authorization 原样转发
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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 } = {};
|
||||
|
||||
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('*', (c) => {
|
||||
captured = {
|
||||
auth: c.req.header('authorization') ?? '',
|
||||
fake: c.req.header('x-user-id') ?? '',
|
||||
path: c.req.path,
|
||||
};
|
||||
return c.json({ from: 'downstream' });
|
||||
});
|
||||
// D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上
|
||||
downstream = await new Promise<ReturnType<typeof serve>>((resolve) => {
|
||||
const s = serve({ fetch: echo.fetch, 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('下游不可达 → 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();
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user