@zereight/mcp-gitlab 2.1.69 → 2.2.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.ko.md +10 -4
- package/README.md +238 -233
- package/README.pt-BR.md +4 -4
- package/README.zh-CN.md +6 -4
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +456 -237
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +226 -57
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/build/utils/url.js +15 -0
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -812
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -482
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -128
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- package/build/test-resolve-issue-note.js +0 -127
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for the stateless `client_id` DCR path.
|
|
3
|
-
*
|
|
4
|
-
* The key cross-pod claim is that two GitLabOAuthServerProvider instances,
|
|
5
|
-
* sharing only OAUTH_STATELESS_SECRET, can successfully issue a client_id on
|
|
6
|
-
* one and validate it on the other — matching the scenario where a Kubernetes
|
|
7
|
-
* load balancer routes POST /register to pod A and GET /authorize to pod B.
|
|
8
|
-
*/
|
|
9
|
-
import assert from "node:assert/strict";
|
|
10
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
-
import { describe, test } from "node:test";
|
|
12
|
-
import { createGitLabOAuthProvider } from "../../oauth-proxy.js";
|
|
13
|
-
import { mintClientId, openClientId, looksLikeStatelessClientId, } from "../../stateless/client-id.js";
|
|
14
|
-
import { loadKeyMaterialFromEnv } from "../../stateless/index.js";
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Helpers
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
function secret() {
|
|
19
|
-
return randomBytes(32).toString("base64url");
|
|
20
|
-
}
|
|
21
|
-
function loadMaterial(current, previous) {
|
|
22
|
-
const env = {
|
|
23
|
-
OAUTH_STATELESS_SECRET: current,
|
|
24
|
-
};
|
|
25
|
-
if (previous)
|
|
26
|
-
env.OAUTH_STATELESS_SECRET_PREVIOUS = previous;
|
|
27
|
-
const m = loadKeyMaterialFromEnv(true, env);
|
|
28
|
-
assert.ok(m, "expected material to load");
|
|
29
|
-
return m;
|
|
30
|
-
}
|
|
31
|
-
function makeProvider(material, { callbackProxy = false } = {}) {
|
|
32
|
-
return createGitLabOAuthProvider("https://gitlab.example.com", "real-gitlab-app-id", "GitLab MCP Server (test)", false, // readOnly
|
|
33
|
-
undefined, // customScopes
|
|
34
|
-
undefined, // allowedGroups
|
|
35
|
-
callbackProxy, callbackProxy ? "https://mcp.example.com/callback" : "", material
|
|
36
|
-
? {
|
|
37
|
-
material,
|
|
38
|
-
clientTtlSeconds: 86400,
|
|
39
|
-
pendingTtlSeconds: 600,
|
|
40
|
-
storedTtlSeconds: 600,
|
|
41
|
-
}
|
|
42
|
-
: null);
|
|
43
|
-
}
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// mint / open helpers (direct)
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
describe("mintClientId / openClientId", () => {
|
|
48
|
-
test("roundtrips redirect_uris", () => {
|
|
49
|
-
const m = loadMaterial(secret());
|
|
50
|
-
const cid = mintClientId(m, {
|
|
51
|
-
redirectUris: ["https://client.example.com/cb"],
|
|
52
|
-
grantTypes: ["authorization_code"],
|
|
53
|
-
clientName: "Test Client",
|
|
54
|
-
});
|
|
55
|
-
assert.ok(looksLikeStatelessClientId(cid));
|
|
56
|
-
const p = openClientId(m, cid, 86400);
|
|
57
|
-
assert.ok(p);
|
|
58
|
-
assert.deepEqual(p.ruris, ["https://client.example.com/cb"]);
|
|
59
|
-
assert.deepEqual(p.gt, ["authorization_code"]);
|
|
60
|
-
assert.equal(p.cn, "Test Client");
|
|
61
|
-
});
|
|
62
|
-
test("openClientId returns null on signature error", () => {
|
|
63
|
-
const m1 = loadMaterial(secret());
|
|
64
|
-
const m2 = loadMaterial(secret()); // different secret entirely
|
|
65
|
-
const cid = mintClientId(m1, {
|
|
66
|
-
redirectUris: ["https://client.example.com/cb"],
|
|
67
|
-
});
|
|
68
|
-
assert.equal(openClientId(m2, cid, 86400), null);
|
|
69
|
-
});
|
|
70
|
-
test("openClientId returns null when expired", () => {
|
|
71
|
-
const m = loadMaterial(secret());
|
|
72
|
-
// Mint at t-3600
|
|
73
|
-
const past = Math.floor(Date.now() / 1000) - 3600;
|
|
74
|
-
const cid = mintClientId(m, {
|
|
75
|
-
redirectUris: ["https://client.example.com/cb"],
|
|
76
|
-
now: () => past,
|
|
77
|
-
});
|
|
78
|
-
// TTL = 60s ⇒ expired
|
|
79
|
-
assert.equal(openClientId(m, cid, 60), null);
|
|
80
|
-
});
|
|
81
|
-
test("rotation: cid minted under previous secret still opens", () => {
|
|
82
|
-
const s1 = secret();
|
|
83
|
-
const s2 = secret();
|
|
84
|
-
const mOld = loadMaterial(s1);
|
|
85
|
-
const cid = mintClientId(mOld, {
|
|
86
|
-
redirectUris: ["https://client.example.com/cb"],
|
|
87
|
-
});
|
|
88
|
-
// Operator rotated: new is current, old is previous.
|
|
89
|
-
const mRotated = loadMaterial(s2, s1);
|
|
90
|
-
assert.ok(openClientId(mRotated, cid, 86400));
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// clientsStore end-to-end across two provider instances (cross-pod)
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
describe("clientsStore cross-pod (stateless)", () => {
|
|
97
|
-
test("register on pod A ⇒ getClient succeeds on pod B", async () => {
|
|
98
|
-
const sharedSecret = secret();
|
|
99
|
-
const podA = makeProvider(loadMaterial(sharedSecret));
|
|
100
|
-
const podB = makeProvider(loadMaterial(sharedSecret));
|
|
101
|
-
// Register on A
|
|
102
|
-
const registered = await podA.clientsStore.registerClient({
|
|
103
|
-
redirect_uris: ["https://client.example.com/cb"],
|
|
104
|
-
token_endpoint_auth_method: "none",
|
|
105
|
-
});
|
|
106
|
-
assert.ok(looksLikeStatelessClientId(registered.client_id));
|
|
107
|
-
assert.deepEqual(registered.redirect_uris, ["https://client.example.com/cb"]);
|
|
108
|
-
// Lookup on B — no shared memory, only shared secret
|
|
109
|
-
const looked = await podB.clientsStore.getClient(registered.client_id);
|
|
110
|
-
assert.ok(looked, "B should resolve the signed client_id");
|
|
111
|
-
assert.equal(looked.client_id, registered.client_id);
|
|
112
|
-
assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
|
|
113
|
-
// token_endpoint_auth_method defaults to "none" for DCR
|
|
114
|
-
assert.equal(looked.token_endpoint_auth_method, "none");
|
|
115
|
-
});
|
|
116
|
-
test("getClient on pod B with different secret rejects the client_id", async () => {
|
|
117
|
-
const podA = makeProvider(loadMaterial(secret()));
|
|
118
|
-
const podB = makeProvider(loadMaterial(secret())); // different secret
|
|
119
|
-
const registered = await podA.clientsStore.registerClient({
|
|
120
|
-
redirect_uris: ["https://client.example.com/cb"],
|
|
121
|
-
token_endpoint_auth_method: "none",
|
|
122
|
-
});
|
|
123
|
-
const looked = await podB.clientsStore.getClient(registered.client_id);
|
|
124
|
-
assert.equal(looked, undefined);
|
|
125
|
-
});
|
|
126
|
-
test("non-stateless provider falls back to legacy cache path", async () => {
|
|
127
|
-
// Simulating a pod with stateless mode OFF — it should keep working
|
|
128
|
-
// against its own in-memory cache.
|
|
129
|
-
const provider = makeProvider(null);
|
|
130
|
-
const registered = await provider.clientsStore.registerClient({
|
|
131
|
-
redirect_uris: ["https://client.example.com/cb"],
|
|
132
|
-
token_endpoint_auth_method: "none",
|
|
133
|
-
});
|
|
134
|
-
// Legacy mode uses UUID client_ids, not stateless v1.cid.*
|
|
135
|
-
assert.ok(!looksLikeStatelessClientId(registered.client_id));
|
|
136
|
-
const looked = await provider.clientsStore.getClient(registered.client_id);
|
|
137
|
-
assert.ok(looked);
|
|
138
|
-
assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
|
|
139
|
-
});
|
|
140
|
-
test("legacy client_id is unchanged in stateless mode (e.g. pre-existing GitLab app uid)", async () => {
|
|
141
|
-
// A caller that passes a non-stateless client_id should receive the
|
|
142
|
-
// legacy stub so unrelated flows (like token exchange with a pre-registered
|
|
143
|
-
// GitLab app) continue to work.
|
|
144
|
-
const provider = makeProvider(loadMaterial(secret()));
|
|
145
|
-
const looked = await provider.clientsStore.getClient("legacy-app-id");
|
|
146
|
-
assert.ok(looked);
|
|
147
|
-
assert.equal(looked.client_id, "legacy-app-id");
|
|
148
|
-
// Stub has empty redirect_uris — the caller's code path must tolerate this.
|
|
149
|
-
assert.deepEqual(looked.redirect_uris, []);
|
|
150
|
-
});
|
|
151
|
-
test("rotation: client_id minted on pod-old verifies on pod-rotated", async () => {
|
|
152
|
-
const s1 = secret();
|
|
153
|
-
const s2 = secret();
|
|
154
|
-
const podOld = makeProvider(loadMaterial(s1));
|
|
155
|
-
const podRotated = makeProvider(loadMaterial(s2, s1));
|
|
156
|
-
const registered = await podOld.clientsStore.registerClient({
|
|
157
|
-
redirect_uris: ["https://client.example.com/cb"],
|
|
158
|
-
token_endpoint_auth_method: "none",
|
|
159
|
-
});
|
|
160
|
-
const looked = await podRotated.clientsStore.getClient(registered.client_id);
|
|
161
|
-
assert.ok(looked);
|
|
162
|
-
assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
|
|
163
|
-
});
|
|
164
|
-
test("long redirect_uri list roundtrips", async () => {
|
|
165
|
-
const sharedSecret = secret();
|
|
166
|
-
const podA = makeProvider(loadMaterial(sharedSecret));
|
|
167
|
-
const podB = makeProvider(loadMaterial(sharedSecret));
|
|
168
|
-
const ruris = Array.from({ length: 5 }, (_, i) => `https://client.example.com/cb-${i}`);
|
|
169
|
-
const registered = await podA.clientsStore.registerClient({
|
|
170
|
-
redirect_uris: ruris,
|
|
171
|
-
token_endpoint_auth_method: "none",
|
|
172
|
-
});
|
|
173
|
-
const looked = await podB.clientsStore.getClient(registered.client_id);
|
|
174
|
-
assert.ok(looked);
|
|
175
|
-
assert.deepEqual(looked.redirect_uris, ruris);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,328 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,149 +0,0 @@
|
|
|
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
|
-
});
|