@remit/backend 0.0.54 → 0.0.56
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 +1 -1
- package/src/handlers/outbox.test.ts +294 -0
- package/src/handlers/system-update.ts +3 -3
- package/src/index.ts +12 -9
- package/src/request-context.ts +4 -0
- package/src/response.test.ts +24 -1
- package/src/response.ts +17 -1
package/package.json
CHANGED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #604: composing a reply, sending it, and then letting one more autosave
|
|
3
|
+
* land 500'd the browser with "Internal server error" two seconds after a send
|
|
4
|
+
* that had actually succeeded.
|
|
5
|
+
*
|
|
6
|
+
* A PATCH against an entry that is no longer a draft is a foreseeable race, not
|
|
7
|
+
* a fault: the draft editor debounces its writes, so the last one can be in
|
|
8
|
+
* flight while the send flips the status. The designed answer is 409 — the
|
|
9
|
+
* entry is immutable now, and saying so truthfully is what lets a client stop.
|
|
10
|
+
*
|
|
11
|
+
* Driven through the real handlers, the real OutboxQueueService and the real
|
|
12
|
+
* error funnel, so what is asserted is the status code the browser receives.
|
|
13
|
+
* Only the queue and the store are stood in for.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import type { SQSClient } from "@aws-sdk/client-sqs";
|
|
19
|
+
import type {
|
|
20
|
+
CreateOutboxMessageInput,
|
|
21
|
+
IAccountRepository,
|
|
22
|
+
IOutboxMessageRepository,
|
|
23
|
+
OutboxMessageItem,
|
|
24
|
+
UpdateOutboxMessageInput,
|
|
25
|
+
} from "@remit/data-ports";
|
|
26
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
27
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
28
|
+
import { OutboxQueueService } from "@remit/mailbox-service";
|
|
29
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
30
|
+
import type { Context } from "openapi-backend";
|
|
31
|
+
import { deriveAccountConfigId } from "../auth.js";
|
|
32
|
+
import { handleError } from "../error.js";
|
|
33
|
+
import { formatResponse } from "../response.js";
|
|
34
|
+
import {
|
|
35
|
+
_resetForTest,
|
|
36
|
+
type RemitClient,
|
|
37
|
+
setClient,
|
|
38
|
+
} from "../service/data-client.js";
|
|
39
|
+
import { OutboxDetailOperations, OutboxOperations } from "./outbox.js";
|
|
40
|
+
|
|
41
|
+
const SUB = "cognito-sub-604";
|
|
42
|
+
const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
|
|
43
|
+
const ACCOUNT_ID = "acc-604";
|
|
44
|
+
const ACCOUNT_EMAIL = "sender@example.com";
|
|
45
|
+
|
|
46
|
+
const createInMemoryOutboxRepository = (): IOutboxMessageRepository => {
|
|
47
|
+
const rows = new Map<string, OutboxMessageItem>();
|
|
48
|
+
let sequence = 0;
|
|
49
|
+
|
|
50
|
+
const mustGet = (outboxMessageId: string): OutboxMessageItem => {
|
|
51
|
+
const row = rows.get(outboxMessageId);
|
|
52
|
+
if (!row) throw new NotFoundError(`No outbox message ${outboxMessageId}`);
|
|
53
|
+
return row;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const repository = {
|
|
57
|
+
create: async (
|
|
58
|
+
input: CreateOutboxMessageInput,
|
|
59
|
+
): Promise<OutboxMessageItem> => {
|
|
60
|
+
sequence += 1;
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const row: OutboxMessageItem = {
|
|
63
|
+
...input,
|
|
64
|
+
ccAddresses: input.ccAddresses ?? [],
|
|
65
|
+
bccAddresses: input.bccAddresses ?? [],
|
|
66
|
+
references: input.references ?? [],
|
|
67
|
+
outboxMessageId: `outbox-${sequence}`,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
};
|
|
71
|
+
rows.set(row.outboxMessageId, row);
|
|
72
|
+
return row;
|
|
73
|
+
},
|
|
74
|
+
get: async (
|
|
75
|
+
_accountConfigId: string,
|
|
76
|
+
outboxMessageId: string | string[],
|
|
77
|
+
) =>
|
|
78
|
+
Array.isArray(outboxMessageId)
|
|
79
|
+
? outboxMessageId.map(mustGet)
|
|
80
|
+
: mustGet(outboxMessageId),
|
|
81
|
+
update: async (
|
|
82
|
+
_accountConfigId: string,
|
|
83
|
+
outboxMessageId: string,
|
|
84
|
+
input: UpdateOutboxMessageInput,
|
|
85
|
+
): Promise<OutboxMessageItem> => {
|
|
86
|
+
const row = {
|
|
87
|
+
...mustGet(outboxMessageId),
|
|
88
|
+
...input,
|
|
89
|
+
updatedAt: Date.now(),
|
|
90
|
+
};
|
|
91
|
+
rows.set(outboxMessageId, row);
|
|
92
|
+
return row;
|
|
93
|
+
},
|
|
94
|
+
updateStatus: async (
|
|
95
|
+
accountConfigId: string,
|
|
96
|
+
outboxMessageId: string,
|
|
97
|
+
status: OutboxMessageItem["status"],
|
|
98
|
+
) => repository.update(accountConfigId, outboxMessageId, { status }),
|
|
99
|
+
markSent: async (
|
|
100
|
+
accountConfigId: string,
|
|
101
|
+
outboxMessageId: string,
|
|
102
|
+
fields: { sentAt: number; smtpMessageId?: string },
|
|
103
|
+
) =>
|
|
104
|
+
repository.update(accountConfigId, outboxMessageId, {
|
|
105
|
+
...fields,
|
|
106
|
+
status: OutboxMessageStatus.sent,
|
|
107
|
+
}),
|
|
108
|
+
delete: async (_accountConfigId: string, outboxMessageId: string) => {
|
|
109
|
+
rows.delete(outboxMessageId);
|
|
110
|
+
},
|
|
111
|
+
deleteMany: async (
|
|
112
|
+
_accountConfigId: string,
|
|
113
|
+
outboxMessageIds: string[],
|
|
114
|
+
) => {
|
|
115
|
+
for (const id of outboxMessageIds) rows.delete(id);
|
|
116
|
+
},
|
|
117
|
+
listByAccount: async () => ({
|
|
118
|
+
items: [...rows.values()],
|
|
119
|
+
continuationToken: null,
|
|
120
|
+
}),
|
|
121
|
+
listQueued: async () =>
|
|
122
|
+
[...rows.values()].filter(
|
|
123
|
+
(row) => row.status === OutboxMessageStatus.queued,
|
|
124
|
+
),
|
|
125
|
+
} as unknown as IOutboxMessageRepository;
|
|
126
|
+
|
|
127
|
+
return repository;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const acceptingSqsClient = (): SQSClient =>
|
|
131
|
+
({ send: async () => ({}) }) as unknown as SQSClient;
|
|
132
|
+
|
|
133
|
+
const accountRepository = {
|
|
134
|
+
get: async () => ({
|
|
135
|
+
accountId: ACCOUNT_ID,
|
|
136
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
137
|
+
email: ACCOUNT_EMAIL,
|
|
138
|
+
}),
|
|
139
|
+
} as unknown as IAccountRepository;
|
|
140
|
+
|
|
141
|
+
const installClient = (): void => {
|
|
142
|
+
const outboxMessage = createInMemoryOutboxRepository();
|
|
143
|
+
setClient({
|
|
144
|
+
outboxMessage,
|
|
145
|
+
account: accountRepository,
|
|
146
|
+
outboxQueue: new OutboxQueueService({
|
|
147
|
+
outboxMessageService: outboxMessage,
|
|
148
|
+
accountService: accountRepository,
|
|
149
|
+
sqsSmtpQueueUrl: "http://localhost:9324/queue/outbox-test",
|
|
150
|
+
sqsClient: acceptingSqsClient(),
|
|
151
|
+
}),
|
|
152
|
+
} as unknown as RemitClient);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const authorizedEvent = (body?: unknown): APIGatewayProxyEvent =>
|
|
156
|
+
({
|
|
157
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
158
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
159
|
+
}) as unknown as APIGatewayProxyEvent;
|
|
160
|
+
|
|
161
|
+
const requestContext = (request: {
|
|
162
|
+
params?: Record<string, string>;
|
|
163
|
+
requestBody?: unknown;
|
|
164
|
+
}): Context => ({ request }) as unknown as Context;
|
|
165
|
+
|
|
166
|
+
type Handler = (
|
|
167
|
+
context: Context,
|
|
168
|
+
event: APIGatewayProxyEvent,
|
|
169
|
+
) => Promise<Record<string, unknown>>;
|
|
170
|
+
|
|
171
|
+
const createDraft =
|
|
172
|
+
OutboxOperations.OutboxOperations_createOutboxMessage as Handler;
|
|
173
|
+
const sendMessage =
|
|
174
|
+
OutboxDetailOperations.OutboxDetailOperations_sendOutboxMessage as Handler;
|
|
175
|
+
const updateDraft =
|
|
176
|
+
OutboxDetailOperations.OutboxDetailOperations_updateOutboxMessage as Handler;
|
|
177
|
+
const deleteDraft =
|
|
178
|
+
OutboxDetailOperations.OutboxDetailOperations_deleteOutboxMessage as Handler;
|
|
179
|
+
|
|
180
|
+
type Outcome =
|
|
181
|
+
| { readonly ok: true; readonly body: Record<string, unknown> }
|
|
182
|
+
| { readonly ok: false; readonly error: unknown };
|
|
183
|
+
|
|
184
|
+
/** The response the browser would receive, error funnel included. */
|
|
185
|
+
const respond = async (
|
|
186
|
+
run: () => Promise<Record<string, unknown>>,
|
|
187
|
+
): Promise<APIGatewayProxyResult> => {
|
|
188
|
+
const outcome: Outcome = await run().then(
|
|
189
|
+
(body) => ({ ok: true, body }) as const,
|
|
190
|
+
(error: unknown) => ({ ok: false, error }) as const,
|
|
191
|
+
);
|
|
192
|
+
if (!outcome.ok) return handleError(outcome.error);
|
|
193
|
+
return formatResponse(outcome.body);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const sentOutboxMessageId = async (): Promise<string> => {
|
|
197
|
+
const draft = await createDraft(
|
|
198
|
+
requestContext({}),
|
|
199
|
+
authorizedEvent({
|
|
200
|
+
accountId: ACCOUNT_ID,
|
|
201
|
+
toAddresses: ["recipient@example.com"],
|
|
202
|
+
subject: "Re: the thing",
|
|
203
|
+
textBody: "on it",
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
const outboxMessageId = draft.outboxMessageId;
|
|
207
|
+
assert.equal(typeof outboxMessageId, "string");
|
|
208
|
+
|
|
209
|
+
const sent = await sendMessage(
|
|
210
|
+
requestContext({ params: { outboxMessageId: String(outboxMessageId) } }),
|
|
211
|
+
authorizedEvent(),
|
|
212
|
+
);
|
|
213
|
+
assert.notEqual(sent.status, "draft");
|
|
214
|
+
|
|
215
|
+
return String(outboxMessageId);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
afterEach(() => {
|
|
219
|
+
_resetForTest();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("an outbox entry that has left draft (#604)", () => {
|
|
223
|
+
it("answers a late autosave PATCH with 409, never a 500", async () => {
|
|
224
|
+
installClient();
|
|
225
|
+
const outboxMessageId = await sentOutboxMessageId();
|
|
226
|
+
|
|
227
|
+
const response = await respond(() =>
|
|
228
|
+
updateDraft(
|
|
229
|
+
requestContext({
|
|
230
|
+
params: { outboxMessageId },
|
|
231
|
+
requestBody: { subject: "Re: the thing", textBody: "on it!" },
|
|
232
|
+
}),
|
|
233
|
+
authorizedEvent(),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
assert.equal(response.statusCode, 409);
|
|
238
|
+
const body = JSON.parse(response.body) as { message?: string };
|
|
239
|
+
assert.match(String(body.message), /can no longer be edited/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("refuses a second send with 409, never a 500", async () => {
|
|
243
|
+
installClient();
|
|
244
|
+
const outboxMessageId = await sentOutboxMessageId();
|
|
245
|
+
|
|
246
|
+
const response = await respond(() =>
|
|
247
|
+
sendMessage(
|
|
248
|
+
requestContext({ params: { outboxMessageId } }),
|
|
249
|
+
authorizedEvent(),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
assert.equal(response.statusCode, 409);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("refuses a discard with 409, never a 500", async () => {
|
|
257
|
+
installClient();
|
|
258
|
+
const outboxMessageId = await sentOutboxMessageId();
|
|
259
|
+
|
|
260
|
+
const response = await respond(() =>
|
|
261
|
+
deleteDraft(
|
|
262
|
+
requestContext({ params: { outboxMessageId } }),
|
|
263
|
+
authorizedEvent(),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
assert.equal(response.statusCode, 409);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("still accepts an autosave PATCH while the entry is a draft", async () => {
|
|
271
|
+
installClient();
|
|
272
|
+
const draft = await createDraft(
|
|
273
|
+
requestContext({}),
|
|
274
|
+
authorizedEvent({
|
|
275
|
+
accountId: ACCOUNT_ID,
|
|
276
|
+
toAddresses: ["recipient@example.com"],
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const response = await respond(() =>
|
|
281
|
+
updateDraft(
|
|
282
|
+
requestContext({
|
|
283
|
+
params: { outboxMessageId: String(draft.outboxMessageId) },
|
|
284
|
+
requestBody: { subject: "still editing" },
|
|
285
|
+
}),
|
|
286
|
+
authorizedEvent(),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
assert.equal(response.statusCode, 200);
|
|
291
|
+
const body = JSON.parse(response.body) as { subject?: string };
|
|
292
|
+
assert.equal(body.subject, "still editing");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -115,9 +115,9 @@ const writeRequest = (request: {
|
|
|
115
115
|
/**
|
|
116
116
|
* The resource returned by the POST. The updater has not yet written the
|
|
117
117
|
* authoritative run — it polls the seam — so this bootstraps the run block with
|
|
118
|
-
* the id just requested and the first phase, giving the client a `runId` to
|
|
119
|
-
*
|
|
120
|
-
*
|
|
118
|
+
* the id just requested and the first phase, giving the client a `runId` to poll
|
|
119
|
+
* for (RFC 037 D9). The updater's own `state.json` supersedes it on the next
|
|
120
|
+
* read.
|
|
121
121
|
*/
|
|
122
122
|
const requestedResource = (
|
|
123
123
|
state: SystemUpdateResponse | null,
|
package/src/index.ts
CHANGED
|
@@ -160,15 +160,18 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
|
|
|
160
160
|
|
|
161
161
|
const origin = readOriginHeader(event.headers);
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
return runWithRequestContext(
|
|
164
|
+
{ origin, correlationId: context.awsRequestId },
|
|
165
|
+
async () => {
|
|
166
|
+
if (usesBetterAuthJwt()) {
|
|
167
|
+
const denied = await authenticateSelfHostRequest(event);
|
|
168
|
+
if (denied) return denied;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return api
|
|
172
|
+
.handleRequest(normalizeRequest(event), event, context)
|
|
173
|
+
.catch(handleError);
|
|
174
|
+
},
|
|
172
175
|
);
|
|
173
176
|
},
|
|
174
177
|
);
|
package/src/request-context.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
|
|
3
3
|
interface RequestContext {
|
|
4
4
|
origin?: string;
|
|
5
|
+
correlationId?: string;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
const storage = new AsyncLocalStorage<RequestContext>();
|
|
@@ -12,6 +13,9 @@ export const runWithRequestContext = <T>(ctx: RequestContext, fn: () => T): T =>
|
|
|
12
13
|
export const getRequestOrigin = (): string | undefined =>
|
|
13
14
|
storage.getStore()?.origin;
|
|
14
15
|
|
|
16
|
+
export const getRequestCorrelationId = (): string | undefined =>
|
|
17
|
+
storage.getStore()?.correlationId;
|
|
18
|
+
|
|
15
19
|
const parseAllowedOrigins = (): readonly string[] => {
|
|
16
20
|
const raw = process.env.CORS_ALLOWED_ORIGINS;
|
|
17
21
|
if (!raw) return [];
|
package/src/response.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
3
|
import type { Context as OpenAPIContext } from "openapi-backend";
|
|
4
|
-
import {
|
|
4
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
5
|
+
import { formatResponse, postResponseHandler } from "./response.js";
|
|
5
6
|
|
|
6
7
|
type ValidateResponseFn = (
|
|
7
8
|
response: unknown,
|
|
@@ -105,3 +106,25 @@ describe("postResponseHandler validation gating", () => {
|
|
|
105
106
|
assert.equal(result.statusCode, 200);
|
|
106
107
|
});
|
|
107
108
|
});
|
|
109
|
+
|
|
110
|
+
// A bug report quotes the correlation id off the failing response. Without the
|
|
111
|
+
// header it reads "(none)" and there is nothing to grep the server logs for.
|
|
112
|
+
describe("the correlation id travels back on the response", () => {
|
|
113
|
+
it("carries the request's id, and exposes the header to the browser", () => {
|
|
114
|
+
const result = runWithRequestContext({ correlationId: "req-604" }, () =>
|
|
115
|
+
formatResponse({ message: "Conflict" }, 409),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
assert.equal(result.headers?.["x-correlation-id"], "req-604");
|
|
119
|
+
assert.match(
|
|
120
|
+
String(result.headers?.["Access-Control-Expose-Headers"]),
|
|
121
|
+
/x-correlation-id/,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("omits the header when the request carried no id", () => {
|
|
126
|
+
const result = formatResponse({ message: "Conflict" }, 409);
|
|
127
|
+
|
|
128
|
+
assert.equal(result.headers?.["x-correlation-id"], undefined);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/response.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { logger } from "@remit/logger-lambda";
|
|
2
2
|
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
3
3
|
import type { Context as OpenAPIContext } from "openapi-backend";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getRequestCorrelationId,
|
|
6
|
+
getRequestOrigin,
|
|
7
|
+
resolveAllowedOrigin,
|
|
8
|
+
} from "./request-context.js";
|
|
5
9
|
|
|
6
10
|
export const formatResponse = (
|
|
7
11
|
body: Record<string, unknown>,
|
|
@@ -30,11 +34,23 @@ export const formatResponse = (
|
|
|
30
34
|
corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
// The id the request's log lines are already tagged with. Returning it is
|
|
38
|
+
// what makes a bug report's "correlation id" resolve to a server-side line;
|
|
39
|
+
// the browser can only read a non-safelisted header when it is exposed.
|
|
40
|
+
const correlationId = getRequestCorrelationId();
|
|
41
|
+
const correlationHeaders: Record<string, string> = correlationId
|
|
42
|
+
? {
|
|
43
|
+
"x-correlation-id": correlationId,
|
|
44
|
+
"Access-Control-Expose-Headers": "x-correlation-id",
|
|
45
|
+
}
|
|
46
|
+
: {};
|
|
47
|
+
|
|
33
48
|
return {
|
|
34
49
|
statusCode: statusCode,
|
|
35
50
|
headers: {
|
|
36
51
|
"Content-Type": "application/json",
|
|
37
52
|
...corsHeaders,
|
|
53
|
+
...correlationHeaders,
|
|
38
54
|
},
|
|
39
55
|
body: JSON.stringify(body),
|
|
40
56
|
};
|