@zereight/mcp-gitlab 2.1.4 → 2.1.6

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.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Unit tests for the stateless token codec.
3
+ *
4
+ * Covers:
5
+ * - secret loading from env (valid, missing, malformed, rotation)
6
+ * - HKDF subkey derivation (purpose isolation)
7
+ * - sign / verify roundtrip (happy path, tamper, TTL, purpose mismatch)
8
+ * - seal / open roundtrip (happy path, tamper, TTL, purpose mismatch)
9
+ * - rotation: values minted under previous secret still verify
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { randomBytes, createHmac } from "node:crypto";
13
+ import { describe, test } from "node:test";
14
+ import { open, seal, sign, verify, STATELESS_PURPOSES, StatelessCodecError, StatelessConfigError, decodeSecret, deriveSubkey, deriveSubkeys, loadKeyMaterialFromEnv, } from "../../stateless/index.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+ /** Generate a 32-byte secret string as an operator would. */
19
+ function genSecret() {
20
+ return randomBytes(32).toString("base64url");
21
+ }
22
+ function buildMaterial(current, previous) {
23
+ const env = {
24
+ OAUTH_STATELESS_SECRET: current ?? genSecret(),
25
+ };
26
+ if (previous)
27
+ env.OAUTH_STATELESS_SECRET_PREVIOUS = previous;
28
+ const material = loadKeyMaterialFromEnv(true, env);
29
+ assert.ok(material, "material should load");
30
+ return material;
31
+ }
32
+ function dummyPayload(now = Math.floor(Date.now() / 1000)) {
33
+ return { v: 1, iat: now, hello: "world" };
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // loadKeyMaterialFromEnv
37
+ // ---------------------------------------------------------------------------
38
+ describe("loadKeyMaterialFromEnv", () => {
39
+ test("returns null when mode disabled", () => {
40
+ // `enabled=false` short-circuits regardless of env contents.
41
+ assert.equal(loadKeyMaterialFromEnv(false, {}), null);
42
+ assert.equal(loadKeyMaterialFromEnv(false, { OAUTH_STATELESS_SECRET: genSecret() }), null);
43
+ });
44
+ test("ignores env.OAUTH_STATELESS_MODE — enablement is caller-resolved", () => {
45
+ // The loader must not re-parse the raw env var; the resolved config flag
46
+ // (which honors the CLI --oauth-stateless-mode) is the single source of
47
+ // truth. Regression guard for the silent fallback-to-per-pod bug when
48
+ // stateless mode was enabled only via CLI.
49
+ const secret = genSecret();
50
+ // enabled=true with no env.OAUTH_STATELESS_MODE set → still loads.
51
+ const m = loadKeyMaterialFromEnv(true, { OAUTH_STATELESS_SECRET: secret });
52
+ assert.ok(m, "loader must not require env.OAUTH_STATELESS_MODE");
53
+ // enabled=false with env.OAUTH_STATELESS_MODE=true → still null.
54
+ assert.equal(loadKeyMaterialFromEnv(false, {
55
+ OAUTH_STATELESS_MODE: "true",
56
+ OAUTH_STATELESS_SECRET: secret,
57
+ }), null);
58
+ });
59
+ test("throws when mode enabled but secret missing", () => {
60
+ assert.throws(() => loadKeyMaterialFromEnv(true, {}), StatelessConfigError);
61
+ });
62
+ test("throws on too-short secret", () => {
63
+ assert.throws(() => loadKeyMaterialFromEnv(true, {
64
+ OAUTH_STATELESS_SECRET: Buffer.from("short").toString("base64url"),
65
+ }), StatelessConfigError);
66
+ });
67
+ test("loads current only", () => {
68
+ const m = loadKeyMaterialFromEnv(true, {
69
+ OAUTH_STATELESS_SECRET: genSecret(),
70
+ });
71
+ assert.ok(m);
72
+ assert.equal(m.previous, undefined);
73
+ });
74
+ test("loads current + previous", () => {
75
+ const m = loadKeyMaterialFromEnv(true, {
76
+ OAUTH_STATELESS_SECRET: genSecret(),
77
+ OAUTH_STATELESS_SECRET_PREVIOUS: genSecret(),
78
+ });
79
+ assert.ok(m);
80
+ assert.equal(m.current.length, 32);
81
+ assert.equal(m.previous.length, 32);
82
+ });
83
+ test("rejects malformed base64url previous", () => {
84
+ assert.throws(() => loadKeyMaterialFromEnv(true, {
85
+ OAUTH_STATELESS_SECRET: genSecret(),
86
+ OAUTH_STATELESS_SECRET_PREVIOUS: "!!!not-base64!!!",
87
+ }), StatelessConfigError);
88
+ });
89
+ });
90
+ // ---------------------------------------------------------------------------
91
+ // decodeSecret direct
92
+ // ---------------------------------------------------------------------------
93
+ describe("decodeSecret", () => {
94
+ test("rejects empty", () => {
95
+ assert.throws(() => decodeSecret(" ", "X"), StatelessConfigError);
96
+ });
97
+ test("accepts exactly 32 bytes", () => {
98
+ const s = randomBytes(32).toString("base64url");
99
+ assert.equal(decodeSecret(s, "X").length, 32);
100
+ });
101
+ test("accepts more than 32 bytes", () => {
102
+ const s = randomBytes(64).toString("base64url");
103
+ assert.equal(decodeSecret(s, "X").length, 64);
104
+ });
105
+ });
106
+ // ---------------------------------------------------------------------------
107
+ // deriveSubkey / deriveSubkeys — purpose isolation
108
+ // ---------------------------------------------------------------------------
109
+ describe("HKDF subkey derivation", () => {
110
+ test("same purpose + same secret ⇒ same key", () => {
111
+ const secret = genSecret();
112
+ const m1 = buildMaterial(secret);
113
+ const m2 = buildMaterial(secret);
114
+ const k1 = deriveSubkey(m1, "current", STATELESS_PURPOSES.CLIENT_ID);
115
+ const k2 = deriveSubkey(m2, "current", STATELESS_PURPOSES.CLIENT_ID);
116
+ assert.ok(k1.equals(k2));
117
+ });
118
+ test("different purposes ⇒ different keys", () => {
119
+ const m = buildMaterial();
120
+ const kC = deriveSubkey(m, "current", STATELESS_PURPOSES.CLIENT_ID);
121
+ const kS = deriveSubkey(m, "current", STATELESS_PURPOSES.SESSION_ID);
122
+ const kP = deriveSubkey(m, "current", STATELESS_PURPOSES.PENDING_AUTH);
123
+ const kT = deriveSubkey(m, "current", STATELESS_PURPOSES.STORED_TOKENS);
124
+ const set = new Set([kC.toString("hex"), kS.toString("hex"), kP.toString("hex"), kT.toString("hex")]);
125
+ assert.equal(set.size, 4);
126
+ });
127
+ test("different master secrets ⇒ different keys", () => {
128
+ const m1 = buildMaterial();
129
+ const m2 = buildMaterial();
130
+ assert.notEqual(deriveSubkey(m1, "current", STATELESS_PURPOSES.CLIENT_ID).toString("hex"), deriveSubkey(m2, "current", STATELESS_PURPOSES.CLIENT_ID).toString("hex"));
131
+ });
132
+ test("deriveSubkeys returns [current] when no previous", () => {
133
+ const m = buildMaterial();
134
+ const ks = deriveSubkeys(m, STATELESS_PURPOSES.CLIENT_ID);
135
+ assert.equal(ks.length, 1);
136
+ assert.equal(ks[0].slot, "current");
137
+ });
138
+ test("deriveSubkeys returns [current, previous] when rotated", () => {
139
+ const m = buildMaterial(genSecret(), genSecret());
140
+ const ks = deriveSubkeys(m, STATELESS_PURPOSES.CLIENT_ID);
141
+ assert.equal(ks.length, 2);
142
+ assert.equal(ks[0].slot, "current");
143
+ assert.equal(ks[1].slot, "previous");
144
+ assert.notEqual(ks[0].key.toString("hex"), ks[1].key.toString("hex"));
145
+ });
146
+ });
147
+ // ---------------------------------------------------------------------------
148
+ // sign / verify (CLIENT_ID purpose)
149
+ // ---------------------------------------------------------------------------
150
+ describe("sign / verify", () => {
151
+ test("happy-path roundtrip", () => {
152
+ const m = buildMaterial();
153
+ const p = dummyPayload();
154
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, p);
155
+ const { payload, slot } = verify(m, STATELESS_PURPOSES.CLIENT_ID, token, {
156
+ ttlSeconds: 3600,
157
+ });
158
+ assert.deepEqual(payload, p);
159
+ assert.equal(slot, "current");
160
+ assert.match(token, /^v1\.cid\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
161
+ });
162
+ test("tampered payload fails verify", () => {
163
+ const m = buildMaterial();
164
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
165
+ const parts = token.split(".");
166
+ const tampered = Buffer.from('{"v":1,"iat":1,"hello":"EVIL"}').toString("base64url");
167
+ parts[2] = tampered;
168
+ const bad = parts.join(".");
169
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, bad, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_signature");
170
+ });
171
+ test("tampered signature fails verify", () => {
172
+ const m = buildMaterial();
173
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
174
+ const parts = token.split(".");
175
+ const evilMac = Buffer.alloc(32, 7).toString("base64url");
176
+ parts[3] = evilMac;
177
+ const bad = parts.join(".");
178
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, bad, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_signature");
179
+ });
180
+ test("wrong purpose on verify is rejected", () => {
181
+ const m = buildMaterial();
182
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
183
+ assert.throws(() => verify(m, STATELESS_PURPOSES.SESSION_ID, token, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "purpose_mismatch");
184
+ });
185
+ test("expired token is rejected", () => {
186
+ const m = buildMaterial();
187
+ const longAgo = Math.floor(Date.now() / 1000) - 10_000;
188
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload(longAgo));
189
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "expired");
190
+ });
191
+ test("future iat beyond skew rejected", () => {
192
+ const m = buildMaterial();
193
+ const future = Math.floor(Date.now() / 1000) + 3600;
194
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload(future));
195
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "future_iat");
196
+ });
197
+ test("malformed prefix rejected", () => {
198
+ const m = buildMaterial();
199
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, "not-a-token", { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "malformed");
200
+ });
201
+ test("wrong version rejected", () => {
202
+ const m = buildMaterial();
203
+ const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
204
+ const bad = token.replace(/^v1\./, "v9.");
205
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, bad, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "unknown_version");
206
+ });
207
+ test("rotation: verify with previous secret succeeds", () => {
208
+ const oldSecret = genSecret();
209
+ const newSecret = genSecret();
210
+ const minter = buildMaterial(oldSecret); // minted under old
211
+ const token = sign(minter, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
212
+ // Verifier has rotated: new is current, old is previous.
213
+ const verifier = buildMaterial(newSecret, oldSecret);
214
+ const { slot } = verify(verifier, STATELESS_PURPOSES.CLIENT_ID, token, {
215
+ ttlSeconds: 3600,
216
+ });
217
+ assert.equal(slot, "previous");
218
+ });
219
+ test("cross-pod: two independently-loaded materials with same secret agree", () => {
220
+ const secret = genSecret();
221
+ const podA = buildMaterial(secret);
222
+ const podB = buildMaterial(secret);
223
+ const token = sign(podA, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
224
+ const { payload } = verify(podB, STATELESS_PURPOSES.CLIENT_ID, token, {
225
+ ttlSeconds: 3600,
226
+ });
227
+ assert.equal(payload.hello, "world");
228
+ });
229
+ });
230
+ // ---------------------------------------------------------------------------
231
+ // seal / open (SESSION_ID purpose)
232
+ // ---------------------------------------------------------------------------
233
+ describe("seal / open", () => {
234
+ test("happy-path roundtrip", () => {
235
+ const m = buildMaterial();
236
+ const p = dummyPayload();
237
+ const token = seal(m, STATELESS_PURPOSES.SESSION_ID, p);
238
+ const { payload, slot } = open(m, STATELESS_PURPOSES.SESSION_ID, token, {
239
+ ttlSeconds: 3600,
240
+ });
241
+ assert.deepEqual(payload, p);
242
+ assert.equal(slot, "current");
243
+ assert.match(token, /^v1\.sid\.[A-Za-z0-9_-]+$/);
244
+ });
245
+ test("each seal uses a fresh nonce (different ciphertexts)", () => {
246
+ const m = buildMaterial();
247
+ const p = dummyPayload();
248
+ const t1 = seal(m, STATELESS_PURPOSES.SESSION_ID, p);
249
+ const t2 = seal(m, STATELESS_PURPOSES.SESSION_ID, p);
250
+ assert.notEqual(t1, t2);
251
+ });
252
+ test("tampered ciphertext fails open", () => {
253
+ const m = buildMaterial();
254
+ const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
255
+ // Flip a bit in the middle of the blob.
256
+ const parts = token.split(".");
257
+ const blob = Buffer.from(parts[2], "base64url");
258
+ const midIndex = Math.floor(blob.length / 2);
259
+ blob[midIndex] ^= 0xff;
260
+ parts[2] = blob.toString("base64url");
261
+ const bad = parts.join(".");
262
+ assert.throws(() => open(m, STATELESS_PURPOSES.SESSION_ID, bad, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_ciphertext");
263
+ });
264
+ test("wrong purpose rejected at parse", () => {
265
+ const m = buildMaterial();
266
+ const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
267
+ assert.throws(() => open(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "purpose_mismatch");
268
+ });
269
+ test("forging a sealed value for wrong purpose fails AAD check", () => {
270
+ // Build a token under SESSION_ID but then relabel as PENDING_AUTH.
271
+ const m = buildMaterial();
272
+ const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
273
+ const relabeled = token.replace(/^v1\.sid\./, "v1.ps.");
274
+ assert.throws(() => open(m, STATELESS_PURPOSES.PENDING_AUTH, relabeled, { ttlSeconds: 3600 }),
275
+ // AAD mismatch surfaces as bad_ciphertext (AEAD integrity failure).
276
+ (err) => err instanceof StatelessCodecError && err.reason === "bad_ciphertext");
277
+ });
278
+ test("expired sealed value rejected", () => {
279
+ const m = buildMaterial();
280
+ const longAgo = Math.floor(Date.now() / 1000) - 10_000;
281
+ const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload(longAgo));
282
+ assert.throws(() => open(m, STATELESS_PURPOSES.SESSION_ID, token, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "expired");
283
+ });
284
+ test("malformed blob (too short) rejected", () => {
285
+ const m = buildMaterial();
286
+ const tooShort = "v1.sid." + Buffer.alloc(4).toString("base64url");
287
+ assert.throws(() => open(m, STATELESS_PURPOSES.SESSION_ID, tooShort, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError &&
288
+ (err.reason === "malformed" || err.reason === "bad_ciphertext"));
289
+ });
290
+ test("rotation: sealed value minted under previous secret still opens", () => {
291
+ const oldSecret = genSecret();
292
+ const newSecret = genSecret();
293
+ const minter = buildMaterial(oldSecret);
294
+ const token = seal(minter, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
295
+ const verifier = buildMaterial(newSecret, oldSecret);
296
+ const { slot } = open(verifier, STATELESS_PURPOSES.SESSION_ID, token, {
297
+ ttlSeconds: 3600,
298
+ });
299
+ assert.equal(slot, "previous");
300
+ });
301
+ test("two pods sharing secret interop", () => {
302
+ const secret = genSecret();
303
+ const podA = buildMaterial(secret);
304
+ const podB = buildMaterial(secret);
305
+ const token = seal(podA, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
306
+ const { payload } = open(podB, STATELESS_PURPOSES.SESSION_ID, token, {
307
+ ttlSeconds: 3600,
308
+ });
309
+ assert.equal(payload.hello, "world");
310
+ });
311
+ });
312
+ // ---------------------------------------------------------------------------
313
+ // Mixed purpose / payload-shape validation
314
+ // ---------------------------------------------------------------------------
315
+ describe("payload schema validation", () => {
316
+ test("payload without v rejected", () => {
317
+ const m = buildMaterial();
318
+ // Hand-craft a signed token with bogus shape.
319
+ const badPayload = Buffer.from(JSON.stringify({ iat: Math.floor(Date.now() / 1000) }));
320
+ // Compute matching HMAC so we reach the shape check, not the sig check.
321
+ const [{ key }] = deriveSubkeys(m, STATELESS_PURPOSES.CLIENT_ID).filter((k) => k.slot === "current");
322
+ const mac = createHmac("sha256", key)
323
+ .update(Buffer.concat([Buffer.from("cid"), Buffer.from([0]), badPayload]))
324
+ .digest();
325
+ const token = `v1.cid.${badPayload.toString("base64url")}.${mac.toString("base64url")}`;
326
+ assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_schema");
327
+ });
328
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Regression tests for OAUTH_STATELESS_SESSION_TTL_SECONDS parsing.
3
+ *
4
+ * Maintainer feedback (zereight, PR #442):
5
+ * "This can still disable stateless session expiry when
6
+ * OAUTH_STATELESS_SESSION_TTL_SECONDS is unset and SESSION_TIMEOUT_SECONDS
7
+ * is invalid. In that state, this fallback becomes NaN; _intEnv() returns
8
+ * the fallback directly when the stateless-specific value is missing, so
9
+ * OAUTH_STATELESS_SESSION_TTL_SECONDS becomes NaN instead of falling back
10
+ * to a safe positive default."
11
+ *
12
+ * The fix validates both the direct env/CLI value and the fallback inside
13
+ * _intEnv, and also sanitizes SESSION_TIMEOUT_SECONDS itself through the
14
+ * same helper so its downstream consumers always see a finite positive
15
+ * integer.
16
+ *
17
+ * config.ts reads process.env / process.argv at module load, so these
18
+ * tests use a child process per scenario to control the environment.
19
+ */
20
+ import assert from "node:assert/strict";
21
+ import { execFileSync } from "node:child_process";
22
+ import * as path from "node:path";
23
+ import * as url from "node:url";
24
+ import { describe, test } from "node:test";
25
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
26
+ const CONFIG_PATH = path.resolve(__dirname, "../../config.ts");
27
+ /**
28
+ * Spawn a child Node process that imports config.ts with the given env
29
+ * overrides, and returns a structured snapshot of the parsed numeric
30
+ * config values. We pipe a tiny evaluator script through stdin (`--eval`
31
+ * with tsx via `--import`) to keep the test hermetic.
32
+ */
33
+ function loadConfig(env) {
34
+ const script = `
35
+ import(${JSON.stringify(url.pathToFileURL(CONFIG_PATH).href)}).then((m) => {
36
+ const out = {
37
+ SESSION_TIMEOUT_SECONDS: m.SESSION_TIMEOUT_SECONDS,
38
+ OAUTH_STATELESS_SESSION_TTL_SECONDS: m.OAUTH_STATELESS_SESSION_TTL_SECONDS,
39
+ OAUTH_STATELESS_CLIENT_TTL_SECONDS: m.OAUTH_STATELESS_CLIENT_TTL_SECONDS,
40
+ PORT: m.PORT,
41
+ };
42
+ process.stdout.write(JSON.stringify(out));
43
+ }).catch((err) => {
44
+ process.stderr.write(String(err && err.stack || err));
45
+ process.exit(2);
46
+ });
47
+ `;
48
+ // Strip undefined entries so they truly aren't set in the child's env.
49
+ const childEnv = {
50
+ // Keep PATH / basic vars so tsx can spawn.
51
+ ...process.env,
52
+ };
53
+ // Reset anything that could interfere with the test even if our caller
54
+ // didn't set it — make every variable start clean.
55
+ delete childEnv.SESSION_TIMEOUT_SECONDS;
56
+ delete childEnv.OAUTH_STATELESS_SESSION_TTL_SECONDS;
57
+ delete childEnv.OAUTH_STATELESS_CLIENT_TTL_SECONDS;
58
+ delete childEnv.PORT;
59
+ // Apply the per-test overrides last.
60
+ for (const [k, v] of Object.entries(env)) {
61
+ if (v === undefined) {
62
+ delete childEnv[k];
63
+ }
64
+ else {
65
+ childEnv[k] = v;
66
+ }
67
+ }
68
+ const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", "--input-type=module", "--eval", script], {
69
+ env: childEnv,
70
+ encoding: "utf8",
71
+ stdio: ["ignore", "pipe", "pipe"],
72
+ });
73
+ return JSON.parse(stdout);
74
+ }
75
+ describe("config.ts TTL parsing — finite-positive guards", () => {
76
+ test("happy path: both TTLs default to 3600 when unset", () => {
77
+ const cfg = loadConfig({});
78
+ assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600);
79
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600);
80
+ });
81
+ test("OAUTH_STATELESS_SESSION_TTL_SECONDS honors its own env value", () => {
82
+ const cfg = loadConfig({
83
+ OAUTH_STATELESS_SESSION_TTL_SECONDS: "7200",
84
+ });
85
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 7200);
86
+ });
87
+ test("OAUTH_STATELESS_SESSION_TTL_SECONDS inherits valid SESSION_TIMEOUT_SECONDS", () => {
88
+ const cfg = loadConfig({
89
+ SESSION_TIMEOUT_SECONDS: "1800",
90
+ });
91
+ assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 1800);
92
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 1800);
93
+ });
94
+ test("invalid SESSION_TIMEOUT_SECONDS must not poison OAUTH_STATELESS_SESSION_TTL_SECONDS (NaN regression)", () => {
95
+ // Regression for zereight's P-level PR review feedback. Before the
96
+ // fix, _intEnv returned NaN verbatim from the fallback when the
97
+ // stateless-specific var was unset, which silently disabled TTL
98
+ // checks in checkIat (`ttlSec > 0` is false for NaN).
99
+ const cfg = loadConfig({
100
+ SESSION_TIMEOUT_SECONDS: "not-a-number",
101
+ });
102
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600, "OAUTH_STATELESS_SESSION_TTL_SECONDS must fall back to the hardcoded 3600 default when SESSION_TIMEOUT_SECONDS is invalid");
103
+ assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600, "SESSION_TIMEOUT_SECONDS must sanitize its own input too");
104
+ assert.ok(Number.isFinite(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS), "OAUTH_STATELESS_SESSION_TTL_SECONDS must be a finite number");
105
+ assert.ok(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS > 0, "OAUTH_STATELESS_SESSION_TTL_SECONDS must be strictly positive");
106
+ });
107
+ test("zero and negative SESSION_TIMEOUT_SECONDS fall back to the safe default", () => {
108
+ for (const badValue of ["0", "-1", "-3600"]) {
109
+ const cfg = loadConfig({ SESSION_TIMEOUT_SECONDS: badValue });
110
+ assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600, `SESSION_TIMEOUT_SECONDS=${badValue} should sanitize to default`);
111
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600, `OAUTH_STATELESS_SESSION_TTL_SECONDS should inherit the sanitized default from SESSION_TIMEOUT_SECONDS=${badValue}`);
112
+ }
113
+ });
114
+ test("garbage OAUTH_STATELESS_SESSION_TTL_SECONDS falls back to SESSION_TIMEOUT_SECONDS (when valid) or safe default", () => {
115
+ const cfg = loadConfig({
116
+ OAUTH_STATELESS_SESSION_TTL_SECONDS: "garbage",
117
+ SESSION_TIMEOUT_SECONDS: "1800",
118
+ });
119
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 1800, "invalid direct value should defer to the valid fallback");
120
+ const cfg2 = loadConfig({
121
+ OAUTH_STATELESS_SESSION_TTL_SECONDS: "garbage",
122
+ SESSION_TIMEOUT_SECONDS: "also-garbage",
123
+ });
124
+ assert.equal(cfg2.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600, "both invalid should hit the hardcoded safe default");
125
+ });
126
+ test("other _intEnv consumers remain unaffected (client TTL default)", () => {
127
+ // Guards against an accidental over-broad change to _intEnv.
128
+ const cfg = loadConfig({});
129
+ assert.equal(cfg.OAUTH_STATELESS_CLIENT_TTL_SECONDS, 86_400);
130
+ });
131
+ test("PORT uses the hardened _intEnv and rejects invalid values", () => {
132
+ const cfg = loadConfig({ PORT: "not-a-port" });
133
+ assert.equal(cfg.PORT, 3002, "invalid PORT should fall back to the default rather than NaN");
134
+ });
135
+ test("empty-string SESSION_TIMEOUT_SECONDS falls back to the safe default", () => {
136
+ // getConfig returns the value only when truthy; empty string is falsy
137
+ // and picks the default branch. This guards against regressions in the
138
+ // "raw empty" → "use defaultValue" short-circuit at getConfig.
139
+ const cfg = loadConfig({ SESSION_TIMEOUT_SECONDS: "" });
140
+ assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600);
141
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600);
142
+ });
143
+ test("whitespace-only SESSION_TIMEOUT_SECONDS also falls back", () => {
144
+ // parseInt(" ", 10) is NaN, so the _intEnv guard must catch it.
145
+ const cfg = loadConfig({ SESSION_TIMEOUT_SECONDS: " " });
146
+ assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600);
147
+ assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600);
148
+ });
149
+ });