@prosopo/api-express-router 3.1.51 → 3.1.53

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 (39) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -2
  2. package/.turbo/turbo-build$colon$tsc.log +21 -21
  3. package/.turbo/turbo-build.log +3 -3
  4. package/CHANGELOG.md +11 -0
  5. package/dist/tests/apiExpressRouter.test-d.d.ts +2 -0
  6. package/dist/tests/apiExpressRouter.test-d.d.ts.map +1 -0
  7. package/dist/tests/apiExpressRouter.test-d.js +76 -0
  8. package/dist/tests/apiExpressRouter.test-d.js.map +1 -0
  9. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.d.ts +2 -0
  10. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.d.ts.map +1 -0
  11. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.js +197 -0
  12. package/dist/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.js.map +1 -0
  13. package/dist/tests/unit/apiExpressRouterFactory.unit.test.d.ts +2 -0
  14. package/dist/tests/unit/apiExpressRouterFactory.unit.test.d.ts.map +1 -0
  15. package/dist/tests/unit/apiExpressRouterFactory.unit.test.js +108 -0
  16. package/dist/tests/unit/apiExpressRouterFactory.unit.test.js.map +1 -0
  17. package/dist/tests/unit/index.unit.test.d.ts +2 -0
  18. package/dist/tests/unit/index.unit.test.d.ts.map +1 -0
  19. package/dist/tests/unit/index.unit.test.js +44 -0
  20. package/dist/tests/unit/index.unit.test.js.map +1 -0
  21. package/dist/tests/unit/middlewares/authMiddleware.unit.test.js +210 -136
  22. package/dist/tests/unit/middlewares/authMiddleware.unit.test.js.map +1 -1
  23. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.d.ts +2 -0
  24. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.d.ts.map +1 -0
  25. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.js +242 -0
  26. package/dist/tests/unit/middlewares/requestLoggerMiddleware.unit.test.js.map +1 -0
  27. package/dist/tests/unit/testDoubles.d.ts +8 -0
  28. package/dist/tests/unit/testDoubles.d.ts.map +1 -0
  29. package/dist/tests/unit/testDoubles.js +11 -0
  30. package/dist/tests/unit/testDoubles.js.map +1 -0
  31. package/package.json +2 -2
  32. package/src/tests/apiExpressRouter.test-d.ts +190 -0
  33. package/src/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.ts +395 -0
  34. package/src/tests/unit/apiExpressRouterFactory.unit.test.ts +215 -0
  35. package/src/tests/unit/index.unit.test.ts +95 -0
  36. package/src/tests/unit/middlewares/authMiddleware.unit.test.ts +318 -159
  37. package/src/tests/unit/middlewares/requestLoggerMiddleware.unit.test.ts +368 -0
  38. package/src/tests/unit/testDoubles.ts +39 -0
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -12,172 +12,331 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import { hexToU8a, isHex } from "@polkadot/util";
16
- import { ProsopoEnvError } from "@prosopo/common";
17
- import { type Logger, getLogger } from "@prosopo/logger";
15
+ import type { IncomingHttpHeaders } from "node:http";
16
+ import { ProsopoApiError } from "@prosopo/common";
17
+ import type { Logger } from "@prosopo/logger";
18
18
  import type { KeyringPair } from "@prosopo/types";
