mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
The suite drove a containerized freight API, so every code change needed an image rebuild before a test could see it, and there was no way to attach a debugger. Files also ran strictly in sequence against one shared database, which is the root of the warm-stack gotchas the README documents: stowaway paid bookings climbing back aboard, a short consist on the fifth file. The freight app now boots inside each vitest worker from dist/, and each worker owns a whole shard of the topology - its own database, payment API, gateway mock and broker vhost - so nothing mutable is shared and files run in parallel. Full suite drops from roughly 20 minutes to 196s at 4 shards. - main.ts exports createFreightApp() so the harness applies the same prefix, pipes, filters and interceptors as production instead of replaying them by hand; self-start is guarded by require.main so the Dockerfile CMD still boots - booking-window tick cadence is env-driven (BOOKING_WINDOW_TICK_CRON), */1 in the suite, */10 unchanged in production - prepare-shards.mjs seeds a template database (boot seeders, then the SQL fixtures that depend on them) and clones it per shard; it.mjs re-clones on every run, so each run is hermetic - gateway mock and payment API are generated per shard: the mock keeps modes and orders process-global and 20 of 25 specs reset it in beforeAll, and the inbound CBE bill query has to reach one specific shard's app - poll() samples every 250ms instead of 2000ms, keeping the caller's deadline - authz.it.ts seeds its own invoice; it previously read another spec's leftover and returned early, which silently passed on a pristine database Known: an unlocked MAX(sequence_no)+1 in train-scheduling.service.ts races under concurrent allocation and leaves a short consist, so 1-3 specs fail intermittently. Pre-existing and reproduces at the production tick cadence.
190 lines
7.7 KiB
TypeScript
190 lines
7.7 KiB
TypeScript
import "reflect-metadata";
|
|
import * as dotenv from "dotenv";
|
|
dotenv.config();
|
|
import { createRequire } from "node:module";
|
|
import { NestFactory } from "@nestjs/core";
|
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
|
import {
|
|
HttpExceptionFilter,
|
|
ResponseTransformInterceptor,
|
|
createValidationPipe,
|
|
} from "@edr/api-common";
|
|
|
|
import { AppModule } from "./app.module";
|
|
|
|
/**
|
|
* JSON body ceiling. Signing posts the signature AND the company stamp as
|
|
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
|
|
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
|
* image with a 413 "request entity too large".
|
|
*/
|
|
const JSON_BODY_LIMIT = "20mb";
|
|
|
|
/**
|
|
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
|
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
|
|
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
|
|
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
|
|
* connection only — so TLS still validates against the certificate's CN.
|
|
*
|
|
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
|
|
* namespace object is frozen, so assigning to it is silently dropped and the
|
|
* patch becomes a no-op. `require` returns the live module object every other
|
|
* caller (minio's http agent included) reads `lookup` off.
|
|
*/
|
|
function applyDnsHostOverrides(): void {
|
|
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
|
|
if (!raw) return;
|
|
|
|
const overrides = new Map<string, string>();
|
|
for (const entry of raw.split(",")) {
|
|
const [host, ip] = entry.split("=").map((part) => part?.trim());
|
|
if (host && ip) overrides.set(host.toLowerCase(), ip);
|
|
}
|
|
if (overrides.size === 0) return;
|
|
|
|
const dns = createRequire(__filename)(
|
|
"node:dns",
|
|
) as typeof import("node:dns");
|
|
const originalLookup = dns.lookup.bind(dns);
|
|
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
|
// cast keeps that surface intact while we intercept only mapped hostnames.
|
|
(dns as { lookup: unknown }).lookup = ((
|
|
hostname: string,
|
|
options: unknown,
|
|
callback?: unknown,
|
|
) => {
|
|
const ip = overrides.get(hostname?.toLowerCase?.());
|
|
if (!ip) return (originalLookup as Function)(hostname, options, callback);
|
|
|
|
const done = (typeof options === "function" ? options : callback) as (
|
|
err: NodeJS.ErrnoException | null,
|
|
address: string | { address: string; family: number }[],
|
|
family?: number,
|
|
) => void;
|
|
const family = ip.includes(":") ? 6 : 4;
|
|
const wantsAll =
|
|
typeof options === "object" &&
|
|
options !== null &&
|
|
(options as { all?: boolean }).all;
|
|
|
|
process.nextTick(() =>
|
|
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
|
);
|
|
}) as typeof dns.lookup;
|
|
|
|
console.log(
|
|
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
applyDnsHostOverrides();
|
|
|
|
/**
|
|
* Build the app with every global the production process applies, but do NOT
|
|
* listen. Exported so a test harness can boot the REAL app in its own process
|
|
* (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor
|
|
* and body-parser configuration — replaying this list by hand is how an e2e
|
|
* harness silently drifts from production (routes 404 without the "api"
|
|
* prefix, responses lose the transform envelope).
|
|
*/
|
|
export async function createFreightApp(): Promise<NestExpressApplication> {
|
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
|
|
|
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
|
// is not a declared dependency of this app (it arrives under
|
|
// @nestjs/platform-express), so importing it directly resolved only through
|
|
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
|
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
|
// This also RECONFIGURES the default parsers rather than racing them.
|
|
app.useBodyParser("json", { limit: JSON_BODY_LIMIT });
|
|
app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true });
|
|
|
|
// Dev CORS: reflect any localhost origin and allow credentials so the
|
|
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
|
// and any other dev port can call the API with cookies + Authorization.
|
|
// For production, restrict `origin` to known FQDNs.
|
|
|
|
app.enableCors({
|
|
origin: true, // reflect request origin
|
|
credentials: true,
|
|
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
|
|
allowedHeaders: [
|
|
"Content-Type",
|
|
"Accept",
|
|
"Authorization",
|
|
"X-Requested-With",
|
|
// Which freight frontend is calling — /auth/login uses this to reject
|
|
// cross-audience credentials (EDRFREIGHT-415).
|
|
"X-Client-App",
|
|
// IAM context headers required by @tria-plc/api-common's JwtGuard
|
|
"organization-unit-id",
|
|
"delegator-position-id",
|
|
"current-project-id",
|
|
"current-position-id",
|
|
// x-prefixed variants sent by the user-management / record-management
|
|
// frontend modules (same values, different naming convention)
|
|
"x-organization-unit-id",
|
|
"x-delegator-id",
|
|
"x-delegator-position-id",
|
|
"x-current-project-id",
|
|
"x-current-position-id",
|
|
// Headers sent by the freight-backoffice OKR/objective-service client
|
|
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
|
|
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
|
|
// and "x-delegated-position-id" (delegated, not delegator).
|
|
"tenant-key",
|
|
"unit-id",
|
|
"x-delegated-position-id",
|
|
],
|
|
exposedHeaders: ["Content-Disposition"],
|
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
|
});
|
|
|
|
// /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack
|
|
// endpoint. Exact path, not "fayda" — exclusion is an exact route match, so
|
|
// "fayda" would leave /fayda/callback prefixed (404 at the registered
|
|
// redirect_uri) while still reading as if it covered the whole subtree.
|
|
app.setGlobalPrefix("api", { exclude: ["fayda/callback"] });
|
|
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
|
// coercion turns any non-empty multipart/form-data string (including the
|
|
// literal "false") into `true`, silently corrupting flags like isHazardous
|
|
// and isGovernment. With it off, only explicit @Transform/@Type decorators
|
|
// coerce values — every numeric/boolean DTO field in this API already has one.
|
|
app.useGlobalPipes(
|
|
createValidationPipe({
|
|
transformOptions: { enableImplicitConversion: false },
|
|
}),
|
|
);
|
|
app.useGlobalFilters(new HttpExceptionFilter());
|
|
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
|
|
|
const config = new DocumentBuilder()
|
|
.setTitle("EDR Freight API")
|
|
.setDescription("API for the EDR Freight Management application")
|
|
.setVersion("0.1.0")
|
|
.addBearerAuth()
|
|
.build();
|
|
const document = SwaggerModule.createDocument(app, config);
|
|
SwaggerModule.setup("api/docs", app, document);
|
|
|
|
return app;
|
|
}
|
|
|
|
async function bootstrap() {
|
|
const app = await createFreightApp();
|
|
const port = parseInt(process.env.PORT ?? "3001", 10);
|
|
// await app.listen(port, "0.0.0.0");
|
|
await app.listen(port);
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[freight-api] listening on port ${port}`);
|
|
}
|
|
|
|
// Only self-start when this file IS the entrypoint. The Dockerfile's
|
|
// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp`
|
|
// without the process binding a port behind their back.
|
|
if (require.main === module) {
|
|
bootstrap();
|
|
}
|