Files
doodle-store/apps/server/test/application.test.ts
T

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);
});