@remit/backend 0.0.71 → 0.0.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { FolderRoleConflict } from "@remit/api-openapi-types";
4
+ import {
5
+ ClientError,
6
+ FolderRoleUnresolvedError,
7
+ ForbiddenError,
8
+ UnhandledError,
9
+ } from "@remit/data-ports/errors";
10
+ import { NO_TRASH_FOLDER_REASON } from "@remit/data-ports/folder-role";
11
+ import { CanonicalMailboxRole } from "@remit/domain-enums";
12
+ import { NoTrashMailboxError } from "@remit/mailbox-service";
13
+ import { handleError } from "./error.js";
14
+
15
+ const parseBody = (body: string): Record<string, unknown> =>
16
+ JSON.parse(body) as Record<string, unknown>;
17
+
18
+ describe("handleError coded refusals", () => {
19
+ it("puts the code and every detail of a folder-role 409 on the wire", async () => {
20
+ const response = await handleError(
21
+ new FolderRoleUnresolvedError(
22
+ "The folder you chose for Trash is gone.",
23
+ CanonicalMailboxRole.Trash,
24
+ "stale",
25
+ "account-7",
26
+ ),
27
+ );
28
+
29
+ assert.equal(response.statusCode, 409);
30
+ assert.deepEqual(parseBody(response.body), {
31
+ message: "The folder you chose for Trash is gone.",
32
+ code: "folder_role_unresolved",
33
+ details: { role: "Trash", reason: "stale", accountId: "account-7" },
34
+ });
35
+ });
36
+
37
+ it("carries NoTrashMailboxError as the Trash role with reason none", async () => {
38
+ const response = await handleError(new NoTrashMailboxError("account-7"));
39
+
40
+ assert.equal(response.statusCode, 409);
41
+ assert.deepEqual(parseBody(response.body), {
42
+ message: NO_TRASH_FOLDER_REASON,
43
+ code: "folder_role_unresolved",
44
+ details: { role: "Trash", reason: "none", accountId: "account-7" },
45
+ });
46
+ });
47
+
48
+ it("keeps an unhandled 500 to a message — no stack, no cause, no code", async () => {
49
+ const response = await handleError(
50
+ new UnhandledError("Something went wrong", new Error("connection reset")),
51
+ );
52
+
53
+ assert.equal(response.statusCode, 500);
54
+ assert.deepEqual(parseBody(response.body), {
55
+ message: "Something went wrong",
56
+ });
57
+ });
58
+
59
+ it("strips a code a 5xx should never have carried", async () => {
60
+ const error = new UnhandledError("Something went wrong");
61
+ error.publicApiError = {
62
+ code: "folder_role_unresolved",
63
+ details: { role: "Trash", reason: "none", accountId: "account-7" },
64
+ };
65
+
66
+ const response = await handleError(error);
67
+
68
+ assert.equal(response.statusCode, 500);
69
+ assert.deepEqual(parseBody(response.body), {
70
+ message: "Something went wrong",
71
+ });
72
+ });
73
+
74
+ it("answers an unauthenticated request with a 401 and an error body", async () => {
75
+ const response = await handleError(new ClientError("Session expired"));
76
+
77
+ assert.equal(response.statusCode, 401);
78
+ assert.deepEqual(parseBody(response.body), { message: "Session expired" });
79
+ });
80
+
81
+ it("answers a forbidden request with a 403 and an error body", async () => {
82
+ const response = await handleError(new ForbiddenError("Not your account"));
83
+
84
+ assert.equal(response.statusCode, 403);
85
+ assert.deepEqual(parseBody(response.body), { message: "Not your account" });
86
+ });
87
+
88
+ // The contract and the emitter cannot drift apart: this asserts the response
89
+ // `handleError` actually builds against the generated model. Nesting the body
90
+ // under `error` again would leave `code` off `keyof FolderRoleConflict` and
91
+ // fail to compile here, long before a client reads a 409 it cannot parse.
92
+ it("matches the generated FolderRoleConflict model field for field", async () => {
93
+ const response = await handleError(
94
+ new FolderRoleUnresolvedError(
95
+ "Nobody has confirmed a Trash folder.",
96
+ CanonicalMailboxRole.Trash,
97
+ "unconfirmed",
98
+ "account-7",
99
+ ),
100
+ );
101
+ const body: Omit<FolderRoleConflict, "statusCode"> = JSON.parse(
102
+ response.body,
103
+ );
104
+ const declaredFields: ReadonlyArray<keyof typeof body> = [
105
+ "code",
106
+ "details",
107
+ "message",
108
+ ];
109
+
110
+ assert.equal(response.statusCode, 409);
111
+ assert.deepEqual(Object.keys(body).sort(), [...declaredFields].sort());
112
+ assert.equal(body.code, "folder_role_unresolved");
113
+ });
114
+ });
package/src/error.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isPublicApiError } from "@remit/data-ports/errors";
1
2
  import { logger } from "@remit/logger-lambda";
2
3
  import type { APIGatewayProxyResult } from "aws-lambda";
3
4
  import { formatResponse } from "./response.js";
@@ -15,9 +16,21 @@ export const handleError = async (
15
16
  },
16
17
  "Error with statusCode",
17
18
  );
19
+ // Only an error that opted in gets a coded body, and only below 500:
20
+ // a coded 5xx is a mistake at the throw site, not a contract, and the
21
+ // status bound keeps it from reaching a client rather than trusting
22
+ // every future thrower to leave `publicApiError` alone.
23
+ const statusCode =
24
+ typeof error.statusCode === "number" ? error.statusCode : 500;
25
+ const publicApiError =
26
+ statusCode < 500 &&
27
+ "publicApiError" in error &&
28
+ isPublicApiError(error.publicApiError)
29
+ ? error.publicApiError
30
+ : undefined;
18
31
  return formatResponse(
19
- { message: error.message },
20
- error.statusCode as number,
32
+ { message: error.message, ...publicApiError },
33
+ statusCode,
21
34
  );
22
35
  }
23
36
 
@@ -421,7 +421,7 @@ export const TrashOperations: Record<
421
421
  await client.mailboxSpecialUse.findConfirmedTrashMailbox(accountId);
422
422
 
423
423
  if (!trashMailbox) {
424
- throw new NoTrashMailboxError();
424
+ throw new NoTrashMailboxError(accountId);
425
425
  }
426
426
 
427
427
  const messages = await client.message.listAllByMailbox(
@@ -26,6 +26,7 @@ import type {
26
26
  OutboxMessageItem,
27
27
  UpdateOutboxMessageInput,
28
28
  } from "@remit/data-ports";
29
+ import { APPENDED_UID_NONE } from "@remit/data-ports";
29
30
  import { NotFoundError } from "@remit/data-ports/errors";
30
31
  import { OutboxMessageStatus } from "@remit/domain-enums";
31
32
  import {
@@ -75,6 +76,7 @@ const createInMemoryOutboxRepository = (): IOutboxMessageRepository => {
75
76
  ccAddresses: input.ccAddresses ?? [],
76
77
  bccAddresses: input.bccAddresses ?? [],
77
78
  references: input.references ?? [],
79
+ appendedUid: APPENDED_UID_NONE,
78
80
  outboxMessageId: `outbox-${sequence}`,
79
81
  createdAt: now,
80
82
  updatedAt: now,