@prosopo/client-example-server 2.7.123 → 2.7.125

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 (59) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +4 -4
  2. package/.turbo/turbo-build$colon$tsc.log +21 -21
  3. package/.turbo/turbo-build.log +6 -6
  4. package/CHANGELOG.md +22 -0
  5. package/dist/app.d.ts +15 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +71 -39
  8. package/dist/app.js.map +1 -1
  9. package/dist/cjs/app.cjs +79 -37
  10. package/dist/cjs/controllers/auth.cjs +11 -9
  11. package/dist/cjs/routes/routes.cjs +1 -1
  12. package/dist/controllers/auth.d.ts +1 -1
  13. package/dist/controllers/auth.d.ts.map +1 -1
  14. package/dist/controllers/auth.js +11 -9
  15. package/dist/controllers/auth.js.map +1 -1
  16. package/dist/routes/routes.d.ts.map +1 -1
  17. package/dist/routes/routes.js +1 -1
  18. package/dist/routes/routes.js.map +1 -1
  19. package/dist/tests/app.unit.test.d.ts +2 -0
  20. package/dist/tests/app.unit.test.d.ts.map +1 -0
  21. package/dist/tests/app.unit.test.js +318 -0
  22. package/dist/tests/app.unit.test.js.map +1 -0
  23. package/dist/tests/appEntrypoint.unit.test.d.ts +2 -0
  24. package/dist/tests/appEntrypoint.unit.test.d.ts.map +1 -0
  25. package/dist/tests/appEntrypoint.unit.test.js +100 -0
  26. package/dist/tests/appEntrypoint.unit.test.js.map +1 -0
  27. package/dist/tests/auth.unit.test.d.ts +2 -0
  28. package/dist/tests/auth.unit.test.d.ts.map +1 -0
  29. package/dist/tests/auth.unit.test.js +426 -0
  30. package/dist/tests/auth.unit.test.js.map +1 -0
  31. package/dist/tests/authHarness.d.ts +40 -0
  32. package/dist/tests/authHarness.d.ts.map +1 -0
  33. package/dist/tests/authHarness.js +57 -0
  34. package/dist/tests/authHarness.js.map +1 -0
  35. package/dist/tests/clientExampleServer.test-d.d.ts +2 -0
  36. package/dist/tests/clientExampleServer.test-d.d.ts.map +1 -0
  37. package/dist/tests/clientExampleServer.test-d.js +84 -0
  38. package/dist/tests/clientExampleServer.test-d.js.map +1 -0
  39. package/dist/tests/routes.unit.test.d.ts +2 -0
  40. package/dist/tests/routes.unit.test.d.ts.map +1 -0
  41. package/dist/tests/routes.unit.test.js +123 -0
  42. package/dist/tests/routes.unit.test.js.map +1 -0
  43. package/dist/tests/utils.unit.test.d.ts +2 -0
  44. package/dist/tests/utils.unit.test.d.ts.map +1 -0
  45. package/dist/tests/utils.unit.test.js +125 -0
  46. package/dist/tests/utils.unit.test.js.map +1 -0
  47. package/package.json +7 -4
  48. package/src/app.ts +114 -73
  49. package/src/controllers/auth.ts +40 -31
  50. package/src/routes/routes.ts +5 -2
  51. package/src/tests/app.unit.test.ts +428 -0
  52. package/src/tests/appEntrypoint.unit.test.ts +154 -0
  53. package/src/tests/auth.unit.test.ts +859 -0
  54. package/src/tests/authHarness.ts +131 -0
  55. package/src/tests/clientExampleServer.test-d.ts +146 -0
  56. package/src/tests/routes.unit.test.ts +166 -0
  57. package/src/tests/utils.unit.test.ts +169 -0
  58. package/tsconfig.tsbuildinfo +1 -1
  59. package/vite.test.config.ts +18 -0
