fix: 完善 API 基础设施验收

This commit is contained in:
root
2026-09-14 13:30:03 +08:00
parent f3fb16dbc0
commit 61562f6893
5 changed files with 201 additions and 17 deletions
@@ -9,11 +9,21 @@ import type { Observable } from "rxjs";
import { map } from "rxjs/operators";
import type { RequestWithId } from "./request-id.middleware.js";
const isEnvelope = (value: unknown): value is SuccessResponse<unknown> =>
const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
export const isSuccessEnvelope = (
value: unknown,
): value is SuccessResponse<unknown> =>
typeof value === "object" &&
value !== null &&
"code" in value &&
"requestId" in value;
value.code === "OK" &&
"message" in value &&
value.message === "success" &&
"data" in value &&
"requestId" in value &&
typeof value.requestId === "string" &&
SAFE_REQUEST_ID.test(value.requestId);
@Injectable()
export class HttpResponseInterceptor<T> implements NestInterceptor<
@@ -28,7 +38,9 @@ export class HttpResponseInterceptor<T> implements NestInterceptor<
return next
.handle()
.pipe(
map((data) => (isEnvelope(data) ? data : ok(data, request.requestId))),
map((data) =>
isSuccessEnvelope(data) ? data : ok(data, request.requestId),
),
);
}
}
+4 -2
View File
@@ -5,6 +5,9 @@ import type { NextFunction, Request, Response } from "express";
export const REQUEST_ID_HEADER = "x-request-id";
const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
export const sanitizeRequestId = (supplied: string | undefined): string =>
supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID();
export interface RequestWithId extends Request {
requestId: string;
}
@@ -13,8 +16,7 @@ export interface RequestWithId extends Request {
export class RequestIdMiddleware implements NestMiddleware {
use(request: Request, response: Response, next: NextFunction): void {
const supplied = request.header(REQUEST_ID_HEADER);
const requestId =
supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID();
const requestId = sanitizeRequestId(supplied);
(request as RequestWithId).requestId = requestId;
response.setHeader(REQUEST_ID_HEADER, requestId);
next();
+172 -10
View File
@@ -1,22 +1,37 @@
import "reflect-metadata";
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */
import { Body, Controller, Get, HttpStatus, Post } from "@nestjs/common";
import {
Body,
Controller,
Get,
HttpStatus,
Post,
type INestApplication,
} from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { IsString } from "class-validator";
import { ErrorCode, ok } from "@drift/contracts";
import { Type } from "class-transformer";
import { IsInt, IsString } from "class-validator";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { INestApplication } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { AppModule } from "../app.module";
import { DomainException } from "../common/domain.exception";
import { configureApp } from "../main";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { AppModule } from "../app.module.js";
import { DomainException } from "../common/domain.exception.js";
import { sanitizeRequestId } from "../common/request-id.middleware.js";
import { PrismaService } from "../database/prisma.service.js";
import { bootstrap, configureApp } from "../main.js";
import { HealthService } from "./health.service.js";
class ProbeDto {
@IsString()
name!: string;
}
class TransformProbeDto {
@Type(() => Number)
@IsInt()
count!: number;
}
@Controller("probe")
class ProbeController {
@Post()
@@ -24,6 +39,25 @@ class ProbeController {
return dto;
}
@Post("transform")
transform(@Body() dto: TransformProbeDto): { value: number; type: string } {
return { value: dto.count, type: typeof dto.count };
}
@Get("envelope")
envelope() {
return ok({ already: "wrapped" }, "controller-request-id");
}
@Get("envelope-lookalike")
envelopeLookalike() {
return {
code: "BUSINESS_CODE",
requestId: "business-request-id",
value: 1,
};
}
@Get("domain-error")
domainError(): never {
throw new DomainException(
@@ -45,6 +79,12 @@ Reflect.defineMetadata(
ProbeController.prototype,
"echo",
);
Reflect.defineMetadata(
"design:paramtypes",
[TransformProbeDto],
ProbeController.prototype,
"transform",
);
describe("API infrastructure", () => {
let app: INestApplication;
@@ -97,6 +137,16 @@ describe("API infrastructure", () => {
},
);
it.each(["line\rbreak", "line\nbreak", "line\r\nbreak"])(
"sanitizes CR/LF in request ids without relying on an HTTP client accepting it",
(unsafeId) => {
const sanitized = sanitizeRequestId(unsafeId);
expect(sanitized).toMatch(/^[0-9a-f-]{36}$/);
expect(sanitized).not.toContain("\r");
expect(sanitized).not.toContain("\n");
},
);
it("returns a uniform 404 envelope", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/missing")
@@ -144,6 +194,39 @@ describe("API infrastructure", () => {
});
});
it("transforms DTO values before invoking the handler", async () => {
const response = await request(app.getHttpServer())
.post("/api/v1/probe/transform")
.send({ count: "42" })
.expect(201);
expect(response.body.data).toEqual({ value: 42, type: "number" });
});
it("does not double-wrap a valid SuccessResponse returned by a controller", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/probe/envelope")
.expect(200);
expect(response.body).toEqual({
code: "OK",
message: "success",
data: { already: "wrapped" },
requestId: "controller-request-id",
});
});
it("wraps a business object that merely resembles an envelope", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/probe/envelope-lookalike")
.expect(200);
expect(response.body.code).toBe("OK");
expect(response.body.message).toBe("success");
expect(response.body.data).toEqual({
code: "BUSINESS_CODE",
requestId: "business-request-id",
value: 1,
});
});
it("applies helmet and same-origin credentialed CORS", async () => {
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
const allowed = await request(app.getHttpServer())
@@ -153,6 +236,8 @@ describe("API infrastructure", () => {
expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin);
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
expect(allowed.headers["x-content-type-options"]).toBe("nosniff");
// Standard CORS rejects browser access by omitting ACAO, not by changing HTTP status.
const denied = await request(app.getHttpServer())
.get("/api/v1/health/live")
.set("Origin", "https://evil.example")
@@ -160,7 +245,15 @@ describe("API infrastructure", () => {
expect(denied.headers).not.toHaveProperty("access-control-allow-origin");
});
it("returns a sanitized 503 when a real dependency is unavailable", async () => {
it("allows non-browser requests without an Origin header", async () => {
const response = await request(app.getHttpServer())
.get("/api/v1/health/live")
.expect(200);
expect(response.body.data).toEqual({ status: "ok" });
expect(response.headers).not.toHaveProperty("access-control-allow-origin");
});
it("returns a sanitized 503 specifically when Redis is unavailable", async () => {
const originalRedisUrl = process.env.REDIS_URL;
process.env.REDIS_URL = "redis://127.0.0.1:1";
const module = await Test.createTestingModule({
@@ -184,4 +277,73 @@ describe("API infrastructure", () => {
process.env.REDIS_URL = originalRedisUrl;
}
});
it("returns a sanitized 503 specifically when PostgreSQL is unavailable", async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(PrismaService)
.useValue({
$queryRaw: vi
.fn()
.mockRejectedValue(new Error("secret postgres failure")),
})
.compile();
const unavailableApp = module.createNestApplication();
configureApp(unavailableApp);
await unavailableApp.init();
try {
const response = await request(unavailableApp.getHttpServer())
.get("/api/v1/health")
.expect(503);
expect(response.body).toMatchObject({
code: "SERVICE_UNAVAILABLE",
message: "Dependencies unavailable",
data: null,
});
expect(JSON.stringify(response.body)).not.toContain(
"secret postgres failure",
);
} finally {
await unavailableApp.close();
}
});
it("closes the real Redis client during module destruction", async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const lifecycleApp = module.createNestApplication();
configureApp(lifecycleApp);
await lifecycleApp.init();
const health = module.get(HealthService);
await health.check();
const redis = health.redisClient;
expect(redis.isOpen).toBe(true);
await lifecycleApp.close();
expect(redis.isOpen).toBe(false);
await expect(redis.ping()).rejects.toThrow();
});
it("bootstraps with numeric PORT, public host, and shutdown hooks", async () => {
const previousPort = process.env.PORT;
process.env.PORT = "4312";
const appDouble = {
setGlobalPrefix: vi.fn(),
use: vi.fn(),
useGlobalPipes: vi.fn(),
useGlobalInterceptors: vi.fn(),
useGlobalFilters: vi.fn(),
enableCors: vi.fn(),
enableShutdownHooks: vi.fn(),
listen: vi.fn().mockResolvedValue(undefined),
};
try {
await bootstrap(() =>
Promise.resolve(appDouble as unknown as INestApplication),
);
expect(appDouble.enableShutdownHooks).toHaveBeenCalledOnce();
expect(appDouble.listen).toHaveBeenCalledWith(4312, "0.0.0.0");
} finally {
process.env.PORT = previousPort;
}
});
});
+4
View File
@@ -9,6 +9,10 @@ import { HttpStatus } from "@nestjs/common";
export class HealthService implements OnModuleDestroy {
private readonly redis: RedisClientType;
get redisClient(): RedisClientType {
return this.redis;
}
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {
this.redis = createClient({
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
+6 -2
View File
@@ -30,8 +30,12 @@ export function configureApp(app: INestApplication): void {
});
}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
type AppFactory = () => Promise<INestApplication>;
export async function bootstrap(
appFactory: AppFactory = () => NestFactory.create(AppModule),
): Promise<void> {
const app = await appFactory();
configureApp(app);
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT ?? 3001), "0.0.0.0");