@remit/backend 0.0.59 → 0.0.61

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.
@@ -7,7 +7,10 @@ import {
7
7
  renderMetrics,
8
8
  setAccountSyncAges,
9
9
  } from "@remit/logger-lambda/metrics";
10
- import { isStorageNotFoundError } from "@remit/storage-service";
10
+ import {
11
+ isStorageNotFoundError,
12
+ UPLOAD_ROUTE_PREFIX,
13
+ } from "@remit/storage-service";
11
14
  import type { APIGatewayProxyResult } from "aws-lambda";
12
15
  import { env } from "expect-env";
13
16
  import express, {
@@ -25,6 +28,7 @@ import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
25
28
  import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
26
29
  import { checkRelationalStore } from "./relational-health.js";
27
30
  import { collectAccountSyncAges } from "./sync-age.js";
31
+ import { receiveUpload } from "./upload-handler.js";
28
32
 
29
33
  const app = express();
30
34
 
@@ -100,9 +104,9 @@ if (isSelfHostBackend) {
100
104
  app.all(/^\/api\/auth\//, toNodeHandler(auth));
101
105
  }
102
106
 
103
- app.use(express.json({ limit: "10mb" }));
104
- app.use(express.urlencoded({ extended: true }));
105
-
107
+ // Ahead of the body parsers, not after them: a body refused for its size never
108
+ // reaches a route, and without these headers the browser reads that refusal as
109
+ // a network failure instead of the reason it carries.
106
110
  app.use((req: Request, res: Response, next: NextFunction) => {
107
111
  const allowOrigin = resolveAllowOrigin(
108
112
  req.headers.origin,
@@ -128,6 +132,42 @@ app.use((req: Request, res: Response, next: NextFunction) => {
128
132
  }
129
133
  });
130
134
 
135
+ // The self-hosted upload receiver, the write-side twin of /content below. The
136
+ // URL that reaches here was minted by the storage backend and carries its own
137
+ // authority: an HMAC over the storage key, an expiry and the exact byte count.
138
+ // No bearer token, for the same reason /content has none — the grant has to
139
+ // travel in the URL itself. A hosted deployment presigns against block storage
140
+ // instead and nothing arrives here at all.
141
+ app.put(
142
+ new RegExp(`^${UPLOAD_ROUTE_PREFIX}.+$`),
143
+ async (req: Request, res: Response) => {
144
+ const storageKey = req.path.slice(UPLOAD_ROUTE_PREFIX.length);
145
+ const client = await getClient();
146
+
147
+ const result = await receiveUpload(client.storage, {
148
+ storageKey,
149
+ exp: typeof req.query.exp === "string" ? req.query.exp : undefined,
150
+ max: typeof req.query.max === "string" ? req.query.max : undefined,
151
+ sig: typeof req.query.sig === "string" ? req.query.sig : undefined,
152
+ body: req,
153
+ nowSeconds: Math.floor(Date.now() / 1000),
154
+ secret: process.env.BETTER_AUTH_SECRET,
155
+ findLiveReservation: (accountConfigId, outboxAttachmentId) =>
156
+ client.outboxAttachment.hasLiveReservation(
157
+ accountConfigId,
158
+ outboxAttachmentId,
159
+ ),
160
+ });
161
+
162
+ res.setHeader("x-remit-upload-reason", result.reason);
163
+ res.status(result.status);
164
+ res.send(result.status === 204 ? undefined : result.reason);
165
+ },
166
+ );
167
+
168
+ app.use(express.json({ limit: "10mb" }));
169
+ app.use(express.urlencoded({ extended: true }));
170
+
131
171
  app.get("/.well-known/appspecific/com.chrome.devtools.json", () => ({}));
132
172
 
133
173
  app.get("/health", async (_req: Request, res: Response) => {
@@ -319,7 +359,9 @@ const port = env.SERVER_PORT;
319
359
  //
320
360
  // This is also the everyday `npm run dev` server, so the addresses stay whole
321
361
  // and clickable — `url`, not a port a developer has to assemble one themselves.
322
- app.listen(Number(port), "0.0.0.0", () => {
362
+ // Exported so a test that drives this exact app can shut the listener down
363
+ // afterwards; nothing else has any business holding it.
364
+ export const listener = app.listen(Number(port), "0.0.0.0", () => {
323
365
  // biome-ignore lint/plugin/no-logger-info: the configuration a container came up on is an audit-grade signal
324
366
  logger.info(
325
367
  {
@@ -0,0 +1,132 @@
1
+ import type { Readable } from "node:stream";
2
+ import {
3
+ authorizeUploadRequest,
4
+ parseOutboxAttachmentKey,
5
+ type StorageService,
6
+ } from "@remit/storage-service";
7
+
8
+ /**
9
+ * The self-hosted receiver for an attachment upload.
10
+ *
11
+ * A hosted deployment mints a presigned PUT and none of this runs: the browser
12
+ * writes to block storage and the size is bound by the storage service itself.
13
+ * Where there is no storage service to presign against, this route is the
14
+ * equivalent, and it has to bind the same things — one attachment, one size,
15
+ * for a while — on its own.
16
+ */
17
+
18
+ export type CollectedBody =
19
+ | { readonly outcome: "Collected"; readonly content: Buffer }
20
+ | { readonly outcome: "TooLarge" };
21
+
22
+ /**
23
+ * Read a request body, refusing it the moment it passes what was reserved.
24
+ *
25
+ * The count is checked per chunk and the stream destroyed on the one that
26
+ * crosses the line, so a client that reserved a megabyte and started sending a
27
+ * hundred is cut off after the first megabyte rather than after all of it.
28
+ */
29
+ export const collectLimitedBody = async (
30
+ stream: Readable,
31
+ maxBytes: number,
32
+ ): Promise<CollectedBody> => {
33
+ const chunks: Buffer[] = [];
34
+ let received = 0;
35
+
36
+ for await (const chunk of stream) {
37
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
+ received += buffer.length;
39
+ if (received > maxBytes) {
40
+ // Stop reading, but leave the socket alone: destroying it here races the
41
+ // 413 the caller is about to write, and the client sees a connection
42
+ // reset instead of the reason it was refused.
43
+ stream.pause();
44
+ return { outcome: "TooLarge" };
45
+ }
46
+ chunks.push(buffer);
47
+ }
48
+
49
+ return { outcome: "Collected", content: Buffer.concat(chunks) };
50
+ };
51
+
52
+ export interface ReceiveUploadInput {
53
+ /** Decoded storage key, the `/outbox-upload/` prefix already stripped. */
54
+ storageKey: string;
55
+ exp: string | undefined;
56
+ max: string | undefined;
57
+ sig: string | undefined;
58
+ body: Readable;
59
+ nowSeconds: number;
60
+ secret: string | undefined;
61
+ /**
62
+ * Whether the database still holds a live reservation for this attachment.
63
+ * Injected rather than reached for, so the receiver stays a function of its
64
+ * request and the storage it writes to.
65
+ */
66
+ findLiveReservation: (
67
+ accountConfigId: string,
68
+ outboxAttachmentId: string,
69
+ nowSeconds: number,
70
+ ) => Promise<boolean>;
71
+ }
72
+
73
+ export interface UploadResult {
74
+ status: number;
75
+ reason: string;
76
+ }
77
+
78
+ export const receiveUpload = async (
79
+ storage: StorageService,
80
+ input: ReceiveUploadInput,
81
+ ): Promise<UploadResult> => {
82
+ const target = parseOutboxAttachmentKey(input.storageKey);
83
+ if (!target) return { status: 404, reason: "not-an-attachment-key" };
84
+
85
+ const auth = authorizeUploadRequest({
86
+ secret: input.secret,
87
+ relativePath: input.storageKey,
88
+ exp: input.exp,
89
+ max: input.max,
90
+ sig: input.sig,
91
+ nowSeconds: input.nowSeconds,
92
+ });
93
+ if (!auth.authorized) {
94
+ return { status: auth.status, reason: auth.reason };
95
+ }
96
+
97
+ // A signed URL outlives the draft it was minted for, so possession of one is
98
+ // not enough: the database has to still hold a live reservation for this
99
+ // attachment. Without this a URL minted before a discard writes bytes back
100
+ // under a draft that no longer exists. The hosted shape cannot make this
101
+ // check — block storage receives the PUT directly — which is what the sweep
102
+ // exists for.
103
+ const reserved = await input.findLiveReservation(
104
+ target.accountConfigId,
105
+ target.outboxAttachmentId,
106
+ input.nowSeconds,
107
+ );
108
+ if (!reserved) return { status: 409, reason: "no-live-reservation" };
109
+
110
+ const collected = await collectLimitedBody(input.body, auth.maxBytes);
111
+ if (collected.outcome === "TooLarge") {
112
+ return { status: 413, reason: "over-reserved-size" };
113
+ }
114
+
115
+ // Exactly the reserved size, not merely under it — the same thing S3 binds
116
+ // when it recomputes a presigned PUT's signature over the content-length it
117
+ // was sent. A short body is a failed upload, and the completion would refuse
118
+ // it anyway; refusing here means no partial object is left to refuse.
119
+ if (collected.content.length !== auth.maxBytes) {
120
+ return { status: 400, reason: "size-mismatch" };
121
+ }
122
+
123
+ await storage.storeOutboxAttachment({
124
+ accountConfigId: target.accountConfigId,
125
+ accountId: target.accountId,
126
+ outboxMessageId: target.outboxMessageId,
127
+ outboxAttachmentId: target.outboxAttachmentId,
128
+ content: collected.content,
129
+ });
130
+
131
+ return { status: 204, reason: "stored" };
132
+ };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Issue #679: the upload route through the real dev-server app.
3
+ *
4
+ * Not a copy of its middleware order — the app object itself, imported from
5
+ * `server.ts`. A copy is what let a JSON-typed upload silently read zero bytes:
6
+ * `express.json` drains the stream before any route below it sees the body, and
7
+ * a test that rebuilds the stack stays green when the real one is reordered.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import type { Server } from "node:http";
13
+ import { join } from "node:path";
14
+ import { after, before, describe, it } from "node:test";
15
+ import type {
16
+ IOutboxAttachmentRepository,
17
+ OutboxAttachmentItem,
18
+ } from "@remit/data-ports";
19
+ import { NotFoundError } from "@remit/data-ports/errors";
20
+ import { OutboxAttachmentService } from "@remit/mailbox-service";
21
+ import {
22
+ type StorageService,
23
+ UPLOAD_ROUTE_PREFIX,
24
+ } from "@remit/storage-service";
25
+ import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
26
+ import {
27
+ _resetForTest,
28
+ type RemitClient,
29
+ setClient,
30
+ } from "../src/service/data-client.js";
31
+
32
+ const SECRET = "a-signing-secret-of-at-least-32-characters";
33
+ const CFG = "cfg-upload";
34
+ const ACC = "acc-upload";
35
+ const DRAFT = "draft-upload";
36
+
37
+ let storage: StorageService;
38
+ let basePath: string;
39
+ let rows: Map<string, OutboxAttachmentItem>;
40
+ let server: Server;
41
+ let port: number;
42
+
43
+ const repository = (): IOutboxAttachmentRepository =>
44
+ ({
45
+ get: async (accountConfigId: string, outboxAttachmentId: string) => {
46
+ const row = rows.get(outboxAttachmentId);
47
+ if (!row || row.accountConfigId !== accountConfigId) {
48
+ throw new NotFoundError("gone");
49
+ }
50
+ return row;
51
+ },
52
+ listByOutboxMessage: async () => [...rows.values()],
53
+ }) as unknown as IOutboxAttachmentRepository;
54
+
55
+ const reserve = async (
56
+ outboxAttachmentId: string,
57
+ sizeBytes: number,
58
+ ): Promise<string> => {
59
+ const expiresAt = Math.floor(Date.now() / 1000) + 900;
60
+ rows.set(outboxAttachmentId, {
61
+ outboxAttachmentId,
62
+ outboxMessageId: DRAFT,
63
+ accountId: ACC,
64
+ accountConfigId: CFG,
65
+ filename: `${outboxAttachmentId}.bin`,
66
+ contentType: "application/octet-stream",
67
+ sizeBytes,
68
+ state: "Pending",
69
+ storageKey: "",
70
+ reservationExpiresAt: expiresAt,
71
+ createdAt: 0,
72
+ updatedAt: 0,
73
+ });
74
+ const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl({
75
+ accountConfigId: CFG,
76
+ accountId: ACC,
77
+ outboxMessageId: DRAFT,
78
+ outboxAttachmentId,
79
+ sizeBytes,
80
+ expiresAt,
81
+ });
82
+ const url = new URL(uploadUrl);
83
+ return `http://127.0.0.1:${port}${url.pathname}${url.search}`;
84
+ };
85
+
86
+ before(async () => {
87
+ // Repo-local scratch, not the machine's shared temp directory.
88
+ basePath = await mkdtemp(join(process.cwd(), ".tmp-upload-route-"));
89
+ storage = createFilesystemStorageService(basePath, {
90
+ origin: "http://127.0.0.1",
91
+ signingSecret: SECRET,
92
+ });
93
+ rows = new Map();
94
+
95
+ setClient({
96
+ storage,
97
+ outboxAttachment: new OutboxAttachmentService({
98
+ outboxMessageService: {} as never,
99
+ outboxAttachmentService: repository(),
100
+ storage,
101
+ }),
102
+ } as unknown as RemitClient);
103
+
104
+ process.env.BETTER_AUTH_SECRET = SECRET;
105
+ // Port 0: the app binds whatever is free and tells us which.
106
+ process.env.SERVER_PORT = "0";
107
+ process.env.CORS_ALLOWED_ORIGINS = "*";
108
+
109
+ const imported = await import("./server.js");
110
+ server = imported.listener;
111
+ if (!server.listening) {
112
+ await new Promise<void>((resolve) => server.once("listening", resolve));
113
+ }
114
+ const address = server.address();
115
+ port = typeof address === "object" && address ? address.port : 0;
116
+ });
117
+
118
+ after(async () => {
119
+ await new Promise<void>((resolve) => server.close(() => resolve()));
120
+ await rm(basePath, { recursive: true, force: true });
121
+ _resetForTest();
122
+ });
123
+
124
+ describe("PUT /outbox-upload through the dev-server's own app", () => {
125
+ it("stores every byte of a file the browser labelled application/json", async () => {
126
+ const content = Buffer.from(JSON.stringify({ note: "x".repeat(100) }));
127
+ const url = await reserve("attjson", content.length);
128
+
129
+ const response = await fetch(url, {
130
+ method: "PUT",
131
+ body: content,
132
+ // What `fetch(url, { body: file })` sends for a .json file: File.type.
133
+ headers: { "content-type": "application/json" },
134
+ });
135
+
136
+ assert.equal(response.status, 204);
137
+ assert.equal(
138
+ (await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attjson"))
139
+ ?.sizeBytes,
140
+ content.length,
141
+ );
142
+ });
143
+
144
+ it("stores every byte of a form-urlencoded-looking upload too", async () => {
145
+ const content = Buffer.from(`a=1&b=2&c=${"z".repeat(200)}`);
146
+ const url = await reserve("attform", content.length);
147
+
148
+ const response = await fetch(url, {
149
+ method: "PUT",
150
+ body: content,
151
+ headers: { "content-type": "application/x-www-form-urlencoded" },
152
+ });
153
+
154
+ assert.equal(response.status, 204);
155
+ assert.equal(
156
+ (await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attform"))
157
+ ?.sizeBytes,
158
+ content.length,
159
+ );
160
+ });
161
+
162
+ it("stores binary unchanged", async () => {
163
+ const content = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10]);
164
+ const url = await reserve("attpng", content.length);
165
+
166
+ const response = await fetch(url, {
167
+ method: "PUT",
168
+ body: content,
169
+ headers: { "content-type": "image/png" },
170
+ });
171
+
172
+ assert.equal(response.status, 204);
173
+ assert.equal(
174
+ (await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attpng"))
175
+ ?.sizeBytes,
176
+ content.length,
177
+ );
178
+ });
179
+
180
+ it("refuses a PUT once the draft no longer has a live reservation", async () => {
181
+ const url = await reserve("attgone", 8);
182
+ rows.delete("attgone");
183
+
184
+ const response = await fetch(url, {
185
+ method: "PUT",
186
+ body: Buffer.alloc(8),
187
+ headers: { "content-type": "application/octet-stream" },
188
+ });
189
+
190
+ assert.equal(response.status, 409);
191
+ assert.equal(
192
+ await storage.statOutboxAttachment(CFG, ACC, DRAFT, "attgone"),
193
+ null,
194
+ );
195
+ });
196
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, describe, it } from "node:test";
3
3
  import {
4
+ CONTENT_URL_TTL_SECONDS,
4
5
  createContentSigner,
5
6
  getContentSigner,
6
7
  verifyContentSignature,
@@ -133,3 +134,28 @@ describe("getContentSigner", () => {
133
134
  assert.throws(() => getContentSigner(), /BETTER_AUTH_SECRET/);
134
135
  });
135
136
  });
137
+
138
+ describe("the wire format is pinned, not merely self-consistent", () => {
139
+ /**
140
+ * Every other test here round-trips through this module, so a change to the
141
+ * canonical message or the key derivation passes them all while breaking every
142
+ * `/content` URL already in a browser. This vector was computed once, by hand,
143
+ * from the scheme as shipped: HMAC-SHA256 with a subkey derived from the
144
+ * master secret under `remit-content-url-signing-v1`, over `path\nexp`,
145
+ * base64url. If it fails, the format moved and in-flight URLs moved with it.
146
+ */
147
+ it("still produces the signature it produced when the scheme shipped", () => {
148
+ const signer = createContentSigner(
149
+ "a-signing-secret-of-at-least-32-characters",
150
+ );
151
+ const originalNow = Date.now;
152
+ Date.now = () => 1_800_000_000_000 - CONTENT_URL_TTL_SECONDS * 1000;
153
+ try {
154
+ const { exp, sig } = signer("accounts/cfg1/acc1/messages/msg1/parts/1.2");
155
+ assert.equal(exp, 1_800_000_000);
156
+ assert.equal(sig, "eFKwOYf42a6DWwaZahIm7aVtWuqMOfhOAPoyxtpFybo");
157
+ } finally {
158
+ Date.now = originalNow;
159
+ }
160
+ });
161
+ });
@@ -1,4 +1,8 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
1
+ import {
2
+ type SignedPathFailure,
3
+ signStoragePath,
4
+ verifyStoragePath,
5
+ } from "@remit/storage-service/signed-path";
2
6
  import { usesBetterAuthJwt } from "../data-backend.js";
3
7
 
4
8
  /**
@@ -20,6 +24,10 @@ import { usesBetterAuthJwt } from "../data-backend.js";
20
24
  * valid signature for another account. This is the presigned-URL capability
21
25
  * model the export flow already uses: possession of the URL grants access until
22
26
  * it expires, bounded by a short TTL.
27
+ *
28
+ * The HMAC itself lives in `@remit/storage-service/signed-path`, shared with the
29
+ * write side (an attachment upload URL) under a different derivation label. This
30
+ * module is the read side's label, TTL and public shape.
23
31
  */
24
32
 
25
33
  const KEY_DERIVATION_LABEL = "remit-content-url-signing-v1";
@@ -32,28 +40,6 @@ const KEY_DERIVATION_LABEL = "remit-content-url-signing-v1";
32
40
  */
33
41
  export const CONTENT_URL_TTL_SECONDS = 3600;
34
42
 
35
- /**
36
- * Derive a purpose-specific signing subkey from the master secret. Domain
37
- * separation via a fixed label keeps the content-signing key distinct from the
38
- * raw better-auth secret, so a compromise of one signing space does not reveal
39
- * the other. Reusing `BETTER_AUTH_SECRET` as the master means no new secret has
40
- * to be provisioned on the self-host stack — it is already required there.
41
- */
42
- const deriveSigningKey = (masterSecret: string): Buffer =>
43
- createHmac("sha256", masterSecret).update(KEY_DERIVATION_LABEL).digest();
44
-
45
- const canonicalMessage = (relativePath: string, exp: number): string =>
46
- `${relativePath}\n${exp}`;
47
-
48
- const computeSignature = (
49
- key: Buffer,
50
- relativePath: string,
51
- exp: number,
52
- ): string =>
53
- createHmac("sha256", key)
54
- .update(canonicalMessage(relativePath, exp))
55
- .digest("base64url");
56
-
57
43
  export interface ContentSignature {
58
44
  exp: number;
59
45
  sig: string;
@@ -69,10 +55,14 @@ export const createContentSigner = (
69
55
  masterSecret: string,
70
56
  ttlSeconds: number = CONTENT_URL_TTL_SECONDS,
71
57
  ): ContentSigner => {
72
- const key = deriveSigningKey(masterSecret);
73
58
  return (relativePath) => {
74
59
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
75
- return { exp, sig: computeSignature(key, relativePath, exp) };
60
+ return {
61
+ exp,
62
+ sig: signStoragePath(masterSecret, KEY_DERIVATION_LABEL, relativePath, [
63
+ exp,
64
+ ]),
65
+ };
76
66
  };
77
67
  };
78
68
 
@@ -96,11 +86,7 @@ export const getContentSigner = (): ContentSigner | undefined => {
96
86
  return createContentSigner(secret);
97
87
  };
98
88
 
99
- export type ContentSignatureFailure =
100
- | "missing"
101
- | "malformed"
102
- | "expired"
103
- | "bad-signature";
89
+ export type ContentSignatureFailure = SignedPathFailure;
104
90
 
105
91
  export type ContentSignatureResult =
106
92
  | { valid: true }
@@ -124,16 +110,13 @@ export const verifyContentSignature = (
124
110
  if (!Number.isInteger(exp) || exp <= 0) {
125
111
  return { valid: false, reason: "malformed" };
126
112
  }
127
- if (exp < nowSeconds) return { valid: false, reason: "expired" };
128
113
 
129
- const key = deriveSigningKey(masterSecret);
130
- const expected = Buffer.from(computeSignature(key, relativePath, exp));
131
- const presented = Buffer.from(sig);
132
- if (expected.length !== presented.length) {
133
- return { valid: false, reason: "bad-signature" };
134
- }
135
- if (!timingSafeEqual(expected, presented)) {
136
- return { valid: false, reason: "bad-signature" };
137
- }
138
- return { valid: true };
114
+ return verifyStoragePath(
115
+ masterSecret,
116
+ KEY_DERIVATION_LABEL,
117
+ relativePath,
118
+ [exp],
119
+ sig,
120
+ nowSeconds,
121
+ );
139
122
  };
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type { AddressItem, ResultList } from "@remit/data-ports";
4
+ import type { APIGatewayProxyEvent } from "aws-lambda";
5
+ import type { Context } from "openapi-backend";
6
+ import { deriveAccountConfigId } from "../auth.js";
7
+ import {
8
+ _resetForTest,
9
+ type RemitClient,
10
+ setClient,
11
+ } from "../service/data-client.js";
12
+ import { AddressOperations } from "./address.js";
13
+
14
+ const searchAddresses =
15
+ AddressOperations.AddressOperations_searchAddresses as unknown as (
16
+ context: Context,
17
+ event: APIGatewayProxyEvent,
18
+ ) => Promise<ResultList<AddressItem>>;
19
+
20
+ const SUB = "cognito-sub-704";
21
+ const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
22
+
23
+ const eventFor = (sub: string): APIGatewayProxyEvent =>
24
+ ({
25
+ requestContext: { authorizer: { claims: { sub } } },
26
+ }) as unknown as APIGatewayProxyEvent;
27
+
28
+ const contextFor = (query: { q: string; limit?: number }): Context =>
29
+ ({ request: { query } }) as unknown as Context;
30
+
31
+ const address = (over: Partial<AddressItem>): AddressItem =>
32
+ ({
33
+ addressId: "addr-1",
34
+ accountConfigId: ACCOUNT_CONFIG_ID,
35
+ localPart: "amsterdam",
36
+ domain: "pocahondas.nl",
37
+ normalizedEmail: "amsterdam@pocahondas.nl",
38
+ normalizedCompound: "pocahondas locatie amsterdam amsterdam@pocahondas.nl",
39
+ displayName: "Pocahondas locatie amsterdam",
40
+ flags: {},
41
+ inboundCount: 4,
42
+ outboundCount: 1,
43
+ replyCount: 2,
44
+ lastInboundAt: 1_000,
45
+ lastReplyAt: 900,
46
+ createdAt: 100,
47
+ updatedAt: 200,
48
+ ...over,
49
+ }) as AddressItem;
50
+
51
+ interface Listing {
52
+ accountConfigId: string;
53
+ search?: string;
54
+ limit?: number;
55
+ }
56
+
57
+ const clientReturning = (items: AddressItem[], seen: Listing[]): RemitClient =>
58
+ ({
59
+ address: {
60
+ listByAccountConfig: async (
61
+ input: Listing,
62
+ ): Promise<ResultList<AddressItem>> => {
63
+ seen.push(input);
64
+ return { items, continuationToken: undefined };
65
+ },
66
+ },
67
+ }) as unknown as RemitClient;
68
+
69
+ afterEach(() => {
70
+ _resetForTest();
71
+ });
72
+
73
+ describe("AddressOperations_searchAddresses", () => {
74
+ it("looks a partial term up against the caller's own addresses (#704)", async () => {
75
+ const seen: Listing[] = [];
76
+ setClient(clientReturning([address({})], seen));
77
+
78
+ const response = await searchAddresses(
79
+ contextFor({ q: "Po", limit: 8 }),
80
+ eventFor(SUB),
81
+ );
82
+
83
+ assert.deepEqual(seen, [
84
+ { accountConfigId: ACCOUNT_CONFIG_ID, search: "po", limit: 8 },
85
+ ]);
86
+ assert.deepEqual(
87
+ response.items.map((item) => item.normalizedEmail),
88
+ ["amsterdam@pocahondas.nl"],
89
+ );
90
+ assert.equal(response.items[0].displayName, "Pocahondas locatie amsterdam");
91
+ });
92
+
93
+ it("hands the suggestion list back in the order it was ranked", async () => {
94
+ const seen: Listing[] = [];
95
+ setClient(
96
+ clientReturning(
97
+ [
98
+ address({
99
+ addressId: "addr-frequent",
100
+ normalizedEmail: "zoe@pocahondas.nl",
101
+ }),
102
+ address({
103
+ addressId: "addr-stranger",
104
+ normalizedEmail: "aaron@pocahondas.nl",
105
+ inboundCount: 0,
106
+ replyCount: 0,
107
+ }),
108
+ ],
109
+ seen,
110
+ ),
111
+ );
112
+
113
+ const response = await searchAddresses(
114
+ contextFor({ q: "po" }),
115
+ eventFor(SUB),
116
+ );
117
+
118
+ assert.deepEqual(
119
+ response.items.map((item) => item.addressId),
120
+ ["addr-frequent", "addr-stranger"],
121
+ );
122
+ });
123
+
124
+ it("asks for a suggestion-sized window when the caller names no limit", async () => {
125
+ const seen: Listing[] = [];
126
+ setClient(clientReturning([], seen));
127
+
128
+ await searchAddresses(contextFor({ q: "po" }), eventFor(SUB));
129
+
130
+ assert.equal(seen[0].limit, 10);
131
+ });
132
+ });