Files
2026-10-16 14:10:10 +07:00

173 lines
13 KiB
TypeScript

import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import { createContext, responseHeaders } from '../src/server/context';
import { buildUpstreamHeaders, upstreamGet } from '../src/server/upstream';
import { handleRequest } from '../src/server/handler';
type Received = { path: string; headers: Record<string, string | string[] | undefined>; body: string };
let server: Server; let upstream = ''; let scenario = '';
const received: Received[] = [];
const originalURL = process.env.UPSTREAM_API_URL;
before(async () => {
server = createServer(async (request, response) => {
let body = ''; for await (const chunk of request) body += chunk;
const path = request.url || '';
received.push({ path, headers: request.headers, body });
if (scenario === 'offline') { response.writeHead(503).end('unavailable'); return; }
if (path === '/api/app/redirect') { response.writeHead(302, { Location: '/api/app/config/info' }).end(); return; }
if (scenario === 'media-redirects' && (path.startsWith('/h5/m3u8/redirect.m3u8?') || path.startsWith('/enkey/?'))) {
const query = path.slice(path.indexOf('?'));
response.writeHead(path.startsWith('/enkey/') ? 301 : 302, { Location: (path.startsWith('/enkey/') ? '/media-key-target' : '/h5/m3u8/playlist.m3u8') + query }).end(); return;
}
if (path.startsWith('/media-key-target?')) { response.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end('1234567890123456'); return; }
if (scenario === 'media-business-error' && path.startsWith('/h5/m3u8/')) { response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 6020, msg: '' })); return; }
if (path.startsWith('/h5/m3u8/')) { response.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' }).end('#EXTM3U\nsegment.ts\n'); return; }
if (path.startsWith('/enkey/')) { response.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end(Buffer.from(scenario === 'binary-brace' ? '{234567890123456' : '1234567890123456')); return; }
if (path.startsWith('/api/app/coin/trans/export')) { response.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="records.csv"' }).end('id,amount\n1,2\n'); return; }
if (path === '/api/app/upload/image') { response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ code: 200, data: { id: 'asset-1', sourceNameFs: 'https://cdn.example/image.png' } })); return; }
if (scenario === 'cookies') response.setHeader('Set-Cookie', ['upstream_a=a; Path=/; HttpOnly', 'upstream_b=b; Path=/; SameSite=Lax']);
let data: unknown = { ok: true }; let code = 200;
if (path === '/api/app/auth/guest' || path === '/api/app/auth/login') data = { token: 'guest-fresh', userId: 12345, nickName: '测试 用户', accountNo: 'guest', type: 0, recharge: 1020 };
if (scenario === 'auth-rejected' && path === '/api/app/auth/login') { code = 1001; data = 'upstream-error'; }
if (path.startsWith('/api/app/playlet/search/suggest')) {
if (scenario === 'expired' && request.headers.authorization === 'stale') code = 6001;
else data = { keyword: '真实', list: [{ id: 1, slug: 'real', playName: '真实响应' }] };
}
response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ code, data, msg: 'ok', tip: '' }));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); if (!address || typeof address === 'string') throw new Error('No upstream address');
upstream = `http://127.0.0.1:${address.port}`; process.env.UPSTREAM_API_URL = upstream;
});
after(async () => { await new Promise<void>(resolve => server.close(() => resolve())); if (originalURL === undefined) delete process.env.UPSTREAM_API_URL; else process.env.UPSTREAM_API_URL = originalURL; });
const request = (path: string, init: RequestInit = {}) => new Request('http://local.example' + path, init);
const post = (path: string, body: unknown, headers: HeadersInit = {}) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...Object.fromEntries(new Headers(headers)) }, body: JSON.stringify(body) });
test('SSR/upstream preserve client cookies, cookie-token priority, IP, and custom UA headers', async () => {
scenario = ''; received.length = 0;
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12'} }));
await upstreamGet(ctx, '/api/app/config/info');
const headers = received[0].headers;
assert.equal(headers.authorization, 'cookie-token'); assert.equal(headers.cookie, 'token=cookie-token; extra=keep%20me');
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12');
});
test('public data requests isolate both stale Authorization and auth cookies', async () => {
scenario = ''; received.length = 0;
await upstreamGet(createContext(request('/', { headers: { Cookie: 'token=stale', Authorization: 'stale' } })), '/api/app/config/info', { public: true });
assert.equal(received[0].headers.authorization, undefined); assert.equal(received[0].headers.cookie, undefined);
});
test('JSON APIs retain the original follow-redirect behavior', async () => {
scenario = ''; received.length = 0;
const result = await upstreamGet<{ code: number }>(createContext(request('/')), '/api/app/redirect');
assert.equal(result.code, 200); assert.deepEqual(received.map(item => item.path), ['/api/app/redirect', '/api/app/config/info']);
});
test('hop-by-hop, Connection-nominated and internal headers never cross the BFF', () => {
const ctx = createContext(request('/', { headers: { Connection: 'keep-alive, x-internal', 'X-Internal': 'no', 'X-Origin-Head': '1', 'X-Tracker-Request-Id': 'forged', 'X-DC-Request-Context': 'forged' } }));
const headers = buildUpstreamHeaders(ctx);
for (const name of ['connection', 'x-internal', 'x-origin-head', 'x-tracker-request-id', 'x-dc-request-context']) assert.equal(headers.has(name), false);
});
test('Set-Cookie values remain separate and updated same-name cookies are written once', async () => {
scenario = 'cookies'; received.length = 0;
const ctx = createContext(request('/api/auth/login'));
await upstreamGet(ctx, '/api/app/config/info');
ctx.setCookie('nickName', '中文 名称', { maxAge: 100 }); ctx.setCookie('nickName', '新名称', { maxAge: 100 });
const headers = responseHeaders(ctx, responseHeaders(ctx));
assert.equal(headers.getSetCookie().length, 3);
assert.equal(headers.getSetCookie().filter(value => value.startsWith('nickName=')).length, 1);
assert.ok(headers.getSetCookie().some(value => value.startsWith('nickName=' + encodeURIComponent('新名称'))));
scenario = '';
});
test('guest authentication establishes SSR-compatible cookies with its success tip', async () => {
scenario = '';
const guest = await handleRequest(post('/api/auth/guest', { devID: 'device-1' }));
assert.equal(guest.status, 200);
const result = await guest.json(); assert.equal(result.code, 200); assert.equal(result.tip, '游客登录成功');
assert.ok(guest.headers.getSetCookie().some(value => value.startsWith('token=guest-fresh;')));
assert.ok(guest.headers.getSetCookie().some(value => value.startsWith('nickName=' + encodeURIComponent('测试 用户'))));
});
test('removed account APIs are unregistered and never reach the upstream', async () => {
scenario = ''; received.length = 0;
for (const [path, init] of [
['/api/auth/login', { method: 'POST', body: JSON.stringify({ account: 'account', password: 'secret' }) }],
['/api/auth/logout', { method: 'POST', body: '{}' }],
['/api/playlet/history/clear', { method: 'POST' }],
['/api/playlet/chapter/like/set', { method: 'POST', body: JSON.stringify({ chapterId: 1, isLike: true }) }],
['/api/coin/trans/export?type=1', {}],
] as [string, RequestInit][]) {
const response = await handleRequest(request(path, { ...init, headers: { Cookie: 'token=session', 'Content-Type': 'application/json' } }));
assert.equal(response.status, 404, path);
assert.equal(response.headers.get('Content-Disposition'), null, path);
assert.equal(response.headers.getSetCookie().some(value => value.startsWith('token=')), false, path);
}
assert.equal(received.some(item => /\/api\/app\/(?:auth\/(?:login|logout)|playlet\/history|playlet\/chapter\/like|coin\/trans)/.test(item.path)), false);
});
test('watch reports require a session before reaching the upstream', async () => {
scenario = ''; received.length = 0;
const unauthorized = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 1 }));
assert.equal(unauthorized.status, 200); assert.equal((await unauthorized.json()).code, 401);
assert.equal(received.some(item => item.path === '/api/app/playlet/chapter/watch/report'), false);
});
test('expired content sessions recover once and the same request uses the newly issued token', async () => {
scenario = 'expired'; received.length = 0;
const response = await handleRequest(request('/api/web/search/suggest?keyword=' + encodeURIComponent('真实'), { headers: { Cookie: 'token=stale' } }));
assert.match(JSON.stringify(await response.json()), /真实响应/);
assert.equal(received.filter(item => item.path === '/api/app/auth/guest').length, 1);
assert.deepEqual(received.filter(item => item.path.startsWith('/api/app/playlet/search/suggest')).map(item => item.headers.authorization), ['stale', 'guest-fresh']);
scenario = '';
});
test('invalid action payloads never reach the upstream; network failures remain JSON responses', async () => {
scenario = ''; received.length = 0;
const invalid = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 'x' }, { Cookie: 'token=session' }));
assert.equal((await invalid.json()).code, 400); assert.equal(received.length, 0);
scenario = 'offline';
const response = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 1 }, { Cookie: 'token=session' }));
assert.equal(response.status, 200); assert.equal((await response.json()).code, 500); assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow');
scenario = '';
});
test('multipart uploads retain generated boundary, file contents and the original frontend URL field', async () => {
scenario = ''; received.length = 0;
const form = new FormData(); form.set('file', new File(['PNG-data'], 'test.png', { type: 'image/png' }));
const response = await handleRequest(request('/api/upload/image', { method: 'POST', body: form, headers: { Cookie: 'token=session' } }));
assert.equal((await response.json()).data.url, 'https://cdn.example/image.png');
assert.match(String(received[0].headers['content-type']), /^multipart\/form-data; boundary=/);
assert.match(received[0].body, /name="watermark"\r\n\r\n0/); assert.match(received[0].body, /PNG-data/);
});
test('signed HLS and encryption key proxy preserve query parameters without session authentication', async () => {
scenario = ''; received.length = 0;
const response = await handleRequest(request('/h5/m3u8/playlist.m3u8?token=signed&quality=1080', { headers: { Cookie: 'token=session', Authorization: 'session', 'X-User-Agent': 'player-UA' } }));
assert.equal(response.status, 200); assert.equal(await response.text(), '#EXTM3U\nsegment.ts\n');
assert.equal(received[0].path, '/h5/m3u8/playlist.m3u8?token=signed&quality=1080'); assert.equal(received[0].headers.authorization, undefined); assert.equal(received[0].headers.cookie, undefined);
assert.equal(received[0].headers['x-user-agent'], 'player-UA');
const key = await handleRequest(request('/enkey?token=signed'));
assert.equal((await key.arrayBuffer()).byteLength, 16); assert.equal(key.headers.get('Access-Control-Allow-Origin'), '*'); assert.equal(received.at(-1)!.path, '/enkey/?token=signed');
scenario = 'binary-brace';
const binary = await handleRequest(request('/enkey?token=signed'));
assert.equal(await binary.text(), '{234567890123456');
scenario = '';
});
test('signed media and key redirects retain the original follow behavior and signature bytes', async () => {
scenario = 'media-redirects';
const query = '?ticket=a%2Bb%3D&path=%2Ffoo&keep=1';
try {
for (const [path, target, body] of [
['/h5/m3u8/redirect.m3u8', '/h5/m3u8/playlist.m3u8', '#EXTM3U\nsegment.ts\n'],
['/enkey', '/media-key-target', '1234567890123456'],
]) {
received.length = 0;
const response = await handleRequest(request(path + query, { headers: { Cookie: 'token=session', Authorization: 'session' } }));
assert.equal(response.status, 200); assert.equal(await response.text(), body);
assert.equal(received.at(-1)?.path, target + query);
for (const call of received) { assert.equal(call.headers.authorization, undefined); assert.equal(call.headers.cookie, undefined); }
}
} finally { scenario = ''; }
});
test('upstream media business errors become 502 and preserve the original error envelope', async () => {
scenario = 'media-business-error';
try {
const response = await handleRequest(request('/h5/m3u8/error.m3u8?ticket=original'));
assert.equal(response.status, 502); assert.deepEqual(await response.json(), { code: 6020, msg: '' });
assert.equal(response.headers.get('Content-Type'), 'application/json; charset=utf-8');
} finally { scenario = ''; }
});