19
- import type { JWTVerifyResult } from "@prosopo/util-crypto";
20
- import type { NextFunction, Request, Response } from "express";
21
- import { describe, expect, it, vi } from "vitest";
22
- import { authMiddleware } from "../../../middlewares/authMiddleware.js";
23
-
24
- const loggerOuter = getLogger("info", "test:auth-middleware");
25
-
26
- const mockLogger = {
27
- debug: vi.fn().mockImplementation(loggerOuter.debug.bind(loggerOuter)),
28
- log: vi.fn().mockImplementation(loggerOuter.log.bind(loggerOuter)),
29
- info: vi.fn().mockImplementation(loggerOuter.info.bind(loggerOuter)),
30
- error: vi.fn().mockImplementation(loggerOuter.error.bind(loggerOuter)),
31
- trace: vi.fn().mockImplementation(loggerOuter.trace.bind(loggerOuter)),
32
- fatal: vi.fn().mockImplementation(loggerOuter.fatal.bind(loggerOuter)),
33
- warn: vi.fn().mockImplementation(loggerOuter.warn.bind(loggerOuter)),
34
- } as unknown as Logger;
35
-
36
- vi.mock("@polkadot/util", async (importOriginal) => {
37
- const actual = await importOriginal();
38
-
39
- return {
40
- // @ts-ignore
41
- ...actual,
42
- hexToU8a: vi.fn(),
43
- isHex: vi.fn(),
44
- };
19
+ import type { JWT } from "@prosopo/util-crypto";
20
+ import type { Request, Response } from "express";
21
+ import { beforeEach, describe, expect, test, vi } from "vitest";
22
+ import {
23
+ authMiddleware,
24
+ verifySignature,
25
+ } from "../../../middlewares/authMiddleware.js";
26
+ import { type NextCapture, captureNext } from "../testDoubles.js";
27
+
28
+ /**
29
+ * Everything this middleware protects is behind a single decision: call next()
30
+ * or answer 401. It must never fall through to the handler when a token is
31
+ * absent, malformed, or verified by neither key — including when verification
32
+ * itself throws.
33
+ */
34
+
35
+ type Verify = (jwt: JWT) => { isValid: boolean };
36
+
37
+ /** A pair that only implements the parts the middleware actually calls. */
38
+ const pairThat = (verify: Verify): KeyringPair =>
39
+ ({
40
+ jwtVerify: vi.fn<Verify>(verify),
41
+ address: "5Test",
42
+ publicKey: new Uint8Array([1, 2, 3]),
43
+ verify: vi.fn<
44
+ (message: string, signature: Uint8Array, key: Uint8Array) => boolean
45
+ >(() => true),
46
+ }) as unknown as KeyringPair;
47
+
48
+ const accepts = (): KeyringPair => pairThat(() => ({ isValid: true }));
49
+ const rejects = (): KeyringPair => pairThat(() => ({ isValid: false }));
50
+ const explodes = (): KeyringPair =>
51
+ pairThat(() => {
52
+ throw new Error("verifier unavailable");
53
+ });
54
+
55
+ interface Harness {
56
+ req: Request;
57
+ res: Response;
58
+ next: NextCapture;
59
+ status: ReturnType<typeof vi.fn<(code: number) => Response>>;
60
+ json: ReturnType<typeof vi.fn<(body: unknown) => Response>>;
61
+ logged: ReturnType<typeof vi.fn<(entry: () => unknown) => void>>;
62
+ }
63
+
64
+ const build = (headers: IncomingHttpHeaders = {}): Harness => {
65
+ const json = vi.fn<(body: unknown) => Response>();
66
+ const status = vi.fn<(code: number) => Response>();
67
+ const res = { status, json } as unknown as Response;
68
+ status.mockReturnValue(res);
69
+
70
+ const logged = vi.fn<(entry: () => unknown) => void>();
71
+ const req = {
72
+ headers,
73
+ logger: { error: logged } as unknown as Logger,
74
+ } as unknown as Request;
75
+
76
+ return { req, res, next: captureNext(), status, json, logged };
77
+ };
78
+
79
+ const bearer = (token: string): IncomingHttpHeaders => ({
80
+ authorization: `Bearer ${token}`,
45
81
  });
46
82
 
47
- const mockPair = {
48
- publicKey: "mockPublicKey",
49
- verify: vi.fn(),
50
- jwtVerify: vi.fn(),
51
- } as unknown as KeyringPair;
52
- const mockEnv = {
53
- pair: mockPair,
54
- authAccount: mockPair,
55
- logger: mockLogger,
56
- jwtVerify: vi.fn(),
83
+ let harness: Harness;
84
+
85
+ beforeEach(() => {
86
+ harness = build(bearer("token-1"));
87
+ });
88
+
89
+ const run = async (
90
+ pair: KeyringPair | undefined,
91
+ authAccount?: KeyringPair | undefined,
92
+ ): Promise<void> => {
93
+ await authMiddleware(pair, authAccount)(
94
+ harness.req,
95
+ harness.res,
96
+ harness.next.fn,
97
+ );
57
98
  };
58
99
 
59
- describe("authMiddleware", () => {
60
- it("should call next() if signature is valid", async () => {
61
- const mockLogger = {
62
- debug: vi.fn().mockImplementation(loggerOuter.debug.bind(loggerOuter)),
63
- log: vi.fn().mockImplementation(loggerOuter.log.bind(loggerOuter)),
64
- info: vi.fn().mockImplementation(loggerOuter.info.bind(loggerOuter)),
65
- error: vi.fn().mockImplementation(loggerOuter.error.bind(loggerOuter)),
66
- trace: vi.fn().mockImplementation(loggerOuter.trace.bind(loggerOuter)),
67
- fatal: vi.fn().mockImplementation(loggerOuter.fatal.bind(loggerOuter)),
68
- warn: vi.fn().mockImplementation(loggerOuter.warn.bind(loggerOuter)),
69
- } as unknown as Logger;
70
- const mockReq = {
71
- url: "/v1/prosopo/provider/captcha/image",
72
- originalUrl: "/v1/prosopo/provider/captcha/image",
73
- headers: {
74
- Authorization: "Bearer mockToken",
75
- },
76
- logger: mockLogger,
77
- } as unknown as Request;
78
-
79
- const mockRes = {
80
- status: vi.fn().mockReturnThis(),
81
- json: vi.fn(),
82
- } as unknown as Response;
83
-
84
- const mockNext = vi.fn() as unknown as NextFunction;
85
-
86
- vi.mocked(isHex).mockReturnValue(true);
87
- vi.mocked(hexToU8a).mockReturnValue(new Uint8Array());
88
- vi.mocked(mockPair.jwtVerify).mockReturnValue({
89
- isValid: true,
90
- } as unknown as JWTVerifyResult);
91
-
92
- const middleware = authMiddleware(mockEnv.pair, mockEnv.authAccount);
93
- await middleware(mockReq, mockRes, mockNext);
94
-
95
- expect(mockNext).toHaveBeenCalled();
96
- expect(mockRes.status).not.toHaveBeenCalled();
97
- });
98
-
99
- it("should return 401 if jwt is invalid", async () => {
100
- const mockLogger = {
101
- debug: vi.fn().mockImplementation(loggerOuter.debug.bind(loggerOuter)),
102
- log: vi.fn().mockImplementation(loggerOuter.log.bind(loggerOuter)),
103
- info: vi.fn().mockImplementation(loggerOuter.info.bind(loggerOuter)),
104
- error: vi.fn().mockImplementation(loggerOuter.error.bind(loggerOuter)),
105
- trace: vi.fn().mockImplementation(loggerOuter.trace.bind(loggerOuter)),
106
- fatal: vi.fn().mockImplementation(loggerOuter.fatal.bind(loggerOuter)),
107
- warn: vi.fn().mockImplementation(loggerOuter.warn.bind(loggerOuter)),
108
- } as unknown as Logger;
109
- const mockReq = {
110
- url: "/v1/prosopo/provider/captcha/image",
111
- originalUrl: "/v1/prosopo/provider/captcha/image",
112
- headers: {
113
- Authorization: "Bearer mockToken",
114
- },
115
- logger: mockLogger,
116
- i18n: vi.fn().mockReturnValue({
117
- t: (key: string) => key,
118
- }),
119
- } as unknown as Request;
120
-
121
- const mockRes = {
122
- status: vi.fn().mockReturnThis(),
123
- json: vi.fn(),
124
- } as unknown as Response;
125
-
126
- const mockNext = vi.fn() as unknown as NextFunction;
127
-
128
- vi.mocked(isHex).mockReturnValue(true);
129
- vi.mocked(hexToU8a).mockReturnValue(new Uint8Array());
130
- vi.mocked(mockPair.jwtVerify).mockReturnValue({
131
- isValid: false,
132
- } as unknown as JWTVerifyResult);
133
-
134
- const middleware = authMiddleware(mockEnv.pair, mockEnv.authAccount);
135
- await middleware(mockReq, mockRes, mockNext);
136
-
137
- expect(mockNext).not.toHaveBeenCalled();
138
- expect(mockRes.status).toHaveBeenCalledWith(401);
139
- expect(mockRes.json).toHaveBeenCalledWith({
140
- error: new ProsopoEnvError("API.UNAUTHORIZED", {
141
- context: { i18n: mockReq.i18n, code: 401 },
142
- }),
143
- });
100
+ describe("a token one of the keys accepts", () => {
101
+ test("passes the request through on the auth account", async () => {
102
+ await run(rejects(), accepts());
103
+ expect(harness.next.calls).toHaveLength(1);
104
+ expect(harness.status).not.toHaveBeenCalled();
105
+ });
106
+
107
+ test("passes the request through on the provider pair", async () => {
108
+ await run(accepts(), rejects());
109
+ expect(harness.next.calls).toHaveLength(1);
110
+ });
111
+
112
+ test("does not consult the provider pair once the auth account accepts", async () => {
113
+ // Short-circuiting matters: the second verify is a signature check and
114
+ // there is no reason to pay for it twice.
115
+ const pair = accepts();
116
+ await run(pair, accepts());
117
+ expect(pair.jwtVerify).not.toHaveBeenCalled();
144
118
  });
145
119
 
146
- it("should return 401 if key pair is missing", async () => {
147
- const mockLogger = {
148
- debug: vi.fn().mockImplementation(loggerOuter.debug.bind(loggerOuter)),
149
- log: vi.fn().mockImplementation(loggerOuter.log.bind(loggerOuter)),
150
- info: vi.fn().mockImplementation(loggerOuter.info.bind(loggerOuter)),
151
- error: vi.fn().mockImplementation(loggerOuter.error.bind(loggerOuter)),
152
- trace: vi.fn().mockImplementation(loggerOuter.trace.bind(loggerOuter)),
153
- fatal: vi.fn().mockImplementation(loggerOuter.fatal.bind(loggerOuter)),
154
- warn: vi.fn().mockImplementation(loggerOuter.warn.bind(loggerOuter)),
155
- } as unknown as Logger;
156
- const mockReq = {
157
- url: "/v1/prosopo/provider/captcha/image",
158
- originalUrl: "/v1/prosopo/provider/captcha/image",
159
- headers: {
160
- Authorization: "Bearer mockToken",
161
- },
162
- logger: mockLogger,
163
- } as unknown as Request;
164
-
165
- const mockRes = {
166
- status: vi.fn().mockReturnThis(),
167
- json: vi.fn(),
168
- } as unknown as Response;
169
-
170
- const mockNext = vi.fn() as unknown as NextFunction;
171
-
172
- const middleware = authMiddleware(undefined, undefined);
173
- await middleware(mockReq, mockRes, mockNext);
174
-
175
- expect(mockNext).not.toHaveBeenCalled();
176
- expect(mockRes.status).toHaveBeenCalledWith(401);
177
- expect(mockRes.json).toHaveBeenCalledWith({
178
- error: new ProsopoEnvError("API.UNAUTHORIZED", {
179
- context: { i18n: mockReq.i18n, code: 401 },
180
- }),
120
+ test("works when only the provider pair is configured", async () => {
121
+ await run(accepts(), undefined);
122
+ expect(harness.next.calls).toHaveLength(1);
123
+ });
124
+
125
+ test("works when only the auth account is configured", async () => {
126
+ await run(undefined, accepts());
127
+ expect(harness.next.calls).toHaveLength(1);
128
+ });
129
+
130
+ test("hands the verifier the token without its Bearer prefix", async () => {
131
+ const pair = accepts();
132
+ await run(pair, undefined);
133
+ expect(pair.jwtVerify).toHaveBeenCalledWith("token-1");
134
+ });
135
+ });
136
+
137
+ describe("a token neither key accepts", () => {
138
+ beforeEach(async () => {
139
+ await run(rejects(), rejects());
140
+ });
141
+
142
+ test("is answered 401 and never reaches the handler", () => {
143
+ expect(harness.status).toHaveBeenCalledWith(401);
144
+ expect(harness.next.calls).toHaveLength(0);
145
+ });
146
+
147
+ test("is answered with an error body carrying the 401 code", () => {
148
+ const body = harness.json.mock.calls[0]?.[0] as { error: ProsopoApiError };
149
+ expect(body.error.context?.code).toBe(401);
150
+ });
151
+ });
152
+
153
+ describe("a request with no usable credentials", () => {
154
+ test("no configured keys at all is rejected, not allowed through", async () => {
155
+ // A misconfigured provider must fail closed.
156
+ await run(undefined, undefined);
157
+ expect(harness.status).toHaveBeenCalledWith(401);
158
+ expect(harness.next.calls).toHaveLength(0);
159
+ });
160
+
161
+ test("a missing Authorization header is rejected", async () => {
162
+ harness = build({});
163
+ await run(accepts(), accepts());
164
+ expect(harness.status).toHaveBeenCalledWith(401);
165
+ expect(harness.next.calls).toHaveLength(0);
166
+ });
167
+
168
+ test("an Authorization header that is not a string is rejected", async () => {
169
+ // Node hands back an array for a repeated header; verifying that would
170
+ // throw deeper in, so it is refused here.
171
+ harness = build({
172
+ authorization: ["Bearer a", "Bearer b"] as unknown as string,
181
173
  });
174
+ await run(accepts(), accepts());
175
+ expect(harness.status).toHaveBeenCalledWith(401);
176
+ });
177
+
178
+ test("an empty Authorization header is rejected", async () => {
179
+ harness = build({ authorization: "" });
180
+ await run(accepts(), accepts());
181
+ expect(harness.status).toHaveBeenCalledWith(401);
182
+ });
183
+
184
+ test("a Bearer prefix with no token after it is rejected", async () => {
185
+ harness = build({ authorization: "Bearer " });
186
+ await run(accepts(), accepts());
187
+ expect(harness.status).toHaveBeenCalledWith(401);
188
+ expect(harness.next.calls).toHaveLength(0);
189
+ });
190
+
191
+ test("the verifier is never reached for a malformed header", async () => {
192
+ harness = build({});
193
+ const pair = accepts();
194
+ await run(pair, undefined);
195
+ expect(pair.jwtVerify).not.toHaveBeenCalled();
196
+ });
197
+
198
+ test("a header rejection is logged", async () => {
199
+ harness = build({});
200
+ await run(accepts(), undefined);
201
+ expect(harness.logged).toHaveBeenCalledTimes(1);
202
+ });
203
+ });
204
+
205
+ describe("header handling quirks worth pinning down", () => {
206
+ test("a bare token with no Bearer prefix is accepted as the token", async () => {
207
+ // The prefix is stripped rather than required, so a client that omits it
208
+ // still authenticates. Recorded so a change here is deliberate.
209
+ harness = build({ authorization: "token-1" });
210
+ const pair = accepts();
211
+ await run(pair, undefined);
212
+ expect(pair.jwtVerify).toHaveBeenCalledWith("token-1");
213
+ expect(harness.next.calls).toHaveLength(1);
214
+ });
215
+
216
+ test("a capitalised Authorization key is read as well as the lowercase one", async () => {
217
+ // Node normalises real inbound header names to lowercase, so only the
218
+ // second lookup fires in production — but both are honoured, which is
219
+ // what lets a hand-built request object work in a test or a shim.
220
+ harness = build({ Authorization: "Bearer token-1" } as IncomingHttpHeaders);
221
+ const pair = accepts();
222
+ await run(pair, undefined);
223
+ expect(pair.jwtVerify).toHaveBeenCalledWith("token-1");
224
+ expect(harness.next.calls).toHaveLength(1);
225
+ });
226
+
227
+ test("a Bearer prefix appearing inside the token is also stripped", async () => {
228
+ // replace() is unanchored, so it removes the first occurrence wherever it
229
+ // sits rather than only at the front.
230
+ harness = build({ authorization: "abcBearer xyz" });
231
+ const pair = accepts();
232
+ await run(pair, undefined);
233
+ expect(pair.jwtVerify).toHaveBeenCalledWith("abcxyz");
234
+ });
235
+ });
236
+
237
+ describe("a verifier that throws", () => {
238
+ test("is answered 401 rather than crashing the request", async () => {
239
+ // A key backend being unavailable must not turn into a 500 or an
240
+ // unhandled rejection.
241
+ await run(explodes(), undefined);
242
+ expect(harness.status).toHaveBeenCalledWith(401);
243
+ expect(harness.next.calls).toHaveLength(0);
244
+ });
245
+
246
+ test("is logged against the request logger", async () => {
247
+ await run(explodes(), undefined);
248
+ expect(harness.logged).toHaveBeenCalledTimes(1);
249
+ const entry = harness.logged.mock.calls[0]?.[0] as () => {
250
+ msg: string;
251
+ err: unknown;
252
+ };
253
+ expect(entry().msg).toBe("Auth Middleware Error");
254
+ });
255
+
256
+ test("a throwing auth account does not fall through to the provider pair", async () => {
257
+ // The throw escapes the whole chain, so a working second key cannot
258
+ // rescue the request.
259
+ const pair = accepts();
260
+ await run(pair, explodes());
261
+ expect(pair.jwtVerify).not.toHaveBeenCalled();
262
+ expect(harness.next.calls).toHaveLength(0);
263
+ });
264
+
265
+ test("does not resolve before it has answered", async () => {
266
+ const middleware = authMiddleware(explodes(), undefined);
267
+ await middleware(harness.req, harness.res, harness.next.fn);
268
+ expect(harness.json).toHaveBeenCalledTimes(1);
269
+ });
270
+ });
271
+
272
+ describe("verifySignature", () => {
273
+ const message = "sign me";
274
+ const signature = "0x0102";
275
+
276
+ test("returns quietly when the pair verifies the signature", () => {
277
+ const pair = pairThat(() => ({ isValid: true }));
278
+ expect(() => verifySignature(signature, message, pair)).not.toThrow();
279
+ });
280
+
281
+ test("passes the decoded signature bytes to the pair", () => {
282
+ const pair = pairThat(() => ({ isValid: true }));
283
+ verifySignature(signature, message, pair);
284
+ expect(pair.verify).toHaveBeenCalledWith(
285
+ message,
286
+ new Uint8Array([1, 2]),
287
+ pair.publicKey,
288
+ );
289
+ });
290
+
291
+ test("throws a 401 error when verification fails", () => {
292
+ const pair = pairThat(() => ({ isValid: true }));
293
+ vi.mocked(pair.verify).mockReturnValue(false);
294
+ try {
295
+ verifySignature(signature, message, pair);
296
+ expect.unreachable("verifySignature should have thrown");
297
+ } catch (error) {
298
+ expect(error).toBeInstanceOf(ProsopoApiError);
299
+ expect((error as ProsopoApiError).context?.code).toBe(401);
300
+ }
301
+ });
302
+
303
+ test("reports which account and message failed", () => {
304
+ // Without the account in the context a failure is untraceable across the
305
+ // several keys a provider holds.
306
+ const pair = pairThat(() => ({ isValid: true }));
307
+ vi.mocked(pair.verify).mockReturnValue(false);
308
+ try {
309
+ verifySignature(signature, message, pair);
310
+ expect.unreachable("verifySignature should have thrown");
311
+ } catch (error) {
312
+ const context = (error as ProsopoApiError).context;
313
+ expect(context?.account).toBe("5Test");
314
+ expect(context?.message).toBe(message);
315
+ expect(context?.signature).toBe(signature);
316
+ }
317
+ });
318
+
319
+ test("a non-hex signature is decoded to garbage rather than rejected", () => {
320
+ // hexToU8a is lenient: it silently produces bytes for input that is not
321
+ // hex at all. Nothing here validates the shape, so a malformed signature
322
+ // reaches the pair and fails there instead of being refused up front.
323
+ const pair = pairThat(() => ({ isValid: true }));
324
+ vi.mocked(pair.verify).mockReturnValue(false);
325
+ expect(() => verifySignature("not hex", message, pair)).toThrow(
326
+ ProsopoApiError,
327
+ );
328
+ expect(pair.verify).toHaveBeenCalledTimes(1);
329
+ });
330
+
331
+ test("an empty message is still passed through to the pair", () => {
332
+ // Nothing guards against it, so an empty message verifies or fails purely
333
+ // on the pair's answer rather than being rejected up front.
334
+ const pair = pairThat(() => ({ isValid: true }));
335
+ verifySignature(signature, "", pair);
336
+ expect(pair.verify).toHaveBeenCalledWith(
337
+ "",
338
+ new Uint8Array([1, 2]),
339
+ pair.publicKey,
340
+ );
182
341
  });
183
342
  });