@remit/backend 0.0.102 → 0.0.104
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/dev-server/oauth-callback-route.test.ts +66 -0
- package/package.json +1 -1
- package/src/derive/enrichThreadRows.test.ts +79 -0
- package/src/derive/enrichThreadRows.ts +23 -3
- package/src/derive/filterThreadCriteria.test.ts +9 -1
- package/src/handlers/account-oauth-callback.test.ts +147 -0
- package/src/handlers/account-oauth.ts +10 -5
- package/src/handlers/calendar-sqlite-fixture.ts +12 -40
- package/src/handlers/message.ts +2 -0
- package/src/response.ts +5 -4
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth callback through the dev-server's own app — the process a
|
|
3
|
+
* self-host container runs.
|
|
4
|
+
*
|
|
5
|
+
* One hop further than the handler test: the app object imported from
|
|
6
|
+
* `server.ts`, so what is asserted is the header express actually writes onto
|
|
7
|
+
* the socket for Microsoft's returning browser. The handler answering 302 is
|
|
8
|
+
* not the same claim as the browser being sent anywhere, and the difference
|
|
9
|
+
* between the two was a blank page.
|
|
10
|
+
*
|
|
11
|
+
* The identity tier is left unmounted — this app serves the route either way,
|
|
12
|
+
* and the callback's exemption from the session gate is pinned where that gate
|
|
13
|
+
* runs, in `src/handlers/account-oauth-callback.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import type { Server } from "node:http";
|
|
18
|
+
import { after, before, describe, it } from "node:test";
|
|
19
|
+
import {
|
|
20
|
+
_resetForTest,
|
|
21
|
+
type RemitClient,
|
|
22
|
+
setClient,
|
|
23
|
+
} from "../src/service/data-client.js";
|
|
24
|
+
|
|
25
|
+
const WEB_ORIGIN = "https://mail.example.com";
|
|
26
|
+
|
|
27
|
+
let server: Server;
|
|
28
|
+
let port: number;
|
|
29
|
+
|
|
30
|
+
before(async () => {
|
|
31
|
+
// Microsoft's redirect carries no session, so the callback answers before it
|
|
32
|
+
// reads anything: no store is touched on this path.
|
|
33
|
+
setClient({} as unknown as RemitClient);
|
|
34
|
+
|
|
35
|
+
process.env.BETTER_AUTH_SECRET = "a-signing-secret-of-at-least-32-characters";
|
|
36
|
+
process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
|
|
37
|
+
process.env.SERVER_PORT = "0";
|
|
38
|
+
|
|
39
|
+
const imported = await import("./server.js");
|
|
40
|
+
server = imported.listener;
|
|
41
|
+
if (!server.listening) {
|
|
42
|
+
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
43
|
+
}
|
|
44
|
+
const address = server.address();
|
|
45
|
+
port = typeof address === "object" && address ? address.port : 0;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
after(async () => {
|
|
49
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
50
|
+
_resetForTest();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("GET /accounts/oauth/microsoft/callback through the dev-server's own app", () => {
|
|
54
|
+
it("answers the browser with somewhere to go", async () => {
|
|
55
|
+
const response = await fetch(
|
|
56
|
+
`http://127.0.0.1:${port}/accounts/oauth/microsoft/callback?error=access_denied`,
|
|
57
|
+
{ redirect: "manual" },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
assert.equal(response.status, 302);
|
|
61
|
+
assert.equal(
|
|
62
|
+
response.headers.get("location"),
|
|
63
|
+
`${WEB_ORIGIN}/settings/accounts?oauthError=access_denied`,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/package.json
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
ThreadMessageItem,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
11
|
+
import { hasAbandonedDelete } from "@remit/data-ports/message-settlement";
|
|
12
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
11
13
|
import { type EnrichClient, enrichThreadRows } from "./enrichThreadRows.js";
|
|
12
14
|
|
|
13
15
|
const threadRow = (
|
|
@@ -250,3 +252,80 @@ describe("enrichThreadRows — muted", () => {
|
|
|
250
252
|
assert.equal(result?.muted, false);
|
|
251
253
|
});
|
|
252
254
|
});
|
|
255
|
+
|
|
256
|
+
describe("enrichThreadRows — the mutation pair", () => {
|
|
257
|
+
const withMessage = (message: Partial<MessageItem>): EnrichClient => ({
|
|
258
|
+
message: {
|
|
259
|
+
get: async () =>
|
|
260
|
+
[{ messageId: "msg-1", ...message }] as unknown as MessageItem[],
|
|
261
|
+
},
|
|
262
|
+
address: { getAddress: async () => [] },
|
|
263
|
+
messageLabel: { listByMessageIds: async () => [] },
|
|
264
|
+
label: { listByAccountConfig: async () => [] },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const project = async (message: Partial<MessageItem>) => {
|
|
268
|
+
const [result] = await enrichThreadRows(
|
|
269
|
+
[threadRow("tm-1", "msg-1")],
|
|
270
|
+
withMessage(message),
|
|
271
|
+
"acc-1",
|
|
272
|
+
);
|
|
273
|
+
return result;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
test("projects the pair an abandoned delete leaves behind", async () => {
|
|
277
|
+
const result = await project({
|
|
278
|
+
status: MessageStatus.active,
|
|
279
|
+
syncStatus: MessageSyncStatus.failed,
|
|
280
|
+
});
|
|
281
|
+
assert.equal(result?.status, MessageStatus.active);
|
|
282
|
+
assert.equal(result?.syncStatus, MessageSyncStatus.failed);
|
|
283
|
+
assert.equal(
|
|
284
|
+
hasAbandonedDelete({
|
|
285
|
+
status: result?.status,
|
|
286
|
+
syncStatus: result?.syncStatus,
|
|
287
|
+
}),
|
|
288
|
+
true,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("projects a move mid-retry without calling it a give-up", async () => {
|
|
293
|
+
const result = await project({
|
|
294
|
+
status: MessageStatus.moving,
|
|
295
|
+
syncStatus: MessageSyncStatus.failed,
|
|
296
|
+
});
|
|
297
|
+
assert.equal(result?.status, MessageStatus.moving);
|
|
298
|
+
assert.equal(result?.syncStatus, MessageSyncStatus.failed);
|
|
299
|
+
assert.equal(
|
|
300
|
+
hasAbandonedDelete({
|
|
301
|
+
status: result?.status,
|
|
302
|
+
syncStatus: result?.syncStatus,
|
|
303
|
+
}),
|
|
304
|
+
false,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("projects an ordinary inbound row", async () => {
|
|
309
|
+
const result = await project({
|
|
310
|
+
status: MessageStatus.active,
|
|
311
|
+
syncStatus: MessageSyncStatus.pending,
|
|
312
|
+
});
|
|
313
|
+
assert.equal(result?.status, MessageStatus.active);
|
|
314
|
+
assert.equal(result?.syncStatus, MessageSyncStatus.pending);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("a row whose Message row is missing claims no mutation", async () => {
|
|
318
|
+
const [result] = await enrichThreadRows(
|
|
319
|
+
[threadRow("tm-1", "msg-1")],
|
|
320
|
+
buildClient([], []),
|
|
321
|
+
"acc-1",
|
|
322
|
+
);
|
|
323
|
+
assert.equal(
|
|
324
|
+
hasAbandonedDelete({
|
|
325
|
+
status: result?.status,
|
|
326
|
+
syncStatus: result?.syncStatus,
|
|
327
|
+
}),
|
|
328
|
+
false,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -7,7 +7,12 @@ import type {
|
|
|
7
7
|
ThreadMessageItem,
|
|
8
8
|
} from "@remit/data-ports";
|
|
9
9
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
MessageStatus,
|
|
12
|
+
MessageSyncStatus,
|
|
13
|
+
SenderTrust,
|
|
14
|
+
StarColor,
|
|
15
|
+
} from "@remit/domain-enums";
|
|
11
16
|
import { deriveAutoMoved } from "./autoMoved.js";
|
|
12
17
|
import { deriveMuted } from "./deriveMuted.js";
|
|
13
18
|
import { deriveSenderTrust } from "./senderTrust.js";
|
|
@@ -56,6 +61,8 @@ const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
|
|
|
56
61
|
updatedAt: item.updatedAt,
|
|
57
62
|
senderTrust: SenderTrust.Unknown,
|
|
58
63
|
muted: false,
|
|
64
|
+
status: MessageStatus.active,
|
|
65
|
+
syncStatus: MessageSyncStatus.pending,
|
|
59
66
|
});
|
|
60
67
|
|
|
61
68
|
/**
|
|
@@ -102,8 +109,13 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
|
|
|
102
109
|
/**
|
|
103
110
|
* Enrich a page of ThreadMessage rows with `senderTrust` and `muted` (both
|
|
104
111
|
* derived from the From Address's flags map), `authenticity`, `autoMoved` and
|
|
105
|
-
* `spamReport
|
|
106
|
-
*
|
|
112
|
+
* `spamReport`, and the `status`/`syncStatus` pair an IMAP mutation leaves
|
|
113
|
+
* behind (all projected straight from the Message row — no ThreadMessage
|
|
114
|
+
* column of their own, see `deriveAutoMoved`). `toResponse` seeds that pair
|
|
115
|
+
* with the ordinary inbound values, which the projection then overwrites; a
|
|
116
|
+
* ThreadMessage row whose Message row is missing from the batch is broken in a
|
|
117
|
+
* way this read path cannot report, and claiming a mutation it has no evidence
|
|
118
|
+
* of would be worse.
|
|
107
119
|
*
|
|
108
120
|
* `category` is not enriched: it is denormalized onto the ThreadMessage row
|
|
109
121
|
* (shared with `Message.category`'s write-once value, see body-sync.ts) and
|
|
@@ -174,6 +186,12 @@ export const enrichThreadRows = async (
|
|
|
174
186
|
const spamReportByMessageId = new Map(
|
|
175
187
|
messages.map((m) => [m.messageId, m.spamReport]),
|
|
176
188
|
);
|
|
189
|
+
const settlementByMessageId = new Map(
|
|
190
|
+
messages.map((m) => [
|
|
191
|
+
m.messageId,
|
|
192
|
+
{ status: m.status, syncStatus: m.syncStatus },
|
|
193
|
+
]),
|
|
194
|
+
);
|
|
177
195
|
const trustByAddressId = new Map(
|
|
178
196
|
addresses.map((a) => [a.addressId, deriveSenderTrust(a.flags)]),
|
|
179
197
|
);
|
|
@@ -194,8 +212,10 @@ export const enrichThreadRows = async (
|
|
|
194
212
|
? (mutedByAddressId.get(addressId) ?? false)
|
|
195
213
|
: false;
|
|
196
214
|
const labels = labelsByMessageId.get(row.messageId);
|
|
215
|
+
const settlement = settlementByMessageId.get(row.messageId);
|
|
197
216
|
return {
|
|
198
217
|
...base,
|
|
218
|
+
...(settlement ?? {}),
|
|
199
219
|
...(authenticity !== undefined ? { authenticity } : {}),
|
|
200
220
|
...(autoMoved !== undefined ? { autoMoved } : {}),
|
|
201
221
|
...(labels !== undefined ? { labels } : {}),
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { ThreadMessageResponse } from "@remit/api-openapi-types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
MessageCategory,
|
|
6
|
+
MessageStatus,
|
|
7
|
+
MessageSyncStatus,
|
|
8
|
+
SenderTrust,
|
|
9
|
+
StarColor,
|
|
10
|
+
} from "@remit/domain-enums";
|
|
5
11
|
import {
|
|
6
12
|
filterByOffRowCriteria,
|
|
7
13
|
hasOffRowCriteria,
|
|
@@ -30,6 +36,8 @@ const row = (
|
|
|
30
36
|
updatedAt: 0,
|
|
31
37
|
senderTrust: SenderTrust.Unknown,
|
|
32
38
|
muted: false,
|
|
39
|
+
status: MessageStatus.active,
|
|
40
|
+
syncStatus: MessageSyncStatus.pending,
|
|
33
41
|
...overrides,
|
|
34
42
|
});
|
|
35
43
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser Microsoft sends back, and where it ends up.
|
|
3
|
+
*
|
|
4
|
+
* The callback answers a redirect, so the whole response is its Location
|
|
5
|
+
* header: an answer that arrives without it is a blank page with the account
|
|
6
|
+
* either connected or not and no way to tell. Driven through the real OpenAPI
|
|
7
|
+
* document and the self-host request path, so what is asserted is what a
|
|
8
|
+
* self-hosted deployment writes onto the wire — the header included, which a
|
|
9
|
+
* handler returning a bare `{statusCode, headers}` shape lost on the way out.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { createServer, type Server } from "node:http";
|
|
15
|
+
import type { AddressInfo } from "node:net";
|
|
16
|
+
import { after, before, describe, it } from "node:test";
|
|
17
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
18
|
+
import {
|
|
19
|
+
_resetForTest,
|
|
20
|
+
type RemitClient,
|
|
21
|
+
setClient,
|
|
22
|
+
} from "../service/data-client.js";
|
|
23
|
+
import { signState } from "./account-oauth.js";
|
|
24
|
+
import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
|
|
25
|
+
|
|
26
|
+
const CLIENT_SECRET = "callback-test-client-secret";
|
|
27
|
+
const WEB_ORIGIN = "https://mail.example.com";
|
|
28
|
+
const CALLBACK_PATH = "/accounts/oauth/microsoft/callback";
|
|
29
|
+
|
|
30
|
+
let handler: (
|
|
31
|
+
event: APIGatewayProxyEvent,
|
|
32
|
+
context: unknown,
|
|
33
|
+
) => Promise<APIGatewayProxyResult>;
|
|
34
|
+
let client: RemitClient;
|
|
35
|
+
let cleanup: () => void;
|
|
36
|
+
let tokenEndpoint: Server;
|
|
37
|
+
|
|
38
|
+
const jwt = (claims: Record<string, unknown>): string =>
|
|
39
|
+
[
|
|
40
|
+
Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url"),
|
|
41
|
+
Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"),
|
|
42
|
+
"signature-not-checked-here",
|
|
43
|
+
].join(".");
|
|
44
|
+
|
|
45
|
+
/** Microsoft's token endpoint, close enough for the one call the callback makes. */
|
|
46
|
+
const startTokenEndpoint = async (): Promise<string> => {
|
|
47
|
+
tokenEndpoint = createServer((_request, response) => {
|
|
48
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
49
|
+
response.end(
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
access_token: "access-token",
|
|
52
|
+
refresh_token: "refresh-token",
|
|
53
|
+
id_token: jwt({ preferred_username: "matthijs@example.com" }),
|
|
54
|
+
expires_in: 3600,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
await new Promise<void>((resolve) => tokenEndpoint.listen(0, resolve));
|
|
59
|
+
const { port } = tokenEndpoint.address() as AddressInfo;
|
|
60
|
+
return `http://127.0.0.1:${port}/token`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const callback = (
|
|
64
|
+
query: Record<string, string>,
|
|
65
|
+
): Promise<APIGatewayProxyResult> =>
|
|
66
|
+
handler(
|
|
67
|
+
{
|
|
68
|
+
httpMethod: "GET",
|
|
69
|
+
path: CALLBACK_PATH,
|
|
70
|
+
headers: {},
|
|
71
|
+
queryStringParameters: query,
|
|
72
|
+
body: null,
|
|
73
|
+
requestContext: {},
|
|
74
|
+
} as unknown as APIGatewayProxyEvent,
|
|
75
|
+
{ awsRequestId: `req-${randomUUID()}`, functionName: "test" },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const headerOf = (
|
|
79
|
+
response: APIGatewayProxyResult,
|
|
80
|
+
name: string,
|
|
81
|
+
): string | undefined => {
|
|
82
|
+
for (const [key, value] of Object.entries(response.headers ?? {})) {
|
|
83
|
+
if (key.toLowerCase() === name) return String(value);
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
before(async () => {
|
|
89
|
+
_resetForTest();
|
|
90
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
91
|
+
process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
|
|
92
|
+
process.env.MSOAUTH_REDIRECT_URI = `${WEB_ORIGIN}${CALLBACK_PATH}`;
|
|
93
|
+
process.env.MSOAUTH_CLIENT_ID = "callback-test-client-id";
|
|
94
|
+
process.env.MSOAUTH_CLIENT_SECRET = CLIENT_SECRET;
|
|
95
|
+
process.env.MSOAUTH_TOKEN_ENDPOINT = await startTokenEndpoint();
|
|
96
|
+
// The self-host key that encrypts the stored refresh token.
|
|
97
|
+
process.env.FAKE_KMS_DATAKEY = "0".repeat(64);
|
|
98
|
+
// Static credentials so the best-effort sync enqueue fails against the local
|
|
99
|
+
// queue instead of walking the instance-metadata chain for a minute.
|
|
100
|
+
process.env.AWS_REGION = "eu-west-1";
|
|
101
|
+
process.env.AWS_ACCESS_KEY_ID = "test";
|
|
102
|
+
process.env.AWS_SECRET_ACCESS_KEY = "test";
|
|
103
|
+
({ client, cleanup } = await createCalendarSqliteClient());
|
|
104
|
+
setClient(client);
|
|
105
|
+
({ handler } = (await import("../index.js")) as unknown as {
|
|
106
|
+
handler: typeof handler;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
after(async () => {
|
|
111
|
+
_resetForTest();
|
|
112
|
+
cleanup();
|
|
113
|
+
await new Promise<void>((resolve) => tokenEndpoint.close(() => resolve()));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("the Microsoft OAuth callback", () => {
|
|
117
|
+
it("sends the consenting browser on to the connected account", async () => {
|
|
118
|
+
const accountConfigId = randomUUID();
|
|
119
|
+
const state = await signState(
|
|
120
|
+
{ accountConfigId, nonce: "nonce", timestamp: Date.now() },
|
|
121
|
+
CLIENT_SECRET,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const response = await callback({ code: "auth-code", state });
|
|
125
|
+
|
|
126
|
+
assert.equal(response.statusCode, 302, response.body);
|
|
127
|
+
const location = headerOf(response, "location");
|
|
128
|
+
assert.ok(location, "a redirect without a Location header is a blank page");
|
|
129
|
+
const [connected] =
|
|
130
|
+
await client.account.listAllByAccountConfig(accountConfigId);
|
|
131
|
+
assert.ok(connected, "the callback stored the account it redirects to");
|
|
132
|
+
assert.equal(
|
|
133
|
+
location,
|
|
134
|
+
`${WEB_ORIGIN}/settings/accounts?connected=${encodeURIComponent(connected.accountId)}`,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("sends a refused consent back with the reason Microsoft gave", async () => {
|
|
139
|
+
const response = await callback({ error: "access_denied" });
|
|
140
|
+
|
|
141
|
+
assert.equal(response.statusCode, 302, response.body);
|
|
142
|
+
assert.equal(
|
|
143
|
+
headerOf(response, "location"),
|
|
144
|
+
`${WEB_ORIGIN}/settings/accounts?oauthError=access_denied`,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -16,6 +16,7 @@ import type { Context } from "openapi-backend";
|
|
|
16
16
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
17
17
|
import { getMsOAuthConfig } from "../config/msoauth.js";
|
|
18
18
|
import { safeJsonParse } from "../json.js";
|
|
19
|
+
import { rawApiResponse } from "../response.js";
|
|
19
20
|
import { getClient } from "../service/data-client.js";
|
|
20
21
|
import type { MicrosoftOAuthOperationIds, OperationHandler } from "../types.js";
|
|
21
22
|
import { triggerAccountSyncSafe } from "./account.js";
|
|
@@ -228,11 +229,15 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
228
229
|
const webOrigin = getWebOrigin();
|
|
229
230
|
const qs = event.queryStringParameters ?? {};
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
// Marked raw so the response formatter forwards the Location header
|
|
233
|
+
// instead of serializing this object as a JSON body. Microsoft's browser
|
|
234
|
+
// redirect lands here, so a dropped Location is a blank page.
|
|
235
|
+
const redirect = (url: string): APIGatewayProxyResult =>
|
|
236
|
+
rawApiResponse({
|
|
237
|
+
statusCode: 302,
|
|
238
|
+
headers: { Location: url },
|
|
239
|
+
body: "",
|
|
240
|
+
});
|
|
236
241
|
|
|
237
242
|
// If Microsoft returned an error, pass it through to the frontend
|
|
238
243
|
if (qs.error) {
|
|
@@ -1,50 +1,22 @@
|
|
|
1
|
-
import { mkdtempSync,
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { applyShippedMigrations } from "@remit/drizzle-service/test-sqlite";
|
|
4
5
|
import Database from "better-sqlite3";
|
|
5
6
|
import { buildSqliteClient } from "../service/compose-sqlite.js";
|
|
6
7
|
import type { RemitClient } from "../service/data-client.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* A
|
|
10
|
-
*
|
|
10
|
+
* A handler test's store: the backend's own SQLite composition, over the DDL a
|
|
11
|
+
* self-host deployment actually runs.
|
|
11
12
|
*
|
|
12
|
-
* The migrations are
|
|
13
|
-
* a handler test exercises the shipped column shapes
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
const MIGRATIONS = new URL(
|
|
20
|
-
"../../../../deploy/vps/migrations-sqlite/entities/",
|
|
21
|
-
import.meta.url,
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
interface Journal {
|
|
25
|
-
entries: Array<{ idx: number; tag: string }>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const applyEntityMigrations = (sqlite: Database.Database): void => {
|
|
29
|
-
const journal = JSON.parse(
|
|
30
|
-
readFileSync(new URL("meta/_journal.json", MIGRATIONS), "utf8"),
|
|
31
|
-
) as Journal;
|
|
32
|
-
const ordered = [...journal.entries].sort(
|
|
33
|
-
(left, right) => left.idx - right.idx,
|
|
34
|
-
);
|
|
35
|
-
for (const entry of ordered) {
|
|
36
|
-
const sql = readFileSync(new URL(`${entry.tag}.sql`, MIGRATIONS), "utf8");
|
|
37
|
-
for (const statement of sql.split("--> statement-breakpoint")) {
|
|
38
|
-
if (statement.trim() === "") continue;
|
|
39
|
-
sqlite.exec(statement);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* A migrated database and the client that reads it. One per test file: every
|
|
46
|
-
* calendar row is scoped by account config, so a test that mints its own
|
|
47
|
-
* account config sees only what it wrote.
|
|
13
|
+
* The migrations are applied rather than pushed from the drizzle table objects,
|
|
14
|
+
* so a handler test exercises the shipped column shapes. The client is built by
|
|
15
|
+
* `compose-sqlite.ts`, the composition root a self-host process boots, so
|
|
16
|
+
* nothing here is a second wiring that could drift from the one that ships.
|
|
17
|
+
*
|
|
18
|
+
* One per test file: every row is scoped by account config, so a test that
|
|
19
|
+
* mints its own account config sees only what it wrote.
|
|
48
20
|
*/
|
|
49
21
|
export const createCalendarSqliteClient = async (): Promise<{
|
|
50
22
|
client: RemitClient;
|
|
@@ -53,7 +25,7 @@ export const createCalendarSqliteClient = async (): Promise<{
|
|
|
53
25
|
const directory = mkdtempSync(join(tmpdir(), "remit-calendar-handlers-"));
|
|
54
26
|
const path = join(directory, "remit.db");
|
|
55
27
|
const sqlite = new Database(path);
|
|
56
|
-
|
|
28
|
+
applyShippedMigrations(sqlite);
|
|
57
29
|
sqlite.close();
|
|
58
30
|
|
|
59
31
|
process.env.SQLITE_DB_PATH = path;
|
package/src/handlers/message.ts
CHANGED
|
@@ -454,6 +454,8 @@ export const MessageOperations: Record<
|
|
|
454
454
|
internalDate: message.internalDate,
|
|
455
455
|
messageIdHeader: message.messageIdHeader,
|
|
456
456
|
authenticity: message.authenticity,
|
|
457
|
+
status: message.status,
|
|
458
|
+
syncStatus: message.syncStatus,
|
|
457
459
|
...(autoMoved ? { autoMoved } : {}),
|
|
458
460
|
...(labels.length > 0 ? { labels } : {}),
|
|
459
461
|
...(message.spamReport ? { spamReport: message.spamReport } : {}),
|
package/src/response.ts
CHANGED
|
@@ -10,13 +10,14 @@ import {
|
|
|
10
10
|
const RAW_API_RESPONSE: unique symbol = Symbol("remit.rawApiResponse");
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* A response
|
|
14
|
-
* joins
|
|
13
|
+
* A response the caller reads by something other than its JSON body — an
|
|
14
|
+
* iCalendar feed, an OAuth redirect's Location, and whatever later joins them.
|
|
15
15
|
*
|
|
16
16
|
* Every other handler returns a plain object and lets this module decide the
|
|
17
17
|
* status, the content type and the serialization. That is the right default and
|
|
18
|
-
* stays the default; a handler that has to own its own media type
|
|
19
|
-
* `rawApiResponse` rather than by returning a shape this one has
|
|
18
|
+
* stays the default; a handler that has to own its own media type or headers
|
|
19
|
+
* says so with `rawApiResponse` rather than by returning a shape this one has
|
|
20
|
+
* to guess at.
|
|
20
21
|
* The CORS and correlation headers are still added here, so a raw response is
|
|
21
22
|
* not a way around them.
|
|
22
23
|
*/
|