307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import 'reflect-metadata';
|
|
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { Test } from '@nestjs/testing';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import type { OpenAPIObject } from '@nestjs/swagger';
|
|
import { IsString, MaxLength } from 'class-validator';
|
|
import { AppModule } from '../src/app.module';
|
|
import { configureApplication } from '../src/app.setup';
|
|
import { createApplication } from '../src/app.factory';
|
|
import { AppLogger } from '../src/logging/app-logger';
|
|
import { ACCOUNT_READER, type Account } from '../src/auth/account-reader';
|
|
import { CurrentUser, Public, Roles } from '../src/auth/auth.decorators';
|
|
import { JwtTokenService } from '../src/auth/jwt-token.service';
|
|
import type { AuthenticatedUser } from '../src/common/http-request';
|
|
import { testConfig } from './test-config';
|
|
|
|
class EchoDto {
|
|
@IsString()
|
|
@MaxLength(100)
|
|
message!: string;
|
|
}
|
|
|
|
// 只在测试模块注册,生产不增加探测身份或签发 token 的路由。
|
|
@Controller('probe')
|
|
class ProbeController {
|
|
@Get('me')
|
|
me(@CurrentUser() user: AuthenticatedUser) {
|
|
return user;
|
|
}
|
|
|
|
@Get('admin')
|
|
@Roles('admin')
|
|
admin() {
|
|
return { ok: true };
|
|
}
|
|
|
|
@Public()
|
|
@Post('echo')
|
|
echo(@Body() body: EchoDto) {
|
|
return body;
|
|
}
|
|
|
|
@Public()
|
|
@Get('failure')
|
|
failure() {
|
|
throw new Error('private-internal-error');
|
|
}
|
|
}
|
|
|
|
test('HTTP 中间件、校验、日志、Swagger 与 JWT 全局守卫', async (context) => {
|
|
const logs: string[] = [];
|
|
const logger = new AppLogger('info', (line) => logs.push(line));
|
|
const config = {
|
|
...testConfig,
|
|
bodyLimitBytes: 1024,
|
|
corsOrigins: ['http://localhost:3001'],
|
|
};
|
|
// 用可变账户快照模拟禁用、角色和凭证失效,不依赖真实用户或开发数据库。
|
|
const accounts = new Map<string, Account>([
|
|
[
|
|
'UserA',
|
|
{ id: 'UserA', authVersion: 1, role: 'user', status: 'active' },
|
|
],
|
|
[
|
|
'AdminA',
|
|
{ id: 'AdminA', authVersion: 1, role: 'admin', status: 'active' },
|
|
],
|
|
]);
|
|
const module = await Test.createTestingModule({
|
|
imports: [AppModule.forRoot(config)],
|
|
controllers: [ProbeController],
|
|
})
|
|
.overrideProvider(ACCOUNT_READER)
|
|
.useValue({ findById: async (id: string) => accounts.get(id) ?? null })
|
|
.compile();
|
|
const app = module.createNestApplication<NestExpressApplication>({
|
|
bodyParser: false,
|
|
logger,
|
|
});
|
|
context.after(() => app.close());
|
|
configureApplication(app, config, logger);
|
|
await app.listen(0, '127.0.0.1');
|
|
const origin = await app.getUrl();
|
|
const tokens = app.get(JwtTokenService);
|
|
const jwt = app.get(JwtService);
|
|
const userToken = await tokens.signAccessToken('UserA', 1);
|
|
const request = (path: string, options?: RequestInit) =>
|
|
fetch(`${origin}${path}`, {
|
|
...options,
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
const health = await request('/api/v1/health?code=private-login-code', {
|
|
headers: {
|
|
'x-request-id': 'request_test_123',
|
|
Origin: 'http://localhost:3001',
|
|
},
|
|
});
|
|
assert.equal(health.status, 200);
|
|
assert.equal(health.headers.get('x-request-id'), 'request_test_123');
|
|
assert.equal(
|
|
health.headers.get('access-control-allow-origin'),
|
|
'http://localhost:3001',
|
|
);
|
|
assert.equal(health.headers.get('x-content-type-options'), 'nosniff');
|
|
assert.equal(health.headers.get('x-powered-by'), null);
|
|
const deniedOrigin = await request('/api/v1/health', {
|
|
headers: { Origin: 'https://untrusted.example' },
|
|
});
|
|
assert.equal(deniedOrigin.headers.get('access-control-allow-origin'), null);
|
|
const missingToken = await request('/api/v1/probe/me');
|
|
assert.equal(missingToken.status, 401);
|
|
const errorBody = (await missingToken.json()) as {
|
|
code: number;
|
|
requestId: string;
|
|
};
|
|
assert.equal(errorBody.code, 401);
|
|
assert.equal(errorBody.requestId, missingToken.headers.get('x-request-id'));
|
|
|
|
const authenticated = await request('/api/v1/probe/me', {
|
|
headers: { Authorization: `Bearer ${userToken}` },
|
|
});
|
|
assert.equal(authenticated.status, 200);
|
|
assert.deepEqual(await authenticated.json(), {
|
|
userId: 'UserA',
|
|
authVersion: 1,
|
|
role: 'user',
|
|
});
|
|
assert.equal(
|
|
(
|
|
await request('/api/v1/probe/admin', {
|
|
headers: { Authorization: `Bearer ${userToken}` },
|
|
})
|
|
).status,
|
|
403,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await request('/api/v1/probe/admin', {
|
|
headers: {
|
|
Authorization: `Bearer ${await tokens.signAccessToken('AdminA', 1)}`,
|
|
},
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
|
|
// 用同一 JWT 库构造异常凭证,验证算法、签发方、受众和字段校验均会生效。
|
|
const sign = (claims: object, overrides: object = {}) =>
|
|
jwt.signAsync(claims, {
|
|
secret: config.jwtSecret,
|
|
algorithm: 'HS256',
|
|
issuer: config.jwtIssuer,
|
|
audience: config.jwtAudience,
|
|
expiresIn: 60,
|
|
...overrides,
|
|
});
|
|
for (const token of [
|
|
'invalid-token',
|
|
await tokens.signAccessToken('UserA', 0),
|
|
await tokens.signAccessToken('DeletedUser', 1),
|
|
await sign({ sub: 'UserA', authVersion: 1 }, { expiresIn: -1 }),
|
|
await sign({ sub: 'UserA', authVersion: 1 }, { audience: 'other' }),
|
|
await sign({ sub: 'UserA', authVersion: 1 }, { issuer: 'other' }),
|
|
await sign({ sub: 'UserA', authVersion: 1 }, { algorithm: 'HS384' }),
|
|
await sign(
|
|
{ sub: 'UserA', authVersion: 1 },
|
|
{ secret: 'wrong-secret' },
|
|
),
|
|
await sign({ sub: 'UserA', authVersion: '1' }),
|
|
await jwt.signAsync(
|
|
{ sub: 'UserA', authVersion: 1 },
|
|
{
|
|
secret: config.jwtSecret,
|
|
issuer: config.jwtIssuer,
|
|
audience: config.jwtAudience,
|
|
},
|
|
),
|
|
])
|
|
assert.equal(
|
|
(
|
|
await request('/api/v1/probe/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
).status,
|
|
401,
|
|
);
|
|
// 即使签名正确,token 自报 admin 也不能覆盖服务端账户中的 user 角色。
|
|
const forgedRole = await sign({
|
|
sub: 'UserA',
|
|
authVersion: 1,
|
|
role: 'admin',
|
|
});
|
|
assert.equal(
|
|
(
|
|
await request('/api/v1/probe/admin', {
|
|
headers: { Authorization: `Bearer ${forgedRole}` },
|
|
})
|
|
).status,
|
|
403,
|
|
);
|
|
accounts.get('UserA')!.status = 'disabled';
|
|
assert.equal(
|
|
(
|
|
await request('/api/v1/probe/me', {
|
|
headers: { Authorization: `Bearer ${userToken}` },
|
|
})
|
|
).status,
|
|
401,
|
|
);
|
|
|
|
const echo = (body: string, contentType = 'application/json') =>
|
|
request('/api/v1/probe/echo', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': contentType },
|
|
body,
|
|
});
|
|
assert.equal((await echo('{"message":"ok"}')).status, 201);
|
|
assert.equal(
|
|
(await echo('message=ok', 'application/x-www-form-urlencoded')).status,
|
|
201,
|
|
);
|
|
assert.equal((await echo('{"message":12}')).status, 400);
|
|
assert.equal((await echo('{"message":"ok","extra":true}')).status, 400);
|
|
const malformed = await echo('{broken-json');
|
|
assert.equal(malformed.status, 400);
|
|
assert.ok(malformed.headers.get('x-request-id'));
|
|
assert.equal(
|
|
(await echo(JSON.stringify({ message: 'x'.repeat(2000) }))).status,
|
|
413,
|
|
);
|
|
const failure = await request('/api/v1/probe/failure');
|
|
assert.equal(failure.status, 500);
|
|
assert.ok(!(await failure.text()).includes('private-internal-error'));
|
|
|
|
assert.equal((await request('/api-docs')).status, 200);
|
|
const openapi = (await (
|
|
await request('/api-docs-json')
|
|
).json()) as OpenAPIObject;
|
|
assert.ok(openapi.paths['/api/v1/health']);
|
|
assert.deepEqual(openapi.paths['/api/v1/health']?.get?.security, []);
|
|
assert.equal(
|
|
(
|
|
openapi.components?.securitySchemes?.['access-token'] as {
|
|
scheme: string;
|
|
}
|
|
).scheme,
|
|
'bearer',
|
|
);
|
|
// 检查完整请求链日志,确认解析失败仍有日志,且查询参数、异常原文和 token 未泄漏。
|
|
const requestLogs = logs.filter(
|
|
(line) => JSON.parse(line).message === 'http_request_completed',
|
|
);
|
|
assert.ok(
|
|
requestLogs.some(
|
|
(line) => JSON.parse(line).context.status_code === 413,
|
|
),
|
|
);
|
|
assert.ok(!logs.join('\n').includes('private-login-code'));
|
|
assert.ok(!logs.join('\n').includes('private-internal-error'));
|
|
assert.ok(!logs.join('\n').includes(userToken));
|
|
});
|
|
|
|
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => {
|
|
const config = {
|
|
...testConfig,
|
|
nodeEnv: 'production' as const,
|
|
swaggerEnabled: false,
|
|
};
|
|
const module = await Test.createTestingModule({
|
|
imports: [AppModule.forRoot(config)],
|
|
controllers: [ProbeController],
|
|
}).compile();
|
|
const logger = new AppLogger('error', () => {});
|
|
const app = module.createNestApplication<NestExpressApplication>({
|
|
bodyParser: false,
|
|
logger,
|
|
});
|
|
context.after(() => app.close());
|
|
configureApplication(app, config, logger);
|
|
await app.listen(0, '127.0.0.1');
|
|
const origin = await app.getUrl();
|
|
for (const path of ['/api-docs', '/api-docs-json'])
|
|
assert.equal((await fetch(`${origin}${path}`)).status, 404);
|
|
const token = await app.get(JwtTokenService).signAccessToken('UserA', 1);
|
|
assert.equal(
|
|
(
|
|
await fetch(`${origin}/api/v1/probe/me`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
).status,
|
|
503,
|
|
);
|
|
});
|
|
|
|
test('导入应用工厂不会监听固定端口', async (context) => {
|
|
const app = await createApplication(
|
|
testConfig,
|
|
new AppLogger('error', () => {}),
|
|
);
|
|
context.after(() => app.close());
|
|
assert.equal(app.getHttpServer().listening, false);
|
|
});
|