@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.
- package/.turbo/turbo-build$colon$cjs.log +4 -4
- package/.turbo/turbo-build$colon$tsc.log +21 -21
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +22 -0
- package/dist/app.d.ts +15 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +71 -39
- package/dist/app.js.map +1 -1
- package/dist/cjs/app.cjs +79 -37
- package/dist/cjs/controllers/auth.cjs +11 -9
- package/dist/cjs/routes/routes.cjs +1 -1
- package/dist/controllers/auth.d.ts +1 -1
- package/dist/controllers/auth.d.ts.map +1 -1
- package/dist/controllers/auth.js +11 -9
- package/dist/controllers/auth.js.map +1 -1
- package/dist/routes/routes.d.ts.map +1 -1
- package/dist/routes/routes.js +1 -1
- package/dist/routes/routes.js.map +1 -1
- package/dist/tests/app.unit.test.d.ts +2 -0
- package/dist/tests/app.unit.test.d.ts.map +1 -0
- package/dist/tests/app.unit.test.js +318 -0
- package/dist/tests/app.unit.test.js.map +1 -0
- package/dist/tests/appEntrypoint.unit.test.d.ts +2 -0
- package/dist/tests/appEntrypoint.unit.test.d.ts.map +1 -0
- package/dist/tests/appEntrypoint.unit.test.js +100 -0
- package/dist/tests/appEntrypoint.unit.test.js.map +1 -0
- package/dist/tests/auth.unit.test.d.ts +2 -0
- package/dist/tests/auth.unit.test.d.ts.map +1 -0
- package/dist/tests/auth.unit.test.js +426 -0
- package/dist/tests/auth.unit.test.js.map +1 -0
- package/dist/tests/authHarness.d.ts +40 -0
- package/dist/tests/authHarness.d.ts.map +1 -0
- package/dist/tests/authHarness.js +57 -0
- package/dist/tests/authHarness.js.map +1 -0
- package/dist/tests/clientExampleServer.test-d.d.ts +2 -0
- package/dist/tests/clientExampleServer.test-d.d.ts.map +1 -0
- package/dist/tests/clientExampleServer.test-d.js +84 -0
- package/dist/tests/clientExampleServer.test-d.js.map +1 -0
- package/dist/tests/routes.unit.test.d.ts +2 -0
- package/dist/tests/routes.unit.test.d.ts.map +1 -0
- package/dist/tests/routes.unit.test.js +123 -0
- package/dist/tests/routes.unit.test.js.map +1 -0
- package/dist/tests/utils.unit.test.d.ts +2 -0
- package/dist/tests/utils.unit.test.d.ts.map +1 -0
- package/dist/tests/utils.unit.test.js +125 -0
- package/dist/tests/utils.unit.test.js.map +1 -0
- package/package.json +7 -4
- package/src/app.ts +114 -73
- package/src/controllers/auth.ts +40 -31
- package/src/routes/routes.ts +5 -2
- package/src/tests/app.unit.test.ts +428 -0
- package/src/tests/appEntrypoint.unit.test.ts +154 -0
- package/src/tests/auth.unit.test.ts +859 -0
- package/src/tests/authHarness.ts +131 -0
- package/src/tests/clientExampleServer.test-d.ts +146 -0
- package/src/tests/routes.unit.test.ts +166 -0
- package/src/tests/utils.unit.test.ts +169 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +18 -0
package/src/controllers/auth.ts
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 };
|
package/src/routes/routes.ts
CHANGED
|
@@ -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
|
+
});
|