@o3co/create-auth-policy-verifier 0.3.1 → 0.5.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/README.ja.md +27 -7
- package/README.md +27 -7
- package/dist/cli.mjs +0 -0
- package/dist/index.d.mts +34 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +110 -12
- package/package.json +40 -30
- package/templates/standalone/Dockerfile +75 -11
- package/templates/standalone/README.ja.md +245 -12
- package/templates/standalone/README.md +248 -12
- package/templates/standalone/config/application.conf +307 -4
- package/templates/standalone/config/development.conf +8 -0
- package/templates/standalone/config/production.conf +9 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/package.json +11 -9
- package/templates/standalone/src/__tests__/configPath.test.mts +37 -0
- package/templates/standalone/src/__tests__/loadConfig.test.mts +415 -0
- package/templates/standalone/src/__tests__/logger.test.mts +56 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +254 -48
- package/templates/standalone/src/configPath.ts +21 -0
- package/templates/standalone/src/loadConfig.ts +22 -0
- package/templates/standalone/src/logger.ts +33 -0
- package/templates/standalone/src/main.mts +11 -14
- package/templates/standalone/tsconfig.json +2 -1
- package/templates/standalone/tsconfig.typecheck.json +8 -0
- package/templates/versions.json +3 -3
- package/dist/__tests__/index.test.d.mts +0 -2
- package/dist/__tests__/index.test.d.mts.map +0 -1
- package/dist/__tests__/index.test.mjs +0 -213
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Tests for the config loading path that main.mts boots from.
|
|
6
|
+
*
|
|
7
|
+
* These read the real files under templates/standalone/config/, so they cover
|
|
8
|
+
* the shipped layering rather than a hand-built object: an env overlay that
|
|
9
|
+
* contains only comments is an empty HOCON document, and it must fall back to
|
|
10
|
+
* application.conf instead of failing to parse. Nothing else in the suite
|
|
11
|
+
* exercises parseFile, so a HOCON parser regression is otherwise invisible
|
|
12
|
+
* until the container fails to start.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
19
|
+
import { loadAppConfig } from "../loadConfig.js";
|
|
20
|
+
|
|
21
|
+
const configDirPath = fileURLToPath(new URL("../../config/", import.meta.url));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 64 hex characters — 32 decoded bytes, the entropy floor the schema enforces
|
|
25
|
+
* on every HS256 secret (#114). A short value now fails to load at all.
|
|
26
|
+
*/
|
|
27
|
+
const TEST_SECRET = "11".repeat(32);
|
|
28
|
+
|
|
29
|
+
const envKeys = [
|
|
30
|
+
"OAUTH_JWT_SECRET",
|
|
31
|
+
"OAUTH_JWT_ISSUER",
|
|
32
|
+
"OAUTH_JWT_AUDIENCE",
|
|
33
|
+
"OAUTH_JWT_MODE",
|
|
34
|
+
"HTTP_HOSTNAME",
|
|
35
|
+
"HTTP_PORT",
|
|
36
|
+
"HTTP_PATH_PREFIX",
|
|
37
|
+
"HTTP_CALLER_AUTH_TOKEN",
|
|
38
|
+
"HTTP_CALLER_AUTH_HEADER",
|
|
39
|
+
"OAUTH_JWT_JWKS_TIMEOUT_MS",
|
|
40
|
+
"OAUTH_JWT_JWKS_COOLDOWN_MS",
|
|
41
|
+
"OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS",
|
|
42
|
+
"OAUTH_JWT_MAX_TOKEN_AGE_SECONDS",
|
|
43
|
+
"OAUTH_JWT_CLOCK_TOLERANCE_SECONDS",
|
|
44
|
+
"VERIFY_MAX_BATCH_SIZE",
|
|
45
|
+
// Every VERIFY_* the cases below set has to be listed, or it leaks into the
|
|
46
|
+
// tests after it. Only `VERIFY_MAX_BATCH_SIZE` was here while the #118 cases
|
|
47
|
+
// set five more, and the leak was invisible for as long as no later case
|
|
48
|
+
// loaded the shipped config and asserted on `verify` — the first one that
|
|
49
|
+
// did got a boot failure from an env var a test three screens up had set.
|
|
50
|
+
"VERIFY_MAX_BODY_BYTES",
|
|
51
|
+
"VERIFY_MAX_RESOURCE_LENGTH",
|
|
52
|
+
"VERIFY_MAX_ACTION_LENGTH",
|
|
53
|
+
"VERIFY_MAX_CONTEXT_ENTRIES",
|
|
54
|
+
"VERIFY_MAX_CONTEXT_VALUE_LENGTH",
|
|
55
|
+
"VERIFY_COLLECTOR_TIMEOUT_MS",
|
|
56
|
+
"VERIFY_COLLECTOR_DEADLINE_MS",
|
|
57
|
+
"VERIFY_COLLECTOR_CONCURRENCY",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** application.conf leaves issuer/audience unset, so every load must supply them. */
|
|
61
|
+
function setRequiredEnv(): void {
|
|
62
|
+
process.env.OAUTH_JWT_SECRET = TEST_SECRET;
|
|
63
|
+
process.env.OAUTH_JWT_ISSUER = "https://issuer.test";
|
|
64
|
+
process.env.OAUTH_JWT_AUDIENCE = "https://api.test";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
for (const key of envKeys) {
|
|
69
|
+
delete process.env[key];
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("loadAppConfig", () => {
|
|
74
|
+
// Both shipped overlays are comment-only. They parse to an empty document,
|
|
75
|
+
// which withFallback must resolve to the application.conf values.
|
|
76
|
+
it.each(["development", "production"])(
|
|
77
|
+
"resolves the %s overlay against application.conf",
|
|
78
|
+
(env) => {
|
|
79
|
+
setRequiredEnv();
|
|
80
|
+
|
|
81
|
+
const config = loadAppConfig(configDirPath, env);
|
|
82
|
+
|
|
83
|
+
// Loopback by default (#108): the shipped config is a sidecar config,
|
|
84
|
+
// and reaching the port from another host is an explicit opt-in. The
|
|
85
|
+
// callerAuth block exists but carries no token, which means the gate is off.
|
|
86
|
+
expect(config.http).toEqual({
|
|
87
|
+
hostname: "127.0.0.1",
|
|
88
|
+
port: 3000,
|
|
89
|
+
pathPrefix: "",
|
|
90
|
+
callerAuth: { header: "x-caller-token" },
|
|
91
|
+
});
|
|
92
|
+
expect(config.oauth.jwt.algorithm).toBe("HS256");
|
|
93
|
+
expect(config.oauth.jwt.secret).toBe(TEST_SECRET);
|
|
94
|
+
expect(config.oauth.jwt.mode).toBe("verify");
|
|
95
|
+
expect(config.oauth.jwt.issuer).toBe("https://issuer.test");
|
|
96
|
+
expect(config.oauth.jwt.audience).toBe("https://api.test");
|
|
97
|
+
expect(config.oauth.jwt.tokenType).toBe("at+jwt");
|
|
98
|
+
expect(config.verify.maxBatchSize).toBe(50);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
it("registers the collectors declared in application.conf", () => {
|
|
103
|
+
setRequiredEnv();
|
|
104
|
+
|
|
105
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
106
|
+
|
|
107
|
+
expect(config.attribute.collectors.map((c) => c.collector)).toEqual([
|
|
108
|
+
"PayloadScopeCollector",
|
|
109
|
+
"PayloadSubjectIdCollector",
|
|
110
|
+
]);
|
|
111
|
+
// Scope-only, and the two lists are one decision (#113). Rule groups are
|
|
112
|
+
// ANDed, so a rule reading an attribute no collector above produces is a
|
|
113
|
+
// group nothing satisfies — which is a verifier that denies every request.
|
|
114
|
+
// A permission rule may only join this list together with its supplier.
|
|
115
|
+
expect(config.rule.collectors.map((c) => c.collector)).toEqual([
|
|
116
|
+
"ResourceActionScopeRuleCollector",
|
|
117
|
+
]);
|
|
118
|
+
expect(config.resource.parser).toBe("DotNotationResourceParser");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("applies the optional environment substitutions from application.conf", () => {
|
|
122
|
+
setRequiredEnv();
|
|
123
|
+
// 0.0.0.0 is the container opt-out of the loopback default — the value a
|
|
124
|
+
// containerised deployment (and the cross-repo E2E) has to set explicitly.
|
|
125
|
+
process.env.HTTP_HOSTNAME = "0.0.0.0";
|
|
126
|
+
process.env.HTTP_PORT = "8080";
|
|
127
|
+
process.env.HTTP_PATH_PREFIX = "/auth";
|
|
128
|
+
|
|
129
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
130
|
+
|
|
131
|
+
expect(config.http).toMatchObject({
|
|
132
|
+
hostname: "0.0.0.0",
|
|
133
|
+
port: 8080,
|
|
134
|
+
pathPrefix: "/auth",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("enables caller authentication from the environment (#108)", () => {
|
|
139
|
+
setRequiredEnv();
|
|
140
|
+
process.env.HTTP_CALLER_AUTH_TOKEN = "caller-secret";
|
|
141
|
+
|
|
142
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
143
|
+
|
|
144
|
+
expect(config.http.callerAuth).toEqual({ header: "x-caller-token", token: "caller-secret" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("lets the caller-credential header be renamed for existing gateway conventions", () => {
|
|
148
|
+
setRequiredEnv();
|
|
149
|
+
process.env.HTTP_CALLER_AUTH_TOKEN = "caller-secret";
|
|
150
|
+
process.env.HTTP_CALLER_AUTH_HEADER = "x-api-key";
|
|
151
|
+
|
|
152
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
153
|
+
|
|
154
|
+
expect(config.http.callerAuth).toEqual({ header: "x-api-key", token: "caller-secret" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("refuses to boot when the caller credential is exported empty", () => {
|
|
158
|
+
// The silent-failure case: `HTTP_CALLER_AUTH_TOKEN=` must not read as
|
|
159
|
+
// "caller auth is off".
|
|
160
|
+
setRequiredEnv();
|
|
161
|
+
process.env.HTTP_CALLER_AUTH_TOKEN = "";
|
|
162
|
+
|
|
163
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow(/callerAuth[.,\s\S]*token/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("selects insecure-decode mode via OAUTH_JWT_MODE, requiring no key material (#134)", () => {
|
|
167
|
+
// The value is the consent: only the literal string "insecure-decode"
|
|
168
|
+
// selects the decode-only path, so a stray boolean-ish env value cannot.
|
|
169
|
+
process.env.OAUTH_JWT_MODE = "insecure-decode";
|
|
170
|
+
|
|
171
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
172
|
+
|
|
173
|
+
expect(config.oauth.jwt.mode).toBe("insecure-decode");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects a boolean-ish OAUTH_JWT_MODE left over from the removed OAUTH_JWT_VALIDATE", () => {
|
|
177
|
+
setRequiredEnv();
|
|
178
|
+
process.env.OAUTH_JWT_MODE = "false";
|
|
179
|
+
|
|
180
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rejects an HS256 config with no secret in the environment", () => {
|
|
184
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow(
|
|
185
|
+
/secret is required for HS256/,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it.each([
|
|
190
|
+
["a one-character OAUTH_JWT_SECRET", "s"],
|
|
191
|
+
["the README's old example value", "your-secret"],
|
|
192
|
+
["32 hex characters — 16 decoded bytes", "ab".repeat(16)],
|
|
193
|
+
])("refuses to load %s (#114)", (_label, secret) => {
|
|
194
|
+
// The floor applies to the shipped config as loaded, not only to
|
|
195
|
+
// hand-built objects: `secret = ${?OAUTH_JWT_SECRET}` is where a weak
|
|
196
|
+
// value actually enters a deployment.
|
|
197
|
+
setRequiredEnv();
|
|
198
|
+
process.env.OAUTH_JWT_SECRET = secret;
|
|
199
|
+
|
|
200
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow(
|
|
201
|
+
/must carry at least 32 bytes/,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("rejects an env name that escapes the config directory", () => {
|
|
206
|
+
expect(() => loadAppConfig(configDirPath, "../secrets")).toThrow(/resolves outside/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("loadAppConfig — RFC 9068 requirements (#105)", () => {
|
|
211
|
+
it("fails to load when the issuer is not supplied", () => {
|
|
212
|
+
process.env.OAUTH_JWT_SECRET = TEST_SECRET;
|
|
213
|
+
process.env.OAUTH_JWT_AUDIENCE = "https://api.test";
|
|
214
|
+
|
|
215
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("fails to load when the audience is not supplied", () => {
|
|
219
|
+
process.env.OAUTH_JWT_SECRET = TEST_SECRET;
|
|
220
|
+
process.env.OAUTH_JWT_ISSUER = "https://issuer.test";
|
|
221
|
+
|
|
222
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#157)", () => {
|
|
227
|
+
// The knobs are read by `resolveBound` at both boundaries now, and this is the
|
|
228
|
+
// path that proves the schema half still works where it actually runs: HOCON
|
|
229
|
+
// substitutes `${?VAR}` as a STRING, and the ts.hocon zod adapter's coercion
|
|
230
|
+
// walk only rewrites fields it recognises as numbers. A knob it cannot see
|
|
231
|
+
// through must therefore coerce the string itself — which is exactly what
|
|
232
|
+
// `resolveBound` does, and what a hand-built config already relied on.
|
|
233
|
+
|
|
234
|
+
it("takes the numbers application.conf states when nothing overrides them", () => {
|
|
235
|
+
setRequiredEnv();
|
|
236
|
+
|
|
237
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
238
|
+
|
|
239
|
+
expect(config.http.port).toBe(3000);
|
|
240
|
+
expect(config.oauth.jwt.jwksTimeoutMs).toBe(5000);
|
|
241
|
+
expect(config.oauth.jwt.jwksCooldownMs).toBe(30_000);
|
|
242
|
+
expect(config.oauth.jwt.jwksCacheMaxAgeMs).toBe(600_000);
|
|
243
|
+
expect(config.oauth.jwt.maxTokenAgeSeconds).toBe(86_400);
|
|
244
|
+
expect(config.oauth.jwt.clockToleranceSeconds).toBe(0);
|
|
245
|
+
expect(config.verify.maxBatchSize).toBe(50);
|
|
246
|
+
// The request limits (#118) travel the same path.
|
|
247
|
+
expect(config.verify.maxBodyBytes).toBe(65_536);
|
|
248
|
+
expect(config.verify.maxResourceLength).toBe(512);
|
|
249
|
+
expect(config.verify.maxActionLength).toBe(64);
|
|
250
|
+
expect(config.verify.maxContextEntries).toBe(64);
|
|
251
|
+
expect(config.verify.maxContextValueLength).toBe(1024);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("reads every numeric knob from the environment, where each arrives as a string", () => {
|
|
255
|
+
setRequiredEnv();
|
|
256
|
+
process.env.HTTP_PORT = "8080";
|
|
257
|
+
process.env.OAUTH_JWT_JWKS_TIMEOUT_MS = "2500";
|
|
258
|
+
process.env.OAUTH_JWT_JWKS_COOLDOWN_MS = "0";
|
|
259
|
+
process.env.OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS = "120000";
|
|
260
|
+
process.env.OAUTH_JWT_MAX_TOKEN_AGE_SECONDS = "600";
|
|
261
|
+
process.env.OAUTH_JWT_CLOCK_TOLERANCE_SECONDS = "60";
|
|
262
|
+
process.env.VERIFY_MAX_BATCH_SIZE = "25";
|
|
263
|
+
process.env.VERIFY_MAX_BODY_BYTES = "16384";
|
|
264
|
+
process.env.VERIFY_MAX_RESOURCE_LENGTH = "128";
|
|
265
|
+
process.env.VERIFY_MAX_ACTION_LENGTH = "32";
|
|
266
|
+
process.env.VERIFY_MAX_CONTEXT_ENTRIES = "16";
|
|
267
|
+
process.env.VERIFY_MAX_CONTEXT_VALUE_LENGTH = "256";
|
|
268
|
+
process.env.VERIFY_COLLECTOR_TIMEOUT_MS = "750";
|
|
269
|
+
process.env.VERIFY_COLLECTOR_DEADLINE_MS = "1500";
|
|
270
|
+
process.env.VERIFY_COLLECTOR_CONCURRENCY = "4";
|
|
271
|
+
|
|
272
|
+
const config = loadAppConfig(configDirPath, "development");
|
|
273
|
+
|
|
274
|
+
expect(config.http.port).toBe(8080);
|
|
275
|
+
expect(config.oauth.jwt.jwksTimeoutMs).toBe(2500);
|
|
276
|
+
expect(config.oauth.jwt.jwksCooldownMs).toBe(0);
|
|
277
|
+
expect(config.oauth.jwt.jwksCacheMaxAgeMs).toBe(120_000);
|
|
278
|
+
expect(config.oauth.jwt.maxTokenAgeSeconds).toBe(600);
|
|
279
|
+
expect(config.oauth.jwt.clockToleranceSeconds).toBe(60);
|
|
280
|
+
expect(config.verify.maxBatchSize).toBe(25);
|
|
281
|
+
expect(config.verify.maxBodyBytes).toBe(16_384);
|
|
282
|
+
expect(config.verify.maxResourceLength).toBe(128);
|
|
283
|
+
expect(config.verify.maxActionLength).toBe(32);
|
|
284
|
+
expect(config.verify.maxContextEntries).toBe(16);
|
|
285
|
+
expect(config.verify.maxContextValueLength).toBe(256);
|
|
286
|
+
expect(config.verify.collectorTimeoutMs).toBe(750);
|
|
287
|
+
expect(config.verify.collectorDeadlineMs).toBe(1_500);
|
|
288
|
+
expect(config.verify.collectorConcurrency).toBe(4);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it.each([
|
|
292
|
+
["HTTP_PORT", "0"],
|
|
293
|
+
["HTTP_PORT", "70000"],
|
|
294
|
+
["OAUTH_JWT_JWKS_TIMEOUT_MS", "0"],
|
|
295
|
+
["OAUTH_JWT_JWKS_COOLDOWN_MS", "-1"],
|
|
296
|
+
["OAUTH_JWT_MAX_TOKEN_AGE_SECONDS", "1.5"],
|
|
297
|
+
["OAUTH_JWT_CLOCK_TOLERANCE_SECONDS", "301"],
|
|
298
|
+
["VERIFY_MAX_BATCH_SIZE", "0"],
|
|
299
|
+
["VERIFY_MAX_BODY_BYTES", "0"],
|
|
300
|
+
["VERIFY_MAX_RESOURCE_LENGTH", "-1"],
|
|
301
|
+
["VERIFY_MAX_ACTION_LENGTH", "1.5"],
|
|
302
|
+
["VERIFY_MAX_CONTEXT_ENTRIES", "abc"],
|
|
303
|
+
["VERIFY_MAX_CONTEXT_VALUE_LENGTH", "0"],
|
|
304
|
+
["VERIFY_COLLECTOR_TIMEOUT_MS", "0"],
|
|
305
|
+
["VERIFY_COLLECTOR_DEADLINE_MS", "-1"],
|
|
306
|
+
["VERIFY_COLLECTOR_CONCURRENCY", "0"],
|
|
307
|
+
])("refuses to boot on %s=%s", (key, value) => {
|
|
308
|
+
setRequiredEnv();
|
|
309
|
+
process.env[key] = value;
|
|
310
|
+
|
|
311
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it.each([
|
|
315
|
+
"HTTP_PORT",
|
|
316
|
+
"OAUTH_JWT_JWKS_COOLDOWN_MS",
|
|
317
|
+
"OAUTH_JWT_CLOCK_TOLERANCE_SECONDS",
|
|
318
|
+
"VERIFY_MAX_BATCH_SIZE",
|
|
319
|
+
"VERIFY_MAX_BODY_BYTES",
|
|
320
|
+
"VERIFY_MAX_CONTEXT_ENTRIES",
|
|
321
|
+
"VERIFY_COLLECTOR_TIMEOUT_MS",
|
|
322
|
+
"VERIFY_COLLECTOR_CONCURRENCY",
|
|
323
|
+
])("refuses %s exported empty rather than reading it as zero", (key) => {
|
|
324
|
+
// `VAR=` substitutes an empty string, and `Number("")` is 0 — which the
|
|
325
|
+
// knobs whose floor is 0 would otherwise accept as a deliberate setting.
|
|
326
|
+
// A zero cooldown is "refetch on every miss", the fetch storm the knob
|
|
327
|
+
// exists to prevent; the same silent failure `http.callerAuth.token`
|
|
328
|
+
// already refuses above.
|
|
329
|
+
setRequiredEnv();
|
|
330
|
+
process.env[key] = "";
|
|
331
|
+
|
|
332
|
+
expect(() => loadAppConfig(configDirPath, "development")).toThrow();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("loadAppConfig — a config with no verify block at all (#115, #118)", () => {
|
|
337
|
+
/*
|
|
338
|
+
* The shipped `application.conf` writes out every `verify` knob, so loading
|
|
339
|
+
* it exercises the schema's per-key defaults and never the block-level one.
|
|
340
|
+
* The block-level default is what serves the OTHER shape, and it is the
|
|
341
|
+
* common one: an overlay config repeats only the sections it changes, and a
|
|
342
|
+
* deployment that has no opinion about batch sizes or collector deadlines
|
|
343
|
+
* simply never writes a `verify` block.
|
|
344
|
+
*
|
|
345
|
+
* zod takes `.default(() => ({…}))` verbatim rather than parsing it back
|
|
346
|
+
* through the shape, so a knob missing from that literal is `undefined` for
|
|
347
|
+
* exactly this shape — silently, with nothing failing. That is why it is
|
|
348
|
+
* proved here through the real loader (`parseFile` → `withFallback` →
|
|
349
|
+
* `validate`) rather than inferred from the schema unit tests, which all
|
|
350
|
+
* write a `verify` block and would go on passing.
|
|
351
|
+
*/
|
|
352
|
+
const withoutVerify = (): string => {
|
|
353
|
+
const dir = mkdtempSync(path.join(tmpdir(), "policy-verifier-config-"));
|
|
354
|
+
// The minimum a deployment must state, and not one key more. No `verify`
|
|
355
|
+
// block, and an env overlay that declares nothing — the shipped
|
|
356
|
+
// development/production files are comments only, so this is their shape.
|
|
357
|
+
// The `${"$"}{?VAR}` spelling keeps the HOCON env substitutions literal
|
|
358
|
+
// `${?VAR}` text rather than JavaScript interpolation — for the parser
|
|
359
|
+
// under test and for anyone skimming the fixture.
|
|
360
|
+
writeFileSync(
|
|
361
|
+
path.join(dir, "application.conf"),
|
|
362
|
+
`
|
|
363
|
+
oauth {
|
|
364
|
+
jwt {
|
|
365
|
+
algorithm = HS256
|
|
366
|
+
secret = ${"$"}{?OAUTH_JWT_SECRET}
|
|
367
|
+
issuer = ${"$"}{?OAUTH_JWT_ISSUER}
|
|
368
|
+
audience = ${"$"}{?OAUTH_JWT_AUDIENCE}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
attribute { collectors = [ { collector = PayloadScopeCollector } ] }
|
|
372
|
+
rule { collectors = [ { collector = ResourceActionScopeRuleCollector } ] }
|
|
373
|
+
`,
|
|
374
|
+
);
|
|
375
|
+
writeFileSync(path.join(dir, "development.conf"), "# overlay declares nothing\n");
|
|
376
|
+
return dir;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
it("still applies every documented verify default", () => {
|
|
380
|
+
setRequiredEnv();
|
|
381
|
+
|
|
382
|
+
const config = loadAppConfig(withoutVerify(), "development");
|
|
383
|
+
|
|
384
|
+
// All eleven, spelled out. A knob that reached the shape but not the
|
|
385
|
+
// block's `.default()` literal is `undefined` here, and `toEqual` says
|
|
386
|
+
// which one.
|
|
387
|
+
expect(config.verify).toEqual({
|
|
388
|
+
maxBatchSize: 50,
|
|
389
|
+
maxBodyBytes: 65_536,
|
|
390
|
+
maxResourceLength: 512,
|
|
391
|
+
maxActionLength: 64,
|
|
392
|
+
maxContextEntries: 64,
|
|
393
|
+
maxContextValueLength: 1_024,
|
|
394
|
+
collectorTimeoutMs: 2_000,
|
|
395
|
+
collectorDeadlineMs: 5_000,
|
|
396
|
+
collectorConcurrency: 8,
|
|
397
|
+
batchConcurrency: 8,
|
|
398
|
+
credentialToCollectors: "never",
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("agrees with the shipped config, which writes the same values out", () => {
|
|
403
|
+
// The two ways a deployment can arrive at the defaults — stating them in
|
|
404
|
+
// `application.conf`, or omitting the block — must not disagree. The
|
|
405
|
+
// shipped file is the documentation operators copy from, so a drift
|
|
406
|
+
// between it and the schema's fallback is a drift between what the docs
|
|
407
|
+
// say and what an unconfigured deployment runs.
|
|
408
|
+
setRequiredEnv();
|
|
409
|
+
|
|
410
|
+
const shipped = loadAppConfig(configDirPath, "development");
|
|
411
|
+
const omitted = loadAppConfig(withoutVerify(), "development");
|
|
412
|
+
|
|
413
|
+
expect(omitted.verify).toEqual(shipped.verify);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The composition root's logger (#107).
|
|
6
|
+
*
|
|
7
|
+
* The template previously created its logger through `@o3co/auth.utils`, whose
|
|
8
|
+
* shape cannot satisfy the `Logger` port `createApp` accepts — so nothing the
|
|
9
|
+
* template configured ever reached the server's failure events. `createAppLogger`
|
|
10
|
+
* returns a pino instance (newline-delimited JSON on stdout, the shape every
|
|
11
|
+
* log aggregator ingests without a parser), which satisfies the port
|
|
12
|
+
* structurally and honours `logging.level` from the application config.
|
|
13
|
+
*/
|
|
14
|
+
import type { Logger } from "@o3co/auth.policy-verifier.core";
|
|
15
|
+
import { AppConfigSchema } from "@o3co/auth.policy-verifier.server";
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import { createAppLogger } from "../logger.js";
|
|
18
|
+
|
|
19
|
+
function configWithLevel(level?: string) {
|
|
20
|
+
return AppConfigSchema.parse({
|
|
21
|
+
oauth: { jwt: { mode: "insecure-decode" } },
|
|
22
|
+
attribute: { collectors: [] },
|
|
23
|
+
rule: { collectors: [{ collector: "ResourceActionScopeRuleCollector" }] },
|
|
24
|
+
...(level === undefined ? {} : { logging: { level } }),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("createAppLogger", () => {
|
|
29
|
+
it("honours logging.level from the application config", () => {
|
|
30
|
+
const logger = createAppLogger(configWithLevel("error"));
|
|
31
|
+
expect(logger.level).toBe("error");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("defaults to info when the config does not set a level", () => {
|
|
35
|
+
const logger = createAppLogger(configWithLevel());
|
|
36
|
+
expect(logger.level).toBe("info");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("identifies the emitting service in its bindings", () => {
|
|
40
|
+
const logger = createAppLogger(configWithLevel());
|
|
41
|
+
expect(logger.bindings().name).toBe("policy-verifier");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("satisfies the Logger port createApp accepts (compile-time + structural check)", () => {
|
|
45
|
+
// The assignment is the compile-time half: a pino instance must satisfy
|
|
46
|
+
// the port with no adapter.
|
|
47
|
+
const logger: Logger = createAppLogger(configWithLevel());
|
|
48
|
+
expect(typeof logger.trace).toBe("function");
|
|
49
|
+
expect(typeof logger.debug).toBe("function");
|
|
50
|
+
expect(typeof logger.info).toBe("function");
|
|
51
|
+
expect(typeof logger.warn).toBe("function");
|
|
52
|
+
expect(typeof logger.error).toBe("function");
|
|
53
|
+
expect(typeof logger.fatal).toBe("function");
|
|
54
|
+
expect(typeof logger.child).toBe("function");
|
|
55
|
+
});
|
|
56
|
+
});
|