@ogygie/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +77 -0
- package/src/bundle-budget.test.ts +64 -0
- package/src/bundle-budget.ts +51 -0
- package/src/clock.ts +24 -0
- package/src/contract-purity.test.ts +185 -0
- package/src/contract-purity.ts +213 -0
- package/src/db.ts +9 -0
- package/src/decision-refs.test.ts +114 -0
- package/src/decision-refs.ts +103 -0
- package/src/design-brief.test.ts +221 -0
- package/src/design-brief.ts +581 -0
- package/src/error.schema.ts +62 -0
- package/src/examples/erreur-convention-ci.json +10 -0
- package/src/examples/erreur-validation-runtime.json +10 -0
- package/src/manifest-client.test.ts +287 -0
- package/src/manifest-client.ts +313 -0
- package/src/manifest-gateway.test.ts +541 -0
- package/src/manifest-gateway.ts +392 -0
- package/src/manifest.schema.test.ts +255 -0
- package/src/manifest.schema.ts +187 -0
- package/src/module-purity.test.ts +117 -0
- package/src/module-purity.ts +127 -0
- package/src/product-vocabulary.scan.test.ts +142 -0
- package/src/shared-tables.test.ts +136 -0
- package/src/shared-tables.ts +113 -0
- package/src/spec.schema.test.ts +143 -0
- package/src/spec.schema.ts +182 -0
- package/src/tenant.ts +207 -0
- package/src/test-kit.ts +30 -0
- package/src/vocabulary.test.ts +46 -0
- package/src/vocabulary.ts +36 -0
- package/src/web-lint.test.ts +100 -0
- package/src/web-lint.ts +176 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the behaviors of the GENERIC manifest-derived gateway
|
|
3
|
+
* factory: mounted inventory = manifest routes (mutation test included),
|
|
4
|
+
* input validated WITHOUT any upstream call, OgygieError relayed intact,
|
|
5
|
+
* 502/504 on upstream failure, security headers on every response, static
|
|
6
|
+
* SPA fallback with per-request nonce, boot refusal on incoherent manifest.
|
|
7
|
+
* The upstream is a real local Fastify recording every hit — no mock of our
|
|
8
|
+
* own relay code.
|
|
9
|
+
*
|
|
10
|
+
* Framework-side file: its fixtures carry no product vocabulary either (the
|
|
11
|
+
* vocabulary test scans this file too). The product-side proof — the REAL
|
|
12
|
+
* manifest against the REAL schema registry — stays in the consuming app.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
18
|
+
import Fastify from "fastify";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { OgygieFailure, ogygieError } from "./error.schema.js";
|
|
22
|
+
import {
|
|
23
|
+
createManifestGateway,
|
|
24
|
+
type ManifestGatewayOptions,
|
|
25
|
+
} from "./manifest-gateway.js";
|
|
26
|
+
|
|
27
|
+
// ------------------------------------------------------------ test fixtures
|
|
28
|
+
const CreateWidgetInputSchema = z.object({ label: z.string().min(3) });
|
|
29
|
+
const ListWidgetsInputSchema = z.object({});
|
|
30
|
+
const WidgetParamsSchema = z.object({ widgetId: z.uuid() });
|
|
31
|
+
const EmptyInputSchema = z.object({});
|
|
32
|
+
const WidgetOutputSchema = z.object({});
|
|
33
|
+
|
|
34
|
+
const TEST_SCHEMAS = {
|
|
35
|
+
CreateWidgetInputSchema,
|
|
36
|
+
ListWidgetsInputSchema,
|
|
37
|
+
WidgetParamsSchema,
|
|
38
|
+
EmptyInputSchema,
|
|
39
|
+
WidgetOutputSchema,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface TestRoute {
|
|
43
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
44
|
+
path: string;
|
|
45
|
+
description: string;
|
|
46
|
+
schemaInput: string;
|
|
47
|
+
schemaOutput: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BASE_ROUTES: TestRoute[] = [
|
|
51
|
+
{
|
|
52
|
+
method: "POST",
|
|
53
|
+
path: "/api/widgets",
|
|
54
|
+
description: "creates a widget (relay test route)",
|
|
55
|
+
schemaInput: "CreateWidgetInputSchema",
|
|
56
|
+
schemaOutput: "WidgetOutputSchema",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
method: "GET",
|
|
60
|
+
path: "/api/widgets",
|
|
61
|
+
description: "lists widgets (relay test route)",
|
|
62
|
+
schemaInput: "ListWidgetsInputSchema",
|
|
63
|
+
schemaOutput: "WidgetOutputSchema",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
method: "GET",
|
|
67
|
+
path: "/api/widgets/:widgetId",
|
|
68
|
+
description: "reads one widget (params validation test route)",
|
|
69
|
+
schemaInput: "WidgetParamsSchema",
|
|
70
|
+
schemaOutput: "WidgetOutputSchema",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
method: "GET",
|
|
74
|
+
path: "/api/failing",
|
|
75
|
+
description: "upstream answers a business OgygieError (relay purity)",
|
|
76
|
+
schemaInput: "EmptyInputSchema",
|
|
77
|
+
schemaOutput: "WidgetOutputSchema",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
method: "GET",
|
|
81
|
+
path: "/api/slow",
|
|
82
|
+
description: "upstream sleeps longer than the gateway timeout",
|
|
83
|
+
schemaInput: "EmptyInputSchema",
|
|
84
|
+
schemaOutput: "WidgetOutputSchema",
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function buildTestManifest(routes: TestRoute[] = BASE_ROUTES) {
|
|
89
|
+
return {
|
|
90
|
+
version: 1,
|
|
91
|
+
product: { name: "generic-product", description: "Produit de test du relais générique." },
|
|
92
|
+
apps: [
|
|
93
|
+
{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" },
|
|
94
|
+
],
|
|
95
|
+
features: [
|
|
96
|
+
{
|
|
97
|
+
name: "widgets",
|
|
98
|
+
intent: "Feature de test générique du relais dérivé du manifeste.",
|
|
99
|
+
folder: "src/features/widgets",
|
|
100
|
+
spec: null,
|
|
101
|
+
routes,
|
|
102
|
+
dbTables: [],
|
|
103
|
+
dependsOn: [],
|
|
104
|
+
status: "delivered",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Business error served by the upstream — built ONCE so its timestamp is
|
|
112
|
+
// stable and the relayed body can be compared byte-for-byte.
|
|
113
|
+
const UPSTREAM_BUSINESS_ERROR = ogygieError({
|
|
114
|
+
code: "WIDGET_NOT_READY",
|
|
115
|
+
message: "The widget is not ready to be served.",
|
|
116
|
+
file: "apps/api/src/features/widgets/service.ts",
|
|
117
|
+
cause: "widget state is draft",
|
|
118
|
+
suggestedFix: "Publish the widget before reading it.",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
interface UpstreamRecorder {
|
|
122
|
+
hits: Record<string, number>;
|
|
123
|
+
lastHeaders: Record<string, string | string[] | undefined>;
|
|
124
|
+
lastBody: unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function startUpstream() {
|
|
128
|
+
const recorder: UpstreamRecorder = { hits: {}, lastHeaders: {}, lastBody: null };
|
|
129
|
+
const upstream = Fastify({ logger: false });
|
|
130
|
+
const record = (key: string, headers: UpstreamRecorder["lastHeaders"], body: unknown) => {
|
|
131
|
+
recorder.hits[key] = (recorder.hits[key] ?? 0) + 1;
|
|
132
|
+
recorder.lastHeaders = headers;
|
|
133
|
+
recorder.lastBody = body;
|
|
134
|
+
};
|
|
135
|
+
upstream.post("/api/widgets", async (request, reply) => {
|
|
136
|
+
record("createWidget", request.headers, request.body);
|
|
137
|
+
return reply.code(201).send({ created: true, echo: request.body });
|
|
138
|
+
});
|
|
139
|
+
upstream.get("/api/widgets", async (request, reply) => {
|
|
140
|
+
record("listWidgets", request.headers, null);
|
|
141
|
+
return reply.code(200).send({ widgets: [] });
|
|
142
|
+
});
|
|
143
|
+
upstream.get("/api/widgets/:widgetId", async (request, reply) => {
|
|
144
|
+
record("getWidget", request.headers, null);
|
|
145
|
+
return reply.code(200).send({ params: request.params });
|
|
146
|
+
});
|
|
147
|
+
upstream.get("/api/failing", async (request, reply) => {
|
|
148
|
+
record("failing", request.headers, null);
|
|
149
|
+
return reply.code(409).send(UPSTREAM_BUSINESS_ERROR);
|
|
150
|
+
});
|
|
151
|
+
upstream.get("/api/slow", async (request, reply) => {
|
|
152
|
+
record("slow", request.headers, null);
|
|
153
|
+
await sleep(400);
|
|
154
|
+
return reply.code(200).send({ slow: true });
|
|
155
|
+
});
|
|
156
|
+
upstream.post("/api/auth/sign-in/email", async (request, reply) => {
|
|
157
|
+
record("authSignIn", request.headers, request.body);
|
|
158
|
+
reply.header("set-cookie", [
|
|
159
|
+
"session_token=abc123; Path=/; HttpOnly; SameSite=Strict",
|
|
160
|
+
"csrf_token=def456; Path=/; HttpOnly; SameSite=Strict",
|
|
161
|
+
]);
|
|
162
|
+
return reply.code(200).send({ signedIn: true });
|
|
163
|
+
});
|
|
164
|
+
await upstream.listen({ port: 0, host: "127.0.0.1" });
|
|
165
|
+
const address = upstream.server.address();
|
|
166
|
+
if (address === null || typeof address === "string") throw new Error("no upstream port");
|
|
167
|
+
return { upstream, recorder, baseUrl: `http://127.0.0.1:${address.port}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type Upstream = Awaited<ReturnType<typeof startUpstream>>;
|
|
171
|
+
let upstream: Upstream;
|
|
172
|
+
|
|
173
|
+
// Wide-open rate limit: these tests fire hundreds of requests; the rate-limit
|
|
174
|
+
// plugin's PRESENCE is asserted via headers, its ceiling is production config.
|
|
175
|
+
const TEST_RATE_LIMIT = { max: 100_000, timeWindow: "1 minute" };
|
|
176
|
+
|
|
177
|
+
function gatewayOptions(overrides: Partial<ManifestGatewayOptions> = {}): ManifestGatewayOptions {
|
|
178
|
+
return {
|
|
179
|
+
upstreamBaseUrl: upstream.baseUrl,
|
|
180
|
+
schemas: TEST_SCHEMAS,
|
|
181
|
+
rateLimit: TEST_RATE_LIMIT,
|
|
182
|
+
timeoutMs: 2_000,
|
|
183
|
+
csp: { nonce: true },
|
|
184
|
+
...overrides,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
beforeAll(async () => {
|
|
189
|
+
upstream = await startUpstream();
|
|
190
|
+
});
|
|
191
|
+
afterAll(async () => {
|
|
192
|
+
await upstream.upstream.close();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("manifest-gateway — dérivation du manifeste", () => {
|
|
196
|
+
it("ÉTANT DONNÉ un manifeste de N routes QUAND la passerelle démarre ALORS elle monte exactement N relais", async () => {
|
|
197
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
198
|
+
expect(app.gatewayRoutes.map(({ method, path }) => ({ method, path }))).toEqual(
|
|
199
|
+
BASE_ROUTES.map(({ method, path }) => ({ method, path })),
|
|
200
|
+
);
|
|
201
|
+
await app.close();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("MUTATION : une route ajoutée au manifeste apparaît et relaie sans aucune ligne de code", async () => {
|
|
205
|
+
const addedRoute: TestRoute = {
|
|
206
|
+
method: "GET",
|
|
207
|
+
path: "/api/widgets/:widgetId/details",
|
|
208
|
+
description: "route ajoutée par la mutation du manifeste",
|
|
209
|
+
schemaInput: "EmptyInputSchema",
|
|
210
|
+
schemaOutput: "WidgetOutputSchema",
|
|
211
|
+
};
|
|
212
|
+
const app = await createManifestGateway(
|
|
213
|
+
buildTestManifest([...BASE_ROUTES, addedRoute]),
|
|
214
|
+
gatewayOptions(),
|
|
215
|
+
);
|
|
216
|
+
expect(app.gatewayRoutes).toHaveLength(BASE_ROUTES.length + 1);
|
|
217
|
+
expect(app.gatewayRoutes.at(-1)?.path).toBe(addedRoute.path);
|
|
218
|
+
// The relay exists even though the upstream 404s it: the 404 comes from
|
|
219
|
+
// the UPSTREAM (plain Fastify payload), not from GATEWAY_ROUTE_UNKNOWN.
|
|
220
|
+
const response = await app.inject({ method: "GET", url: "/api/widgets/abc/details" });
|
|
221
|
+
expect(response.statusCode).toBe(404);
|
|
222
|
+
expect(response.body).not.toContain("GATEWAY_ROUTE_UNKNOWN");
|
|
223
|
+
await app.close();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("une route non déclarée répond 404 GATEWAY_ROUTE_UNKNOWN sans atteindre l'upstream", async () => {
|
|
227
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
228
|
+
const hitsBefore = { ...upstream.recorder.hits };
|
|
229
|
+
const response = await app.inject({ method: "DELETE", url: "/api/widgets" });
|
|
230
|
+
expect(response.statusCode).toBe(404);
|
|
231
|
+
expect(response.json().code).toBe("GATEWAY_ROUTE_UNKNOWN");
|
|
232
|
+
expect(upstream.recorder.hits).toEqual(hitsBefore);
|
|
233
|
+
await app.close();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("manifest-gateway — validation d'entrée (2e barrière)", () => {
|
|
238
|
+
it("une entrée invalide → 400 CONTRACT_VALIDATION_FAILED émis PAR la passerelle, sans appel upstream", async () => {
|
|
239
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
240
|
+
const hitsBefore = upstream.recorder.hits["createWidget"] ?? 0;
|
|
241
|
+
const response = await app.inject({
|
|
242
|
+
method: "POST",
|
|
243
|
+
url: "/api/widgets",
|
|
244
|
+
payload: { label: "ab" },
|
|
245
|
+
});
|
|
246
|
+
expect(response.statusCode).toBe(400);
|
|
247
|
+
const body = response.json();
|
|
248
|
+
expect(body.code).toBe("CONTRACT_VALIDATION_FAILED");
|
|
249
|
+
expect(body.cause).toContain("label");
|
|
250
|
+
expect(upstream.recorder.hits["createWidget"] ?? 0).toBe(hitsBefore);
|
|
251
|
+
await app.close();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("les params d'URL sont validés contre le schéma déclaré (uuid attendu)", async () => {
|
|
255
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
256
|
+
const invalid = await app.inject({ method: "GET", url: "/api/widgets/not-a-uuid" });
|
|
257
|
+
expect(invalid.statusCode).toBe(400);
|
|
258
|
+
expect(invalid.json().code).toBe("CONTRACT_VALIDATION_FAILED");
|
|
259
|
+
const valid = await app.inject({
|
|
260
|
+
method: "GET",
|
|
261
|
+
url: "/api/widgets/3b6f0a52-8f3c-4f5e-9f0a-2b1c6d7e8f90",
|
|
262
|
+
});
|
|
263
|
+
expect(valid.statusCode).toBe(200);
|
|
264
|
+
await app.close();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("un corps qui n'est pas du JSON valide n'atteint jamais le réseau privé", async () => {
|
|
268
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
269
|
+
const hitsBefore = upstream.recorder.hits["createWidget"] ?? 0;
|
|
270
|
+
const response = await app.inject({
|
|
271
|
+
method: "POST",
|
|
272
|
+
url: "/api/widgets",
|
|
273
|
+
headers: { "content-type": "application/json" },
|
|
274
|
+
payload: "{broken",
|
|
275
|
+
});
|
|
276
|
+
expect(response.statusCode).toBe(400);
|
|
277
|
+
expect(response.json().code).toBe("CONTRACT_VALIDATION_FAILED");
|
|
278
|
+
expect(upstream.recorder.hits["createWidget"] ?? 0).toBe(hitsBefore);
|
|
279
|
+
await app.close();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("manifest-gateway — relais pur", () => {
|
|
284
|
+
it("une requête valide est transmise avec cookie de session et x-request-id, réponse relayée", async () => {
|
|
285
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
286
|
+
const response = await app.inject({
|
|
287
|
+
method: "POST",
|
|
288
|
+
url: "/api/widgets",
|
|
289
|
+
headers: { cookie: "session_token=abc123" },
|
|
290
|
+
payload: { label: "widget zéro" },
|
|
291
|
+
});
|
|
292
|
+
expect(response.statusCode).toBe(201);
|
|
293
|
+
expect(response.json()).toEqual({ created: true, echo: { label: "widget zéro" } });
|
|
294
|
+
expect(upstream.recorder.lastHeaders["cookie"]).toBe("session_token=abc123");
|
|
295
|
+
expect(upstream.recorder.lastHeaders["x-request-id"]).toBeTruthy();
|
|
296
|
+
expect(response.headers["x-request-id"]).toBe(upstream.recorder.lastHeaders["x-request-id"]);
|
|
297
|
+
await app.close();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("un x-request-id fourni par le client est propagé tel quel", async () => {
|
|
301
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
302
|
+
await app.inject({
|
|
303
|
+
method: "GET",
|
|
304
|
+
url: "/api/widgets",
|
|
305
|
+
headers: { "x-request-id": "corr-42" },
|
|
306
|
+
});
|
|
307
|
+
expect(upstream.recorder.lastHeaders["x-request-id"]).toBe("corr-42");
|
|
308
|
+
await app.close();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("une OgygieError upstream traverse SANS reformatage — statut et corps inchangés, octet pour octet", async () => {
|
|
312
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
313
|
+
const direct = await fetch(`${upstream.baseUrl}/api/failing`);
|
|
314
|
+
const directBody = await direct.text();
|
|
315
|
+
const relayed = await app.inject({ method: "GET", url: "/api/failing" });
|
|
316
|
+
expect(relayed.statusCode).toBe(direct.status);
|
|
317
|
+
expect(relayed.body).toBe(directBody);
|
|
318
|
+
expect(relayed.json().code).toBe("WIDGET_NOT_READY");
|
|
319
|
+
await app.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("le passthrough /api/auth/* relaie sans validation et retransmet Set-Cookie intégralement", async () => {
|
|
323
|
+
const app = await createManifestGateway(
|
|
324
|
+
buildTestManifest(),
|
|
325
|
+
gatewayOptions({ passthroughPrefixes: ["/api/auth/"] }),
|
|
326
|
+
);
|
|
327
|
+
const response = await app.inject({
|
|
328
|
+
method: "POST",
|
|
329
|
+
url: "/api/auth/sign-in/email",
|
|
330
|
+
payload: { email: "user@example.com", password: "secret-password" },
|
|
331
|
+
});
|
|
332
|
+
expect(response.statusCode).toBe(200);
|
|
333
|
+
expect(response.json()).toEqual({ signedIn: true });
|
|
334
|
+
const cookies = response.headers["set-cookie"];
|
|
335
|
+
expect(cookies).toHaveLength(2);
|
|
336
|
+
expect(cookies?.[0]).toContain("session_token=abc123");
|
|
337
|
+
expect(cookies?.[1]).toContain("csrf_token=def456");
|
|
338
|
+
await app.close();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("manifest-gateway — upstream en panne", () => {
|
|
343
|
+
it("upstream injoignable → 502 GATEWAY_UPSTREAM_UNAVAILABLE, sans fuite d'URL privée", async () => {
|
|
344
|
+
const app = await createManifestGateway(
|
|
345
|
+
buildTestManifest(),
|
|
346
|
+
gatewayOptions({ upstreamBaseUrl: "http://127.0.0.1:1" }),
|
|
347
|
+
);
|
|
348
|
+
const response = await app.inject({ method: "GET", url: "/api/widgets" });
|
|
349
|
+
expect(response.statusCode).toBe(502);
|
|
350
|
+
expect(response.json().code).toBe("GATEWAY_UPSTREAM_UNAVAILABLE");
|
|
351
|
+
expect(response.body).not.toContain("127.0.0.1");
|
|
352
|
+
expect(response.body).not.toContain("stack");
|
|
353
|
+
await app.close();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("upstream trop lent → 504 GATEWAY_UPSTREAM_TIMEOUT", async () => {
|
|
357
|
+
const app = await createManifestGateway(
|
|
358
|
+
buildTestManifest(),
|
|
359
|
+
gatewayOptions({ timeoutMs: 80 }),
|
|
360
|
+
);
|
|
361
|
+
const response = await app.inject({ method: "GET", url: "/api/slow" });
|
|
362
|
+
expect(response.statusCode).toBe(504);
|
|
363
|
+
expect(response.json().code).toBe("GATEWAY_UPSTREAM_TIMEOUT");
|
|
364
|
+
await app.close();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("manifest-gateway — en-têtes de sécurité et front statique", () => {
|
|
369
|
+
function buildStaticRoot(): string {
|
|
370
|
+
const root = mkdtempSync(join(tmpdir(), "gateway-static-"));
|
|
371
|
+
writeFileSync(
|
|
372
|
+
join(root, "index.html"),
|
|
373
|
+
'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>',
|
|
374
|
+
);
|
|
375
|
+
mkdirSync(join(root, "assets"));
|
|
376
|
+
writeFileSync(join(root, "assets", "app.js"), 'console.log("static asset");');
|
|
377
|
+
return root;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
it("les en-têtes transverses sont posés ICI sur toute réponse (api comme front), CSP sans unsafe-inline", async () => {
|
|
381
|
+
const app = await createManifestGateway(
|
|
382
|
+
buildTestManifest(),
|
|
383
|
+
gatewayOptions({ staticRoot: buildStaticRoot() }),
|
|
384
|
+
);
|
|
385
|
+
for (const url of ["/api/widgets", "/app"]) {
|
|
386
|
+
const response = await app.inject({ method: "GET", url });
|
|
387
|
+
expect(response.statusCode).toBe(200);
|
|
388
|
+
const csp = response.headers["content-security-policy"];
|
|
389
|
+
expect(csp).toContain("default-src 'self'");
|
|
390
|
+
expect(csp).toContain("frame-ancestors 'none'");
|
|
391
|
+
expect(csp).not.toContain("unsafe-inline");
|
|
392
|
+
expect(response.headers["strict-transport-security"]).toContain("max-age");
|
|
393
|
+
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
|
394
|
+
expect(response.headers["referrer-policy"]).toBeTruthy();
|
|
395
|
+
expect(response.headers["permissions-policy"]).toContain("camera=()");
|
|
396
|
+
expect(response.headers["x-ratelimit-limit"]).toBeDefined();
|
|
397
|
+
}
|
|
398
|
+
await app.close();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("les en-têtes de sécurité de la passerelle priment sur ceux relayés de l'upstream", async () => {
|
|
402
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
403
|
+
const response = await app.inject({ method: "GET", url: "/api/widgets" });
|
|
404
|
+
// The upstream (plain Fastify) sets none of these; if it did, the
|
|
405
|
+
// gateway's own values must win — here we prove the gateway's are there.
|
|
406
|
+
expect(response.headers["content-security-policy"]).toContain("default-src 'self'");
|
|
407
|
+
await app.close();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("fichier statique servi, fallback SPA avec nonce par requête, /api inconnu jamais avalé par le fallback", async () => {
|
|
411
|
+
const app = await createManifestGateway(
|
|
412
|
+
buildTestManifest(),
|
|
413
|
+
gatewayOptions({ staticRoot: buildStaticRoot() }),
|
|
414
|
+
);
|
|
415
|
+
const asset = await app.inject({ method: "GET", url: "/assets/app.js" });
|
|
416
|
+
expect(asset.statusCode).toBe(200);
|
|
417
|
+
expect(asset.body).toContain("static asset");
|
|
418
|
+
|
|
419
|
+
const spa = await app.inject({ method: "GET", url: "/app/nested/route" });
|
|
420
|
+
expect(spa.statusCode).toBe(200);
|
|
421
|
+
expect(spa.headers["content-type"]).toContain("text/html");
|
|
422
|
+
const nonceMatch = /<script nonce="([^"]+)"/.exec(spa.body);
|
|
423
|
+
expect(nonceMatch).not.toBeNull();
|
|
424
|
+
expect(spa.headers["content-security-policy"]).toContain(`'nonce-${nonceMatch?.[1]}'`);
|
|
425
|
+
|
|
426
|
+
const unknownApi = await app.inject({ method: "GET", url: "/api/nowhere" });
|
|
427
|
+
expect(unknownApi.statusCode).toBe(404);
|
|
428
|
+
expect(unknownApi.json().code).toBe("GATEWAY_ROUTE_UNKNOWN");
|
|
429
|
+
|
|
430
|
+
const postOutsideApi = await app.inject({ method: "POST", url: "/app" });
|
|
431
|
+
expect(postOutsideApi.statusCode).toBe(404);
|
|
432
|
+
expect(postOutsideApi.json().code).toBe("GATEWAY_ROUTE_UNKNOWN");
|
|
433
|
+
await app.close();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("la RACINE sert le shell du SPA, avec son nonce — l'URL d'entrée du produit n'est pas un cas particulier", async () => {
|
|
437
|
+
const app = await createManifestGateway(
|
|
438
|
+
buildTestManifest(),
|
|
439
|
+
gatewayOptions({ staticRoot: buildStaticRoot() }),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
for (const method of ["GET", "HEAD"] as const) {
|
|
443
|
+
const response = await app.inject({ method, url: "/" });
|
|
444
|
+
expect(response.statusCode, `${method} /`).toBe(200);
|
|
445
|
+
expect(response.headers["content-type"]).toContain("text/html");
|
|
446
|
+
}
|
|
447
|
+
const root = await app.inject({ method: "GET", url: "/" });
|
|
448
|
+
const nonceMatch = /<script nonce="([^"]+)"/.exec(root.body);
|
|
449
|
+
expect(nonceMatch).not.toBeNull();
|
|
450
|
+
expect(root.headers["content-security-policy"]).toContain(`'nonce-${nonceMatch?.[1]}'`);
|
|
451
|
+
await app.close();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("MUTATION : sans front à servir, la racine reste un 404 structuré — jamais une page vide", async () => {
|
|
455
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
456
|
+
|
|
457
|
+
const response = await app.inject({ method: "GET", url: "/" });
|
|
458
|
+
|
|
459
|
+
expect(response.statusCode).toBe(404);
|
|
460
|
+
expect(response.json().code).toBe("GATEWAY_ROUTE_UNKNOWN");
|
|
461
|
+
await app.close();
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("manifest-gateway — refus de démarrer (GATEWAY_MANIFEST_INVALID)", () => {
|
|
466
|
+
async function expectBootRefusal(manifest: unknown, options: Partial<ManifestGatewayOptions>) {
|
|
467
|
+
try {
|
|
468
|
+
await createManifestGateway(manifest, gatewayOptions(options));
|
|
469
|
+
expect.unreachable("boot should have been refused");
|
|
470
|
+
} catch (error) {
|
|
471
|
+
expect(error).toBeInstanceOf(OgygieFailure);
|
|
472
|
+
if (error instanceof OgygieFailure) {
|
|
473
|
+
expect(error.error.code).toBe("GATEWAY_MANIFEST_INVALID");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
it("MUTATION : un manifeste qui ne parse pas est refusé", async () => {
|
|
479
|
+
await expectBootRefusal({ version: 1, features: "broken" }, {});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("MUTATION : une route dont le schéma d'entrée est introuvable au registre est refusée", async () => {
|
|
483
|
+
const incompleteRegistry: Record<string, z.ZodType> = { ...TEST_SCHEMAS };
|
|
484
|
+
delete incompleteRegistry["CreateWidgetInputSchema"];
|
|
485
|
+
await expectBootRefusal(buildTestManifest(), { schemas: incompleteRegistry });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("MUTATION : un chemin de route malformé est refusé", async () => {
|
|
489
|
+
const malformed = buildTestManifest([
|
|
490
|
+
{
|
|
491
|
+
method: "GET",
|
|
492
|
+
path: "/api/widg ets",
|
|
493
|
+
description: "chemin malformé pour le test de refus",
|
|
494
|
+
schemaInput: "ListWidgetsInputSchema",
|
|
495
|
+
schemaOutput: "WidgetOutputSchema",
|
|
496
|
+
},
|
|
497
|
+
]);
|
|
498
|
+
await expectBootRefusal(malformed, {});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("MUTATION : une route déclarée deux fois est refusée", async () => {
|
|
502
|
+
const listRoute = BASE_ROUTES[1];
|
|
503
|
+
if (listRoute === undefined) throw new Error("fixture manquante");
|
|
504
|
+
await expectBootRefusal(buildTestManifest([...BASE_ROUTES, { ...listRoute }]), {});
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("manifest-gateway — budget de latence", () => {
|
|
509
|
+
it("le surcoût du relais est < 5 ms au P95 (keep-alive local, mesuré)", async () => {
|
|
510
|
+
const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
|
|
511
|
+
await app.listen({ port: 0, host: "127.0.0.1" });
|
|
512
|
+
const address = app.server.address();
|
|
513
|
+
if (address === null || typeof address === "string") throw new Error("no gateway port");
|
|
514
|
+
const gatewayBase = `http://127.0.0.1:${address.port}`;
|
|
515
|
+
|
|
516
|
+
async function measure(base: string, samples: number): Promise<number[]> {
|
|
517
|
+
const durations: number[] = [];
|
|
518
|
+
for (let index = 0; index < samples; index += 1) {
|
|
519
|
+
const start = performance.now();
|
|
520
|
+
const response = await fetch(`${base}/api/widgets`);
|
|
521
|
+
await response.arrayBuffer();
|
|
522
|
+
durations.push(performance.now() - start);
|
|
523
|
+
}
|
|
524
|
+
return durations.sort((left, right) => left - right);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const WARMUP = 30;
|
|
528
|
+
const SAMPLES = 200;
|
|
529
|
+
await measure(upstream.baseUrl, WARMUP);
|
|
530
|
+
await measure(gatewayBase, WARMUP);
|
|
531
|
+
const direct = await measure(upstream.baseUrl, SAMPLES);
|
|
532
|
+
const relayed = await measure(gatewayBase, SAMPLES);
|
|
533
|
+
const p95 = (sorted: number[]) => sorted[Math.floor(sorted.length * 0.95)] ?? 0;
|
|
534
|
+
const overhead = p95(relayed) - p95(direct);
|
|
535
|
+
console.log(
|
|
536
|
+
`latence P95 — direct ${p95(direct).toFixed(2)} ms, via passerelle ${p95(relayed).toFixed(2)} ms, surcoût ${overhead.toFixed(2)} ms`,
|
|
537
|
+
);
|
|
538
|
+
expect(overhead).toBeLessThan(5);
|
|
539
|
+
await app.close();
|
|
540
|
+
});
|
|
541
|
+
});
|