@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

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.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -16,25 +16,23 @@
16
16
 
17
17
  /**
18
18
  * The credential auth.provider presents to the Store, through the shipped
19
- * composition: `CLIENT_USER_BEARER_TOKEN` in the environment → the HOCON
20
- * layers → `AppConfigSchema` → the production `repositoriesModule` → the
19
+ * composition: `REPOSITORIES_USER_HTTP_BEARER_TOKEN` in the environment → the HOCON
20
+ * layers → the production `repositories` module's section → the
21
21
  * `"http"` user adapter → the `Authorization` header a real `node:http` Store
22
- * receives. Unset, the Store receives no `Authorization` header; set blank —
23
- * an exported-but-empty variable — the user repository is refused.
24
- *
25
- * And what a mismatch looks like from outside, with the app booted from the
26
- * shipped config through `createApp`: a Store that refuses the token with a
27
- * `401` and a `Bearer` challenge turns `POST /session/login` into a `503`
28
- * whose log line names the refused credential and never the token; a `401`
29
- * without the challenge is still a wrong password.
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.
30
27
  */
31
28
 
29
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
32
30
  import { createServer, type OutgoingHttpHeaders, type Server } from "node:http";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { inspect } from "node:util";
35
35
  import {
36
- type AppConfig,
37
- AppConfigSchema,
38
36
  createApp,
39
37
  createKeyStoreFactory,
40
38
  defineModule,
@@ -44,13 +42,19 @@ import {
44
42
  type UserRepository,
45
43
  } from "@o3co/auth-provider-core";
46
44
  import { parseFile } from "@o3co/ts.hocon";
47
- import { validate } from "@o3co/ts.hocon/zod";
48
45
  import express from "express";
49
46
  import request from "supertest";
50
- import { afterEach, describe, expect, it } from "vitest";
47
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
51
48
  import { buildModules } from "../buildModules.mjs";
52
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
53
- import { repositoriesModule } from "../modules.mjs";
49
+ import { resolveConfigPaths } from "../configPath.mjs";
50
+ import { repositoriesModuleFor, templateReference } from "../modules.mjs";
51
+ import { repositoriesSectionSchema } from "../sections.mjs";
52
+ import {
53
+ type BothPhases,
54
+ bothPhasesOf,
55
+ capturedRenames,
56
+ libraryLayers,
57
+ } from "./library-references.fixture.mjs";
54
58
 
55
59
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
56
60
 
@@ -97,39 +101,47 @@ const recordingStore = async (
97
101
  };
98
102
 
99
103
  /** The shipped config for the production overlay, resolved against `env`. */
100
- const resolve = (env: Record<string, string>): AppConfig => {
104
+ const resolve = (env: Record<string, string>): BothPhases => {
101
105
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
102
- return validate(
103
- parseFile(envConfPath, { env })
104
- .withFallback(parseFile(applicationConfPath, { env }))
105
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
106
- AppConfigSchema,
107
- );
106
+ const layers = parseFile(envConfPath, { env })
107
+ .withFallback(parseFile(applicationConfPath, { env }))
108
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
109
+ .withFallback(libraryLayers(env));
110
+ return {
111
+ ...bothPhasesOf(layers, env),
112
+ // What the resolution captured of the renamed variables.
113
+ "renamed-variables": capturedRenames(env),
114
+ } as BothPhases;
108
115
  };
109
116
 
110
- /** The user repository the production `repositoriesModule` builds from `config`. */
111
- const userRepositoryFrom = (config: AppConfig): Promise<UserRepository> =>
117
+ /** The user repository the production `repositories` module builds from `config`'s section. */
118
+ const userRepositoryFrom = (config: BothPhases): Promise<UserRepository> =>
112
119
  Promise.resolve(
113
- repositoriesModule.provides?.userRepository?.({ config } as never) as
114
- | UserRepository
115
- | Promise<UserRepository>,
120
+ repositoriesModuleFor({
121
+ client: config.adapters.clientRepository,
122
+ user: config.adapters.userRepository,
123
+ }).provides?.userRepository?.({
124
+ section: repositoriesSectionSchema.parse(
125
+ (config as unknown as Record<string, unknown>).repositories,
126
+ ),
127
+ } as never) as UserRepository | Promise<UserRepository>,
116
128
  );
117
129
 
118
130
  const envFor = (origin: string): Record<string, string> => ({
119
- OAUTH_JWT_ALGORITHM: "HS256",
120
- OAUTH_JWT_SECRET: "store-credential-composition.at-least-32-bytes.ok",
131
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
132
+ KEY_STORE_LOCAL_SECRET: "store-credential-composition.at-least-32-bytes.ok",
121
133
  OAUTH_JWT_ISSUER: "https://auth.test",
122
- SESSION_SECRET: "store-credential-composition-session.at-least-32-bytes.ok",
123
- CLIENT_USER_TYPE: "http",
124
- CLIENT_USER_AUTHENTICATE_URL: `${origin}/authenticate`,
125
- CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: `${origin}/authenticate-by-token`,
134
+ SESSION_STORE_SECRET: "store-credential-composition-session.at-least-32-bytes.ok",
135
+ ADAPTERS_USER_REPOSITORY: "http",
136
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: `${origin}/authenticate`,
137
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: `${origin}/authenticate-by-token`,
126
138
  });
127
139
 
128
- describe("CLIENT_USER_BEARER_TOKEN reaches the Store through the shipped composition", () => {
140
+ describe("REPOSITORIES_USER_HTTP_BEARER_TOKEN reaches the Store through the shipped composition", () => {
129
141
  it("sends Authorization: Bearer <token> on every Store call when the variable is set", async () => {
130
142
  const { origin, heard } = await recordingStore();
131
143
  const repo = await userRepositoryFrom(
132
- resolve({ ...envFor(origin), CLIENT_USER_BEARER_TOKEN: TOKEN }),
144
+ resolve({ ...envFor(origin), REPOSITORIES_USER_HTTP_BEARER_TOKEN: TOKEN }),
133
145
  );
134
146
 
135
147
  await repo.authenticate("alice", "pass");
@@ -149,7 +161,7 @@ describe("CLIENT_USER_BEARER_TOKEN reaches the Store through the shipped composi
149
161
 
150
162
  it("refuses the user repository when the variable is exported but empty", async () => {
151
163
  const { origin, heard } = await recordingStore();
152
- const config = resolve({ ...envFor(origin), CLIENT_USER_BEARER_TOKEN: "" });
164
+ const config = resolve({ ...envFor(origin), REPOSITORIES_USER_HTTP_BEARER_TOKEN: "" });
153
165
 
154
166
  await expect(userRepositoryFrom(config)).rejects.toThrow(/"bearerToken" must not be empty/);
155
167
  expect(heard).toEqual([]);
@@ -157,20 +169,21 @@ describe("CLIENT_USER_BEARER_TOKEN reaches the Store through the shipped composi
157
169
  });
158
170
 
159
171
  describe("a token the Store refuses, seen from outside the booted app", () => {
160
- /** Every store on memory, so the boot opens no socket but the Store's. */
172
+ /** Every store on memory, so the boot opens no socket but the Store's; MFA off. */
161
173
  const MEMORY_ENV: Readonly<Record<string, string>> = {
162
- SESSION_SECURE: "false",
163
- SESSION_NAME: "auth.session",
164
- SESSION_STORAGE_TYPE: "memory",
165
- USER_SESSION_STORES_ADAPTER: "memory",
166
- RATE_LIMITER_ADAPTER: "memory",
167
- OAUTH_CODE_ADAPTER: "memory",
168
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
169
- REPLAY_SEEN_SET_ADAPTER: "memory",
170
- FEDERATION_TOKEN_STORE_TYPE: "memory",
171
- CONSENT_STORE_ADAPTER: "none",
172
- FEDERATION_GRANT_STORE_ADAPTER: "memory",
173
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
174
+ MFA_MODE: "off",
175
+ SESSION_STORE_SECURE: "false",
176
+ SESSION_STORE_NAME: "auth.session",
177
+ SESSION_STORE_STORAGE_TYPE: "memory",
178
+ ADAPTERS_USER_SESSION_STORES: "memory",
179
+ ADAPTERS_RATE_LIMITER: "memory",
180
+ ADAPTERS_CODE_REPOSITORY: "memory",
181
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
182
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
183
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
184
+ ADAPTERS_CONSENT_STORE: "none",
185
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
186
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
174
187
  };
175
188
 
176
189
  const testKeyStoreModule = defineModule({
@@ -182,7 +195,7 @@ describe("a token the Store refuses, seen from outside the booted app", () => {
182
195
  registerBuiltinKeyStores(factory);
183
196
  return factory.create({
184
197
  type: "local",
185
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
198
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
186
199
  });
187
200
  },
188
201
  },
@@ -214,6 +227,24 @@ describe("a token the Store refuses, seen from outside the booted app", () => {
214
227
  handleRef = undefined;
215
228
  });
216
229
 
230
+ /**
231
+ * The client registry the boot reads, which this test brings itself.
232
+ * `REPOSITORIES_CLIENT_YAML_PATH`'s default, `./config/clients.yaml`, is per-deployment: the
233
+ * scaffold's `.gitignore` keeps it out of the project's repository, so a
234
+ * fresh clone — a CI runner, the `test` image — has none. The login below
235
+ * names no client, so an empty registry is all it needs.
236
+ */
237
+ let registryDir: string;
238
+ let clientPath: string;
239
+ beforeAll(() => {
240
+ registryDir = mkdtempSync(join(tmpdir(), "store-credential-"));
241
+ clientPath = join(registryDir, "clients.yaml");
242
+ writeFileSync(clientPath, "# No clients: the login under test names none.\n");
243
+ });
244
+ afterAll(() => {
245
+ rmSync(registryDir, { recursive: true, force: true });
246
+ });
247
+
217
248
  /**
218
249
  * The shipped config against a Store answering `answer`, booted with the
219
250
  * production repositories module; then a real browser login — a CSRF pair,
@@ -221,7 +252,12 @@ describe("a token the Store refuses, seen from outside the booted app", () => {
221
252
  */
222
253
  const logInAgainst = async (answer: StoreAnswer) => {
223
254
  const { origin, heard } = await recordingStore(answer);
224
- const config = resolve({ ...envFor(origin), ...MEMORY_ENV, CLIENT_USER_BEARER_TOKEN: TOKEN });
255
+ const config = resolve({
256
+ ...envFor(origin),
257
+ ...MEMORY_ENV,
258
+ REPOSITORIES_CLIENT_YAML_PATH: clientPath,
259
+ REPOSITORIES_USER_HTTP_BEARER_TOKEN: TOKEN,
260
+ });
225
261
  const { logger, lines } = capturingLogger();
226
262
  handleRef = await createApp({
227
263
  modules: buildModules(config, {
@@ -15,16 +15,15 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #556 — the server `request(app)` starts listens on the address supertest
19
- * dials, in this project's own test run.
20
- *
21
- * Unpatched, supertest starts its server with `app.listen(0)` — the
22
- * dual-stack wildcard `[::]:P` — and sends the request to `127.0.0.1:P`. On
23
- * macOS the kernel can hand out a `P` another process already holds as
24
- * `127.0.0.1:P`; the request then reaches that process, and if it never
25
- * answers the test hangs until its timeout. `vitest.supertest-loopback.mts`
26
- * (wired in through `setupFiles` in `vitest.config.mts`) binds the server to
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
- describe("#556 — supertest's own server listens on the loopback address it dials", () => {
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