@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1
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 +139 -48
- package/README.md +138 -47
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The credential auth.provider presents to the Store, through the shipped
|
|
19
|
+
* composition: `REPOSITORIES_USER_HTTP_BEARER_TOKEN` in the environment → the HOCON
|
|
20
|
+
* layers → the production `repositories` module's section → the
|
|
21
|
+
* `"http"` user adapter → the `Authorization` header a real `node:http` Store
|
|
22
|
+
* receives. Unset, no header is sent; exported but empty, the user repository
|
|
23
|
+
* is refused. From outside the booted app, a Store's `401` with a `Bearer`
|
|
24
|
+
* challenge turns `POST /session/login` into a `503` whose log line names the
|
|
25
|
+
* refused credential and never the token; a `401` without the challenge is
|
|
26
|
+
* still a wrong password.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { createServer, type OutgoingHttpHeaders, type Server } from "node:http";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { inspect } from "node:util";
|
|
35
|
+
import {
|
|
36
|
+
AppConfigSchema,
|
|
37
|
+
createApp,
|
|
38
|
+
createKeyStoreFactory,
|
|
39
|
+
defineModule,
|
|
40
|
+
type Logger,
|
|
41
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
42
|
+
registerBuiltinKeyStores,
|
|
43
|
+
type UserRepository,
|
|
44
|
+
} from "@o3co/auth-provider-core";
|
|
45
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
46
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
47
|
+
import express from "express";
|
|
48
|
+
import request from "supertest";
|
|
49
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
50
|
+
import { buildModules } from "../buildModules.mjs";
|
|
51
|
+
import { resolveConfigPaths, type Switches } from "../configPath.mjs";
|
|
52
|
+
import { repositoriesModuleFor, templateReference } from "../modules.mjs";
|
|
53
|
+
import { repositoriesSectionSchema } from "../sections.mjs";
|
|
54
|
+
import {
|
|
55
|
+
capturedRenames,
|
|
56
|
+
libraryLayers,
|
|
57
|
+
rootSectionsOf,
|
|
58
|
+
sectionsCoreDoesNotDeclare,
|
|
59
|
+
} from "./library-references.fixture.mjs";
|
|
60
|
+
|
|
61
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
62
|
+
|
|
63
|
+
/** 32 bytes of key material — what `openssl rand -hex 32` prints. */
|
|
64
|
+
const TOKEN = "7d1f0c3b9a5e2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f";
|
|
65
|
+
|
|
66
|
+
let servers: Server[] = [];
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
await Promise.all(
|
|
69
|
+
servers.map((server) => {
|
|
70
|
+
server.closeAllConnections();
|
|
71
|
+
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
servers = [];
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** How the Store answers every request. */
|
|
78
|
+
interface StoreAnswer {
|
|
79
|
+
readonly status: number;
|
|
80
|
+
readonly headers?: OutgoingHttpHeaders;
|
|
81
|
+
readonly body: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const USER: StoreAnswer = { status: 200, body: { id: "user-1", username: "alice" } };
|
|
85
|
+
|
|
86
|
+
/** A Store on its own loopback port that records each request's Authorization header. */
|
|
87
|
+
const recordingStore = async (
|
|
88
|
+
answer: StoreAnswer = USER,
|
|
89
|
+
): Promise<{ origin: string; heard: (string | undefined)[] }> => {
|
|
90
|
+
const heard: (string | undefined)[] = [];
|
|
91
|
+
const server = createServer((req, res) => {
|
|
92
|
+
req.resume();
|
|
93
|
+
req.on("end", () => {
|
|
94
|
+
heard.push(req.headers.authorization);
|
|
95
|
+
res.writeHead(answer.status, { "Content-Type": "application/json", ...answer.headers });
|
|
96
|
+
res.end(JSON.stringify(answer.body));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
servers.push(server);
|
|
100
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
101
|
+
const { port } = server.address() as { port: number };
|
|
102
|
+
return { origin: `http://127.0.0.1:${port}`, heard };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The shipped config for the production overlay, resolved against `env`. */
|
|
106
|
+
const resolve = (env: Record<string, string>): Switches => {
|
|
107
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
108
|
+
const layers = parseFile(envConfPath, { env })
|
|
109
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
110
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
111
|
+
.withFallback(libraryLayers(env));
|
|
112
|
+
return {
|
|
113
|
+
...sectionsCoreDoesNotDeclare(layers),
|
|
114
|
+
...rootSectionsOf(layers, env),
|
|
115
|
+
...validate(layers, AppConfigSchema),
|
|
116
|
+
// What the resolution captured of core's renamed variables, which the
|
|
117
|
+
// schema's parse drops.
|
|
118
|
+
"renamed-variables": capturedRenames(env),
|
|
119
|
+
} as Switches;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The user repository the production `repositories` module builds from `config`'s section. */
|
|
123
|
+
const userRepositoryFrom = (config: Switches): Promise<UserRepository> =>
|
|
124
|
+
Promise.resolve(
|
|
125
|
+
repositoriesModuleFor({
|
|
126
|
+
client: config.adapters.clientRepository,
|
|
127
|
+
user: config.adapters.userRepository,
|
|
128
|
+
}).provides?.userRepository?.({
|
|
129
|
+
section: repositoriesSectionSchema.parse(
|
|
130
|
+
(config as unknown as Record<string, unknown>).repositories,
|
|
131
|
+
),
|
|
132
|
+
} as never) as UserRepository | Promise<UserRepository>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const envFor = (origin: string): Record<string, string> => ({
|
|
136
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
137
|
+
KEY_STORE_LOCAL_SECRET: "store-credential-composition.at-least-32-bytes.ok",
|
|
138
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
139
|
+
SESSION_STORE_SECRET: "store-credential-composition-session.at-least-32-bytes.ok",
|
|
140
|
+
ADAPTERS_USER_REPOSITORY: "http",
|
|
141
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: `${origin}/authenticate`,
|
|
142
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: `${origin}/authenticate-by-token`,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("REPOSITORIES_USER_HTTP_BEARER_TOKEN reaches the Store through the shipped composition", () => {
|
|
146
|
+
it("sends Authorization: Bearer <token> on every Store call when the variable is set", async () => {
|
|
147
|
+
const { origin, heard } = await recordingStore();
|
|
148
|
+
const repo = await userRepositoryFrom(
|
|
149
|
+
resolve({ ...envFor(origin), REPOSITORIES_USER_HTTP_BEARER_TOKEN: TOKEN }),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
await repo.authenticate("alice", "pass");
|
|
153
|
+
await repo.authenticateByToken("apple:a1");
|
|
154
|
+
|
|
155
|
+
expect(heard).toEqual([`Bearer ${TOKEN}`, `Bearer ${TOKEN}`]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("sends no Authorization header when the variable is unset", async () => {
|
|
159
|
+
const { origin, heard } = await recordingStore();
|
|
160
|
+
const repo = await userRepositoryFrom(resolve(envFor(origin)));
|
|
161
|
+
|
|
162
|
+
await repo.authenticate("alice", "pass");
|
|
163
|
+
|
|
164
|
+
expect(heard).toEqual([undefined]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("refuses the user repository when the variable is exported but empty", async () => {
|
|
168
|
+
const { origin, heard } = await recordingStore();
|
|
169
|
+
const config = resolve({ ...envFor(origin), REPOSITORIES_USER_HTTP_BEARER_TOKEN: "" });
|
|
170
|
+
|
|
171
|
+
await expect(userRepositoryFrom(config)).rejects.toThrow(/"bearerToken" must not be empty/);
|
|
172
|
+
expect(heard).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("a token the Store refuses, seen from outside the booted app", () => {
|
|
177
|
+
/** Every store on memory, so the boot opens no socket but the Store's; MFA off. */
|
|
178
|
+
const MEMORY_ENV: Readonly<Record<string, string>> = {
|
|
179
|
+
MFA_MODE: "off",
|
|
180
|
+
SESSION_STORE_SECURE: "false",
|
|
181
|
+
SESSION_STORE_NAME: "auth.session",
|
|
182
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
183
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
184
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
185
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
186
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
187
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
188
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
189
|
+
ADAPTERS_CONSENT_STORE: "none",
|
|
190
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
191
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const testKeyStoreModule = defineModule({
|
|
195
|
+
name: "test:key-store",
|
|
196
|
+
requires: ["config"] as const,
|
|
197
|
+
provides: {
|
|
198
|
+
keyStore: async ({ config: c }) => {
|
|
199
|
+
const factory = createKeyStoreFactory();
|
|
200
|
+
registerBuiltinKeyStores(factory);
|
|
201
|
+
return factory.create({
|
|
202
|
+
type: "local",
|
|
203
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** Every log call, as the arguments it was made with. */
|
|
210
|
+
const capturingLogger = (): { logger: Logger; lines: { level: string; args: unknown[] }[] } => {
|
|
211
|
+
const lines: { level: string; args: unknown[] }[] = [];
|
|
212
|
+
const at =
|
|
213
|
+
(level: string) =>
|
|
214
|
+
(...args: unknown[]): void => {
|
|
215
|
+
lines.push({ level, args });
|
|
216
|
+
};
|
|
217
|
+
const logger = {
|
|
218
|
+
trace: at("trace"),
|
|
219
|
+
debug: at("debug"),
|
|
220
|
+
info: at("info"),
|
|
221
|
+
warn: at("warn"),
|
|
222
|
+
error: at("error"),
|
|
223
|
+
fatal: at("fatal"),
|
|
224
|
+
child: () => logger,
|
|
225
|
+
} as Logger;
|
|
226
|
+
return { logger, lines };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
230
|
+
afterEach(async () => {
|
|
231
|
+
await handleRef?.dispose();
|
|
232
|
+
handleRef = undefined;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The client registry the boot reads, which this test brings itself.
|
|
237
|
+
* `REPOSITORIES_CLIENT_YAML_PATH`'s default, `./config/clients.yaml`, is per-deployment: the
|
|
238
|
+
* scaffold's `.gitignore` keeps it out of the project's repository, so a
|
|
239
|
+
* fresh clone — a CI runner, the `test` image — has none. The login below
|
|
240
|
+
* names no client, so an empty registry is all it needs.
|
|
241
|
+
*/
|
|
242
|
+
let registryDir: string;
|
|
243
|
+
let clientPath: string;
|
|
244
|
+
beforeAll(() => {
|
|
245
|
+
registryDir = mkdtempSync(join(tmpdir(), "store-credential-"));
|
|
246
|
+
clientPath = join(registryDir, "clients.yaml");
|
|
247
|
+
writeFileSync(clientPath, "# No clients: the login under test names none.\n");
|
|
248
|
+
});
|
|
249
|
+
afterAll(() => {
|
|
250
|
+
rmSync(registryDir, { recursive: true, force: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The shipped config against a Store answering `answer`, booted with the
|
|
255
|
+
* production repositories module; then a real browser login — a CSRF pair,
|
|
256
|
+
* then the form post.
|
|
257
|
+
*/
|
|
258
|
+
const logInAgainst = async (answer: StoreAnswer) => {
|
|
259
|
+
const { origin, heard } = await recordingStore(answer);
|
|
260
|
+
const config = resolve({
|
|
261
|
+
...envFor(origin),
|
|
262
|
+
...MEMORY_ENV,
|
|
263
|
+
REPOSITORIES_CLIENT_YAML_PATH: clientPath,
|
|
264
|
+
REPOSITORIES_USER_HTTP_BEARER_TOKEN: TOKEN,
|
|
265
|
+
});
|
|
266
|
+
const { logger, lines } = capturingLogger();
|
|
267
|
+
handleRef = await createApp({
|
|
268
|
+
modules: buildModules(config, {
|
|
269
|
+
keyStoreModule: testKeyStoreModule,
|
|
270
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
271
|
+
}),
|
|
272
|
+
bootstrapComponents: { config, pathResolver: (s: string) => s, logger },
|
|
273
|
+
});
|
|
274
|
+
const app = express().use(handleRef.router);
|
|
275
|
+
|
|
276
|
+
const csrf = await request(app).get("/session/csrf");
|
|
277
|
+
expect(csrf.status).toBe(200);
|
|
278
|
+
const login = await request(app)
|
|
279
|
+
.post("/session/login")
|
|
280
|
+
.set("Cookie", csrf.headers["set-cookie"] as unknown as string[])
|
|
281
|
+
.set(csrf.body.header_name as string, csrf.body.csrf_token as string)
|
|
282
|
+
.type("form")
|
|
283
|
+
.send({ username: "alice", password: "correct-horse-battery-staple" });
|
|
284
|
+
const logged = lines.map((line) =>
|
|
285
|
+
inspect(line, { depth: Number.POSITIVE_INFINITY, showHidden: true }),
|
|
286
|
+
);
|
|
287
|
+
return { login, heard, logged };
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
it("answers the login 503 and logs the refused credential by name, never the token", async () => {
|
|
291
|
+
const { login, heard, logged } = await logInAgainst({
|
|
292
|
+
status: 401,
|
|
293
|
+
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
|
|
294
|
+
body: { error: "invalid_token" },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(heard).toEqual([`Bearer ${TOKEN}`]);
|
|
298
|
+
expect(login.status).toBe(503);
|
|
299
|
+
expect(login.body.error).toBe("temporarily_unavailable");
|
|
300
|
+
expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toHaveLength(
|
|
301
|
+
1,
|
|
302
|
+
);
|
|
303
|
+
expect(logged.filter((line) => line.includes(TOKEN))).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("still answers a 401 without the challenge as a wrong password", async () => {
|
|
307
|
+
const { login, heard, logged } = await logInAgainst({
|
|
308
|
+
status: 401,
|
|
309
|
+
body: { error: "invalid_credentials" },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(heard).toEqual([`Bearer ${TOKEN}`]);
|
|
313
|
+
expect(login.status).toBe(401);
|
|
314
|
+
expect(login.body.error).toBe("invalid_credentials");
|
|
315
|
+
expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -15,16 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `127.0.0.1` instead. These tests fail if that wiring is lost.
|
|
18
|
+
* The server `request(app)` starts listens on the address supertest dials,
|
|
19
|
+
* in this project's own test run. Unpatched, supertest starts its server with
|
|
20
|
+
* `app.listen(0)` — the dual-stack wildcard `[::]:P` — and sends the request
|
|
21
|
+
* to `127.0.0.1:P`. On macOS the kernel can hand out a `P` another process
|
|
22
|
+
* already holds as `127.0.0.1:P`; the request then reaches that process, and
|
|
23
|
+
* if it never answers the test hangs until its timeout.
|
|
24
|
+
* `vitest.supertest-loopback.mts` (a `setupFiles` entry in
|
|
25
|
+
* `vitest.config.mts`) binds `127.0.0.1` instead; these tests fail if that
|
|
26
|
+
* wiring is lost.
|
|
28
27
|
*/
|
|
29
28
|
|
|
30
29
|
import http from "node:http";
|
|
@@ -48,7 +47,62 @@ function boundAddress(server: net.Server): Promise<AddressInfo> {
|
|
|
48
47
|
});
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* An app whose `GET /hold/:name` waits in its handler until the test releases
|
|
52
|
+
* that name, so a test decides the order in which requests finish.
|
|
53
|
+
* `inFlight` holds the names whose handlers have not answered yet.
|
|
54
|
+
*/
|
|
55
|
+
function holdingApp() {
|
|
56
|
+
const gates = new Map<
|
|
57
|
+
string,
|
|
58
|
+
{ arrived: PromiseWithResolvers<void>; released: PromiseWithResolvers<void> }
|
|
59
|
+
>();
|
|
60
|
+
const gate = (name: string) => {
|
|
61
|
+
let found = gates.get(name);
|
|
62
|
+
if (!found) {
|
|
63
|
+
found = { arrived: Promise.withResolvers(), released: Promise.withResolvers() };
|
|
64
|
+
gates.set(name, found);
|
|
65
|
+
}
|
|
66
|
+
return found;
|
|
67
|
+
};
|
|
68
|
+
const inFlight = new Set<string>();
|
|
69
|
+
const app = express();
|
|
70
|
+
app.get("/hold/:name", async (req, res) => {
|
|
71
|
+
const { name } = req.params;
|
|
72
|
+
inFlight.add(name);
|
|
73
|
+
gate(name).arrived.resolve();
|
|
74
|
+
await gate(name).released.promise;
|
|
75
|
+
inFlight.delete(name);
|
|
76
|
+
res.status(200).send(name);
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
app,
|
|
80
|
+
inFlight,
|
|
81
|
+
arrived: (name: string) => gate(name).arrived.promise,
|
|
82
|
+
release: (name: string) => gate(name).released.resolve(),
|
|
83
|
+
releaseAll: () => {
|
|
84
|
+
for (const { released } of gates.values()) released.resolve();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Records, for every `close` of `server`, which held requests it was still
|
|
91
|
+
* serving. A close that comes too early releases them all, so the test
|
|
92
|
+
* finishes and reports it instead of waiting on a close that waits on them.
|
|
93
|
+
*/
|
|
94
|
+
function recordCloses(server: http.Server, held: ReturnType<typeof holdingApp>): string[][] {
|
|
95
|
+
const closes: string[][] = [];
|
|
96
|
+
const close = server.close.bind(server);
|
|
97
|
+
server.close = ((callback?: (err?: Error) => void) => {
|
|
98
|
+
closes.push([...held.inFlight]);
|
|
99
|
+
held.releaseAll();
|
|
100
|
+
return close(callback);
|
|
101
|
+
}) as typeof server.close;
|
|
102
|
+
return closes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("supertest's own server listens on the loopback address it dials", () => {
|
|
52
106
|
it("binds 127.0.0.1, not the dual-stack wildcard", async () => {
|
|
53
107
|
const server = http.createServer(helloApp());
|
|
54
108
|
const bound = boundAddress(server);
|
|
@@ -97,6 +151,123 @@ describe("#556 — supertest's own server listens on the loopback address it dia
|
|
|
97
151
|
expect(responses.map((res) => res.status)).toEqual([200, 200]);
|
|
98
152
|
});
|
|
99
153
|
|
|
154
|
+
it("closes an agent's one server after the last request sent over it, not the first", async () => {
|
|
155
|
+
// The request built first is the one whose call bound the server. Closing
|
|
156
|
+
// the server when that one finishes, as supertest 7.2 does, resets the
|
|
157
|
+
// connection of a request still on its way: ECONNRESET on Node 26.
|
|
158
|
+
const held = holdingApp();
|
|
159
|
+
const server = http.createServer(held.app);
|
|
160
|
+
const closes = recordCloses(server, held);
|
|
161
|
+
const agent = request.agent(server);
|
|
162
|
+
|
|
163
|
+
const first = agent.get("/hold/first");
|
|
164
|
+
const second = agent.get("/hold/second");
|
|
165
|
+
const both = Promise.all([first, second]);
|
|
166
|
+
await Promise.all([held.arrived("first"), held.arrived("second")]);
|
|
167
|
+
held.release("first");
|
|
168
|
+
await first;
|
|
169
|
+
held.release("second");
|
|
170
|
+
|
|
171
|
+
expect((await both).map((res) => res.text)).toEqual(["first", "second"]);
|
|
172
|
+
expect(closes).toEqual([[]]);
|
|
173
|
+
expect(server.listening).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("keeps it open for a request sent while the server is already serving another", async () => {
|
|
177
|
+
const held = holdingApp();
|
|
178
|
+
const server = http.createServer(held.app);
|
|
179
|
+
const closes = recordCloses(server, held);
|
|
180
|
+
const agent = request.agent(server);
|
|
181
|
+
|
|
182
|
+
// A Test is sent when it is first awaited or `then`ed; the identity
|
|
183
|
+
// `then` sends each one here, before the test waits for its arrival.
|
|
184
|
+
const first = agent.get("/hold/first").then((res) => res);
|
|
185
|
+
await held.arrived("first");
|
|
186
|
+
const late = agent.get("/hold/late").then((res) => res);
|
|
187
|
+
await held.arrived("late");
|
|
188
|
+
held.release("first");
|
|
189
|
+
await first;
|
|
190
|
+
held.release("late");
|
|
191
|
+
|
|
192
|
+
expect((await late).text).toBe("late");
|
|
193
|
+
expect(closes).toEqual([[]]);
|
|
194
|
+
expect(server.listening).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("binds again for a request built before the server was closed, and sent after", async () => {
|
|
198
|
+
const agent = request.agent(helloApp());
|
|
199
|
+
|
|
200
|
+
const first = agent.get("/hello");
|
|
201
|
+
const second = agent.get("/hello");
|
|
202
|
+
|
|
203
|
+
expect((await first).status).toBe(200);
|
|
204
|
+
expect((await second).status).toBe(200);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("joins the bind another request made since, for a request built before the server was closed", async () => {
|
|
208
|
+
// Built on the first bind, sent while a later request is being served
|
|
209
|
+
// on the second: it joins that bind — one server, two requests in
|
|
210
|
+
// flight — and the server closes once, after the last of them.
|
|
211
|
+
const held = holdingApp();
|
|
212
|
+
const server = http.createServer(held.app);
|
|
213
|
+
const closes = recordCloses(server, held);
|
|
214
|
+
const agent = request.agent(server);
|
|
215
|
+
|
|
216
|
+
const early = agent.get("/hold/early");
|
|
217
|
+
const first = agent.get("/hold/first").then((res) => res);
|
|
218
|
+
await held.arrived("first");
|
|
219
|
+
held.release("first");
|
|
220
|
+
expect((await first).text).toBe("first");
|
|
221
|
+
expect(server.listening).toBe(false);
|
|
222
|
+
|
|
223
|
+
const second = agent.get("/hold/second").then((res) => res);
|
|
224
|
+
await held.arrived("second");
|
|
225
|
+
const earlySent = early.then((res) => res);
|
|
226
|
+
await held.arrived("early");
|
|
227
|
+
held.release("second");
|
|
228
|
+
expect((await second).text).toBe("second");
|
|
229
|
+
expect(server.listening).toBe(true);
|
|
230
|
+
held.release("early");
|
|
231
|
+
|
|
232
|
+
expect((await earlySent).text).toBe("early");
|
|
233
|
+
expect(closes).toEqual([[], []]);
|
|
234
|
+
expect(server.listening).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("serves a request built on a bind that failed, once another request has bound the server again", async () => {
|
|
238
|
+
// The first `listen` lands on a port another socket holds; the bind
|
|
239
|
+
// fails, and the request built on it is not sent until a later request
|
|
240
|
+
// has bound the server on a free port. It joins that bind rather than
|
|
241
|
+
// failing with the error of the one it was built on.
|
|
242
|
+
const held: net.Socket[] = [];
|
|
243
|
+
const squatter = net.createServer((socket) => {
|
|
244
|
+
held.push(socket);
|
|
245
|
+
});
|
|
246
|
+
await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
|
|
247
|
+
const { port } = squatter.address() as AddressInfo;
|
|
248
|
+
|
|
249
|
+
const server = http.createServer(helloApp());
|
|
250
|
+
const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
|
|
251
|
+
let steered = false;
|
|
252
|
+
(server as { listen: (...args: unknown[]) => net.Server }).listen = (requested, ...rest) => {
|
|
253
|
+
if (steered) return listen(requested, ...rest);
|
|
254
|
+
steered = true;
|
|
255
|
+
return listen(port, ...rest);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const failed = new Promise<void>((resolve) => server.once("error", () => resolve()));
|
|
260
|
+
const builtOnTheFailedBind = request(server).get("/hello");
|
|
261
|
+
await failed;
|
|
262
|
+
expect((await request(server).get("/hello")).status).toBe(200);
|
|
263
|
+
expect((await builtOnTheFailedBind).status).toBe(200);
|
|
264
|
+
expect(server.listening).toBe(false);
|
|
265
|
+
} finally {
|
|
266
|
+
for (const socket of held) socket.destroy();
|
|
267
|
+
squatter.close();
|
|
268
|
+
}
|
|
269
|
+
}, 5_000);
|
|
270
|
+
|
|
100
271
|
it("fails the request, instead of hanging on another socket, when 127.0.0.1:P is taken", async () => {
|
|
101
272
|
// The collision the kernel produces by chance, produced on purpose: a
|
|
102
273
|
// socket that accepts and never answers holds 127.0.0.1:P, and the
|