feat: 添加 NestJS API 基础设施
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Inject } from "@nestjs/common";
|
||||
import { HealthService } from "./health.service.js";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(
|
||||
@Inject(HealthService) private readonly healthService: HealthService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
check(): Promise<{ status: "ok"; postgres: "up"; redis: "up" }> {
|
||||
return this.healthService.check();
|
||||
}
|
||||
|
||||
@Get("live")
|
||||
live(): { status: "ok" } {
|
||||
return { status: "ok" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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 { Test } from "@nestjs/testing";
|
||||
import { 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";
|
||||
|
||||
class ProbeDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
@Controller("probe")
|
||||
class ProbeController {
|
||||
@Post()
|
||||
echo(@Body() dto: ProbeDto): ProbeDto {
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Get("domain-error")
|
||||
domainError(): never {
|
||||
throw new DomainException(
|
||||
ErrorCode.CONTENT_REJECTED,
|
||||
"content rejected",
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("internal-error")
|
||||
internalError(): never {
|
||||
throw new Error(`sensitive ${process.env.DATABASE_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(
|
||||
"design:paramtypes",
|
||||
[ProbeDto],
|
||||
ProbeController.prototype,
|
||||
"echo",
|
||||
);
|
||||
|
||||
describe("API infrastructure", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
controllers: [ProbeController],
|
||||
}).compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it("reports real dependency readiness in the uniform success envelope", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/health")
|
||||
.expect(200);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "OK",
|
||||
message: "success",
|
||||
data: { status: "ok", postgres: "up", redis: "up" },
|
||||
});
|
||||
expect(response.body.requestId).toEqual(expect.any(String));
|
||||
expect(response.headers["x-request-id"]).toBe(response.body.requestId);
|
||||
});
|
||||
|
||||
it("passes through a safe caller request id", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("x-request-id", "client_req-123")
|
||||
.expect(200);
|
||||
expect(response.body.requestId).toBe("client_req-123");
|
||||
expect(response.headers["x-request-id"]).toBe("client_req-123");
|
||||
});
|
||||
|
||||
it.each(["x".repeat(129), "bad value"])(
|
||||
"replaces unsafe request id %s",
|
||||
async (unsafeId) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("x-request-id", unsafeId)
|
||||
.expect(200);
|
||||
expect(response.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(response.body.requestId).not.toBe(unsafeId);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns a uniform 404 envelope", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/missing")
|
||||
.expect(404);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Not Found",
|
||||
data: null,
|
||||
});
|
||||
expect(response.body.requestId).toBe(response.headers["x-request-id"]);
|
||||
});
|
||||
|
||||
it("returns a domain exception envelope and status", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/probe/domain-error")
|
||||
.expect(422);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "CONTENT_REJECTED",
|
||||
message: "content rejected",
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak internal errors", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/probe/internal-error")
|
||||
.expect(500);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Internal server error",
|
||||
data: null,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("postgresql://");
|
||||
expect(response.body).not.toHaveProperty("stack");
|
||||
});
|
||||
|
||||
it("rejects non-whitelisted DTO properties in the uniform envelope", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v1/probe")
|
||||
.send({ name: "ok", extra: true })
|
||||
.expect(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies helmet and same-origin credentialed CORS", async () => {
|
||||
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
|
||||
const allowed = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("Origin", allowedOrigin)
|
||||
.expect(200);
|
||||
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");
|
||||
const denied = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("Origin", "https://evil.example")
|
||||
.expect(200);
|
||||
expect(denied.headers).not.toHaveProperty("access-control-allow-origin");
|
||||
});
|
||||
|
||||
it("returns a sanitized 503 when a real dependency is unavailable", async () => {
|
||||
const originalRedisUrl = process.env.REDIS_URL;
|
||||
process.env.REDIS_URL = "redis://127.0.0.1:1";
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).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("redis://");
|
||||
} finally {
|
||||
await unavailableApp.close();
|
||||
process.env.REDIS_URL = originalRedisUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
import { HealthService } from "./health.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService, PrismaService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Inject, Injectable, type OnModuleDestroy } from "@nestjs/common";
|
||||
import { createClient, type RedisClientType } from "redis";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import { DomainException } from "../common/domain.exception.js";
|
||||
import { ErrorCode } from "@drift/contracts";
|
||||
import { HttpStatus } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class HealthService implements OnModuleDestroy {
|
||||
private readonly redis: RedisClientType;
|
||||
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {
|
||||
this.redis = createClient({
|
||||
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
|
||||
socket: { connectTimeout: 500, reconnectStrategy: false },
|
||||
});
|
||||
this.redis.on("error", () => undefined);
|
||||
}
|
||||
|
||||
async check(): Promise<{ status: "ok"; postgres: "up"; redis: "up" }> {
|
||||
try {
|
||||
if (!this.redis.isOpen) await this.redis.connect();
|
||||
await Promise.all([this.prisma.$queryRaw`SELECT 1`, this.redis.ping()]);
|
||||
return { status: "ok", postgres: "up", redis: "up" };
|
||||
} catch {
|
||||
throw new DomainException(
|
||||
ErrorCode.SERVICE_UNAVAILABLE,
|
||||
"Dependencies unavailable",
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.redis.isOpen) await this.redis.quit();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user