@@ -85,9 +85,6 @@ const getPairAndSecretForSiteKey = (
85
85
  ]) {
86
86
  const newSecret = `${baseSecret}//${captchaType}`;
87
87
  pair = getPair(newSecret);
88
- console.log(
89
- `[PAIR CHECK] Checking pair for ${captchaType}: ${pair?.address} (expected: ${siteKey})`,
90
- );
91
88
  logger.info(() => ({
92
89
  msg: "Checking pair",
93
90
  data: {
@@ -97,7 +94,6 @@ const getPairAndSecretForSiteKey = (
97
94
  },
98
95
  }));
99
96
  if (pair.address === siteKey) {
100
- console.log(`[PAIR CHECK] ✅ Match found for ${captchaType}!`);
101
97
  secret = newSecret;
102
98
  break;
103
99
  }
@@ -236,29 +232,29 @@ const signup = async (
236
232
  const salt = randomAsHex(32);
237
233
  // !!!DUMMY CODE!!! - Do not use in production. Use bcrypt or similar for password hashing.
238
234
  const passwordHash = hashPassword(`${req.body.password}${salt}`);
239
- if (passwordHash) {
240
- return User.create({
241
- email: email,
242
- name: req.body.name,
243
- password: passwordHash,
244
- salt: salt,
235
+ // No `if (passwordHash)` guard: the hash is always a non-empty hex
236
+ // string, and the guard's absent else-branch left the request hanging
237
+ // with no response at all.
238
+ return User.create({
239
+ email: email,
240
+ name: req.body.name,
241
+ password: passwordHash,
242
+ salt: salt,
243
+ })
244
+ .then(() => {
245
+ res.status(200).json({ message: "user created" });
245
246
  })
246
- .then(() => {
247
- res.status(200).json({ message: "user created" });
248
- })
249
- .catch((err) => {
250
- logger.error(() => ({
251
- err,
252
- msg: "Error creating user in database",
253
- }));
254
- res.status(502).json({ message: "error while creating the user" });
255
- });
256
- }
257
- } else {
258
- res
259
- .status(401)
260
- .json({ message: "user has not completed a captcha", verified });
247
+ .catch((err) => {
248
+ logger.error(() => ({
249
+ err,
250
+ msg: "Error creating user in database",
251
+ }));
252
+ res.status(502).json({ message: "error while creating the user" });
253
+ });
261
254
  }
255
+ res
256
+ .status(401)
257
+ .json({ message: "user has not completed a captcha", verified });
262
258
  } catch (err) {
263
259
  console.error("error", err);
264
260
  res
@@ -347,25 +343,38 @@ const login = async (
347
343
 
348
344
  const isAuth = (req: Request, res: Response) => {
349
345
  const authHeader = req.get("Authorization") || "";
346
+ // Each branch returns: without the returns a missing header still fell
347
+ // through to `at(headerParts, 1)`, which throws on a one-element array, and
348
+ // a failed verify sent a 500 followed by a 401 on the same response.
350
349
  if (!authHeader) {
351
- res.status(401).json({ message: "not authenticated" });
350
+ return res.status(401).json({ message: "not authenticated" });
351
+ }
352
+
353
+ const headerParts = authHeader.split(" ");
354
+ if (headerParts.length < 2) {
355
+ return res.status(401).json({ message: "not authenticated" });
356
+ }
357
+
358
+ const token = at(headerParts, 1);
359
+ // "Bearer " with nothing after it is a malformed credential, not a server
360
+ // fault: jwt.verify("") throws and would otherwise surface as a 500.
361
+ if (!token) {
362
+ return res.status(401).json({ message: "not authenticated" });
352
363
  }
353
364
 
354
- const token = at(authHeader.split(" "), 1);
355
365
  let decodedToken: string | JwtPayload = "";
356
366
  try {
357
367
  decodedToken = jwt.verify(token, "secret");
358
368
  } catch (err) {
359
- res.status(500).json({
369
+ return res.status(500).json({
360
370
  message: (err as Error).message || "could not decode the token",
361
371
  });
362
372
  }
363
373
 
364
374
  if (!decodedToken) {
365
- res.status(401).json({ message: "unauthorized" });
366
- } else {
367
- res.status(200).json({ message: "here is your resource" });
375
+ return res.status(401).json({ message: "unauthorized" });
368
376
  }
377
+ return res.status(200).json({ message: "here is your resource" });
369
378
  };
370
379
 
371
380
  export { signup, login, isAuth };
@@ -16,14 +16,17 @@ import express from "express";
16
16
  import type { Connection } from "mongoose";
17
17
  import { isAuth, login, signup } from "../controllers/auth.js";
18
18
 
19
- const router = express.Router();
20
-
21
19
  function getRoutes(
22
20
  mongoose: Connection,
23
21
  config: ProsopoServerConfigOutput,
24
22
  verifyEndpoint: string,
25
23
  verifyType: string,
26
24
  ): express.Router {
25
+ // A router per call: the module-level singleton this used to share meant a
26
+ // second getRoutes() stacked another set of handlers onto the same router,
27
+ // so every request ran the first call's bound config.
28
+ const router = express.Router();
29
+
27
30
  router.post(
28
31
  "/login",
29
32
  login.bind(null, mongoose, config, verifyEndpoint, verifyType),
@@ -0,0 +1,428 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import fs from "node:fs";
16
+ import type { Server } from "node:http";
17
+ import type { AddressInfo } from "node:net";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import type { ProsopoServerConfigOutput } from "@prosopo/types";
21
+ import express from "express";
22
+ import type { Connection } from "mongoose";
23
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
24
+ import {
25
+ DEFAULT_PORT,
26
+ ProsopoVerificationType,
27
+ createApp,
28
+ isDevelopmentOrTest,
29
+ main,
30
+ resolvePort,
31
+ resolveVerifyEndpoint,
32
+ resolveVerifyType,
33
+ startServer,
34
+ } from "../app.js";
35
+ import { serverConfig } from "./authHarness.js";
36
+
37
+ /**
38
+ * main() opens a mongo connection and reads the deployment config; both are
39
+ * replaced. The express wiring, the endpoint/port resolution and the HTTP vs
40
+ * HTTPS decision are exercised for real.
41
+ */
42
+ const mocks = vi.hoisted(() => ({
43
+ memoryUris: [] as string[],
44
+ memorySetup: vi.fn(),
45
+ connections: [] as string[],
46
+ routesArgs: [] as unknown[][],
47
+ config: {} as ProsopoServerConfigOutput,
48
+ }));
49
+
50
+ vi.mock("../utils/database.js", () => ({
51
+ default: () => {
52
+ mocks.memorySetup();
53
+ return Promise.resolve("mongodb://memory:27017/");
54
+ },
55
+ }));
56
+
57
+ vi.mock("../utils/connection.js", () => ({
58
+ default: (uri: string) => {
59
+ mocks.connections.push(uri);
60
+ return {} as unknown as Connection;
61
+ },
62
+ }));
63
+
64
+ vi.mock("../routes/routes.js", () => ({
65
+ default: (...args: unknown[]) => {
66
+ mocks.routesArgs.push(args);
67
+ return express.Router();
68
+ },
69
+ }));
70
+
71
+ vi.mock("@prosopo/server", async (importOriginal) => {
72
+ const actual = await importOriginal<typeof import("@prosopo/server")>();
73
+ return {
74
+ ...actual,
75
+ getServerConfig: () => mocks.config,
76
+ };
77
+ });
78
+
79
+ vi.mock("@prosopo/dotenv", async (importOriginal) => {
80
+ const actual = await importOriginal<typeof import("@prosopo/dotenv")>();
81
+ return {
82
+ ...actual,
83
+ // The real loadEnv walks the repo for .env files; irrelevant here and
84
+ // it would overwrite the env each test sets up.
85
+ loadEnv: () => undefined,
86
+ };
87
+ });
88
+
89
+ const servers: Server[] = [];
90
+
91
+ const track = (server: Server): Server => {
92
+ servers.push(server);
93
+ return server;
94
+ };
95
+
96
+ const env = { ...process.env };
97
+
98
+ beforeEach(() => {
99
+ mocks.memorySetup.mockClear();
100
+ mocks.memoryUris.length = 0;
101
+ mocks.connections.length = 0;
102
+ mocks.routesArgs.length = 0;
103
+ mocks.config = serverConfig();
104
+ process.env.NODE_ENV = "test";
105
+ process.env.MONGO_URI = "mongodb://configured:27017/";
106
+ process.env.PROSOPO_SITE_PRIVATE_KEY = "//Alice";
107
+ Reflect.deleteProperty(process.env, "PROSOPO_VERIFY_ENDPOINT");
108
+ Reflect.deleteProperty(process.env, "PROSOPO_VERIFICATION_TYPE");
109
+ });
110
+
111
+ afterEach(async () => {
112
+ await Promise.all(
113
+ servers.splice(0).map(
114
+ (server) =>
115
+ new Promise<void>((resolve) => {
116
+ server.close(() => resolve());
117
+ }),
118
+ ),
119
+ );
120
+ for (const key of Object.keys(process.env)) {
121
+ if (!(key in env)) Reflect.deleteProperty(process.env, key);
122
+ }
123
+ Object.assign(process.env, env);
124
+ vi.restoreAllMocks();
125
+ });
126
+
127
+ describe("resolveVerifyEndpoint", () => {
128
+ test("prefers an explicitly configured endpoint", () => {
129
+ process.env.PROSOPO_VERIFY_ENDPOINT = "https://self-hosted/siteverify";
130
+ expect(resolveVerifyEndpoint()).toBe("https://self-hosted/siteverify");
131
+ });
132
+
133
+ test("prefixes the hosted API with the environment name", () => {
134
+ process.env.NODE_ENV = "staging";
135
+ expect(resolveVerifyEndpoint()).toBe(
136
+ "https://staging-api.prosopo.io/siteverify",
137
+ );
138
+ });
139
+
140
+ test("uses the bare hosted API in production", () => {
141
+ process.env.NODE_ENV = "production";
142
+ expect(resolveVerifyEndpoint()).toBe("https://api.prosopo.io/siteverify");
143
+ });
144
+
145
+ test("an unset NODE_ENV falls back to the production host", () => {
146
+ // Prefixing an absent environment produced undefined-api.prosopo.io,
147
+ // which resolves to nothing.
148
+ Reflect.deleteProperty(process.env, "NODE_ENV");
149
+ expect(resolveVerifyEndpoint()).toBe("https://api.prosopo.io/siteverify");
150
+ });
151
+
152
+ test("an empty NODE_ENV is treated the same way", () => {
153
+ process.env.NODE_ENV = "";
154
+ expect(resolveVerifyEndpoint()).toBe("https://api.prosopo.io/siteverify");
155
+ });
156
+ });
157
+
158
+ describe("resolveVerifyType", () => {
159
+ test("accepts the verification types it knows", () => {
160
+ process.env.PROSOPO_VERIFICATION_TYPE = "local";
161
+ expect(resolveVerifyType()).toBe(ProsopoVerificationType.local);
162
+ process.env.PROSOPO_VERIFICATION_TYPE = "api";
163
+ expect(resolveVerifyType()).toBe(ProsopoVerificationType.api);
164
+ });
165
+
166
+ test("falls back to the hosted API for anything else", () => {
167
+ process.env.PROSOPO_VERIFICATION_TYPE = "carrier-pigeon";
168
+ expect(resolveVerifyType()).toBe(ProsopoVerificationType.api);
169
+ });
170
+
171
+ test("falls back to the hosted API when unset", () => {
172
+ expect(resolveVerifyType()).toBe(ProsopoVerificationType.api);
173
+ });
174
+ });
175
+
176
+ describe("isDevelopmentOrTest", () => {
177
+ test("is true only for development and test", () => {
178
+ for (const [value, expected] of [
179
+ ["development", true],
180
+ ["test", true],
181
+ ["production", false],
182
+ ["staging", false],
183
+ ] as [string, boolean][]) {
184
+ process.env.NODE_ENV = value;
185
+ expect(isDevelopmentOrTest()).toBe(expected);
186
+ }
187
+ });
188
+ });
189
+
190
+ describe("resolvePort", () => {
191
+ test("takes the port from the configured server URL", () => {
192
+ expect(resolvePort(serverConfig())).toBe(9228);
193
+ });
194
+
195
+ test("defaults when no server URL is configured", () => {
196
+ const config: ProsopoServerConfigOutput = {
197
+ ...serverConfig(),
198
+ serverUrl: "",
199
+ };
200
+ expect(resolvePort(config)).toBe(9228);
201
+ });
202
+
203
+ test("defaults on a server URL with no port segment", () => {
204
+ // This used to produce NaN, which node listens on as an arbitrary free
205
+ // port — the server came up somewhere nothing was pointed at.
206
+ const config: ProsopoServerConfigOutput = {
207
+ ...serverConfig(),
208
+ serverUrl: "https://localhost",
209
+ };
210
+ expect(resolvePort(config)).toBe(DEFAULT_PORT);
211
+ });
212
+
213
+ test("defaults when the port segment isn't a number", () => {
214
+ const config: ProsopoServerConfigOutput = {
215
+ ...serverConfig(),
216
+ serverUrl: "https://localhost:https",
217
+ };
218
+ expect(resolvePort(config)).toBe(DEFAULT_PORT);
219
+ });
220
+ });
221
+
222
+ describe("createApp", () => {
223
+ const listen = (app: express.Express): Promise<string> =>
224
+ new Promise((resolve) => {
225
+ const server = track(
226
+ app.listen(0, () => {
227
+ const { port } = server.address() as AddressInfo;
228
+ resolve(`http://127.0.0.1:${port}`);
229
+ }),
230
+ );
231
+ });
232
+
233
+ test("sets permissive CORS headers on every response", async () => {
234
+ const app = createApp();
235
+ app.get("/thing", (_req, res) => {
236
+ res.status(200).json({ ok: true });
237
+ });
238
+ const base = await listen(app);
239
+ const response = await fetch(`${base}/thing`);
240
+ expect(response.headers.get("access-control-allow-origin")).toBe("*");
241
+ expect(response.headers.get("access-control-allow-methods")).toContain(
242
+ "POST",
243
+ );
244
+ expect(response.headers.get("access-control-allow-headers")).toContain(
245
+ "Authorization",
246
+ );
247
+ });
248
+
249
+ test("answers preflight requests", async () => {
250
+ // cors() answers preflight itself with a 204; the explicit OPTIONS
251
+ // handler this app used to declare could never run.
252
+ const base = await listen(createApp());
253
+ const response = await fetch(`${base}/anything`, { method: "OPTIONS" });
254
+ expect(response.status).toBe(204);
255
+ expect(response.headers.get("access-control-allow-methods")).toContain(
256
+ "POST",
257
+ );
258
+ });
259
+
260
+ test("parses JSON bodies", async () => {
261
+ const app = createApp();
262
+ app.post("/echo", (req, res) => {
263
+ res.status(200).json(req.body);
264
+ });
265
+ const base = await listen(app);
266
+ const response = await fetch(`${base}/echo`, {
267
+ method: "POST",
268
+ headers: { "content-type": "application/json" },
269
+ body: JSON.stringify({ email: "user@example.com" }),
270
+ });
271
+ expect(await response.json()).toEqual({ email: "user@example.com" });
272
+ });
273
+
274
+ test("parses form-encoded bodies", async () => {
275
+ const app = createApp();
276
+ app.post("/echo", (req, res) => {
277
+ res.status(200).json(req.body);
278
+ });
279
+ const base = await listen(app);
280
+ const response = await fetch(`${base}/echo`, {
281
+ method: "POST",
282
+ headers: { "content-type": "application/x-www-form-urlencoded" },
283
+ body: "email=user%40example.com",
284
+ });
285
+ expect(await response.json()).toEqual({ email: "user@example.com" });
286
+ });
287
+
288
+ test("trusts one proxy hop, so client IPs survive the reverse proxy", () => {
289
+ expect(createApp().get("trust proxy")).toBe(1);
290
+ });
291
+ });
292
+
293
+ describe("startServer", () => {
294
+ const certDir = (): string => {
295
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ces-certs-"));
296
+ fs.mkdirSync(path.join(root, "certs"));
297
+ return root;
298
+ };
299
+
300
+ test("serves HTTPS in development when the certificates exist", async () => {
301
+ process.env.NODE_ENV = "development";
302
+ const root = certDir();
303
+ // startServer resolves ../../../certs relative to the module, so the
304
+ // fake module directory sits three levels below the cert directory.
305
+ const moduleDir = path.join(root, "certs", "a", "b");
306
+ fs.mkdirSync(moduleDir, { recursive: true });
307
+ const certs = path.join(root, "certs");
308
+ // A throwaway self-signed pair would need openssl; the files only have
309
+ // to exist for the branch, and https.createServer is stubbed.
310
+ fs.writeFileSync(path.join(certs, "server.key"), "key");
311
+ fs.writeFileSync(path.join(certs, "server.crt"), "cert");
312
+ const https = await import("node:https");
313
+ const fake = { listen: vi.fn((_port: number, cb: () => void) => cb()) };
314
+ const createServer = vi
315
+ .spyOn(https.default, "createServer")
316
+ .mockReturnValue(
317
+ fake as unknown as ReturnType<typeof https.createServer>,
318
+ );
319
+ startServer(express(), 1234, path.join(certs, "a", "b"));
320
+ expect(createServer).toHaveBeenCalledTimes(1);
321
+ const options = createServer.mock.calls[0]?.[0] as {
322
+ key: Buffer;
323
+ cert: Buffer;
324
+ };
325
+ expect(options.key.toString()).toBe("key");
326
+ expect(options.cert.toString()).toBe("cert");
327
+ fs.rmSync(root, { recursive: true, force: true });
328
+ });
329
+
330
+ test("falls back to HTTP in development when the certificates are missing", async () => {
331
+ process.env.NODE_ENV = "development";
332
+ const server = track(
333
+ startServer(express(), 0, path.join(os.tmpdir(), "no-such-dir")),
334
+ );
335
+ await new Promise<void>((resolve) => {
336
+ if (server.listening) return resolve();
337
+ server.once("listening", () => resolve());
338
+ });
339
+ expect((server.address() as AddressInfo).port).toBeGreaterThan(0);
340
+ });
341
+
342
+ test("serves plain HTTP in production, where a proxy terminates TLS", async () => {
343
+ process.env.NODE_ENV = "production";
344
+ const https = await import("node:https");
345
+ const createServer = vi.spyOn(https.default, "createServer");
346
+ const server = track(startServer(express(), 0));
347
+ expect(createServer).not.toHaveBeenCalled();
348
+ expect((server.address() as AddressInfo).port).toBeGreaterThan(0);
349
+ });
350
+
351
+ test("the server it returns actually serves the app", async () => {
352
+ process.env.NODE_ENV = "production";
353
+ const app = express();
354
+ app.get("/health", (_req, res) => {
355
+ res.status(200).json({ status: "ok" });
356
+ });
357
+ const server = track(startServer(app, 0));
358
+ await new Promise<void>((resolve) => {
359
+ if (server.listening) return resolve();
360
+ server.once("listening", () => resolve());
361
+ });
362
+ const { port } = server.address() as AddressInfo;
363
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
364
+ expect(await response.json()).toEqual({ status: "ok" });
365
+ });
366
+ });
367
+
368
+ describe("main", () => {
369
+ test("wires the routes with the resolved endpoint and verification type", async () => {
370
+ process.env.PROSOPO_VERIFY_ENDPOINT = "https://self-hosted/siteverify";
371
+ process.env.PROSOPO_VERIFICATION_TYPE = "local";
372
+ mocks.config = { ...serverConfig(), serverUrl: "https://localhost:0" };
373
+ track(await main());
374
+ expect(mocks.routesArgs[0]?.slice(1)).toEqual([
375
+ mocks.config,
376
+ "https://self-hosted/siteverify",
377
+ ProsopoVerificationType.local,
378
+ ]);
379
+ });
380
+
381
+ test("connects to the configured mongo instance", async () => {
382
+ mocks.config = { ...serverConfig(), serverUrl: "https://localhost:0" };
383
+ track(await main());
384
+ expect(mocks.connections).toEqual(["mongodb://configured:27017/"]);
385
+ expect(mocks.memorySetup).not.toHaveBeenCalled();
386
+ });
387
+
388
+ test("starts an in-memory mongo when none is configured", async () => {
389
+ Reflect.deleteProperty(process.env, "MONGO_URI");
390
+ mocks.config = { ...serverConfig(), serverUrl: "https://localhost:0" };
391
+ track(await main());
392
+ expect(mocks.memorySetup).toHaveBeenCalledTimes(1);
393
+ expect(mocks.connections).toEqual(["mongodb://memory:27017/"]);
394
+ });
395
+
396
+ test("refuses to run an in-memory database outside development", async () => {
397
+ Reflect.deleteProperty(process.env, "MONGO_URI");
398
+ process.env.NODE_ENV = "production";
399
+ await expect(main()).rejects.toThrow(
400
+ "Cannot run mongo memory when NODE_ENV is neither development nor test",
401
+ );
402
+ expect(mocks.connections).toHaveLength(0);
403
+ });
404
+
405
+ test("boots without a site private key, but says so", async () => {
406
+ // The demo server is still useful without one — every signup will just
407
+ // fail verification — so this logs rather than throws.
408
+ Reflect.deleteProperty(process.env, "PROSOPO_SITE_PRIVATE_KEY");
409
+ mocks.config = { ...serverConfig(), serverUrl: "https://localhost:0" };
410
+ const server = track(await main());
411
+ expect(server.listening).toBe(true);
412
+ });
413
+
414
+ test("listens on the port the config names", async () => {
415
+ mocks.config = { ...serverConfig(), serverUrl: "https://localhost:0" };
416
+ const server = track(await main());
417
+ expect((server.address() as AddressInfo).port).toBeGreaterThan(0);
418
+ });
419
+
420
+ test("propagates a database connection failure instead of listening", async () => {
421
+ mocks.config = { ...serverConfig(), serverUrl: "https://localhost:0" };
422
+ const connection = await import("../utils/connection.js");
423
+ vi.spyOn(connection, "default").mockImplementation(() => {
424
+ throw new Error("mongo unreachable");
425
+ });
426
+ await expect(main()).rejects.toThrow("mongo unreachable");
427
+ });
428
+ });