@remit/backend 0.0.91 → 0.0.93

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.91",
3
+ "version": "0.0.93",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,568 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import type { APIGatewayProxyEvent } from "aws-lambda";
4
+ import type { Context } from "openapi-backend";
5
+ import { deriveAccountConfigId } from "../auth.js";
6
+ import { authenticateSelfHostRequest } from "../jwt-auth.js";
7
+ import {
8
+ _resetForTest,
9
+ type RemitClient,
10
+ setClient,
11
+ } from "../service/data-client.js";
12
+ import { CalendarOperations } from "./calendar.js";
13
+ import {
14
+ CalendarEventDetailOperations,
15
+ CalendarEventOperations,
16
+ CalendarFreeBusyOperations,
17
+ } from "./calendar-event.js";
18
+ import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
19
+
20
+ /**
21
+ * The event, free/busy and window wrappers driven the way an HTTP request
22
+ * drives them — through the registered client — against the SQLite store the
23
+ * self-host build ships (issue #1033).
24
+ */
25
+
26
+ type Handler = (
27
+ context: Context,
28
+ event: APIGatewayProxyEvent,
29
+ ) => Promise<Record<string, unknown>>;
30
+
31
+ const listCalendars =
32
+ CalendarOperations.CalendarOperations_listCalendars as Handler;
33
+ const createCalendar =
34
+ CalendarOperations.CalendarOperations_createCalendar as Handler;
35
+ const listEvents =
36
+ CalendarEventOperations.CalendarEventOperations_listCalendarEvents as Handler;
37
+ const createEvent =
38
+ CalendarEventOperations.CalendarEventOperations_createCalendarEvent as Handler;
39
+ const updateEvent =
40
+ CalendarEventDetailOperations.CalendarEventDetailOperations_updateCalendarEvent as Handler;
41
+ const deleteEvent =
42
+ CalendarEventDetailOperations.CalendarEventDetailOperations_deleteCalendarEvent as Handler;
43
+ const listFreeBusy =
44
+ CalendarFreeBusyOperations.CalendarFreeBusyOperations_listCalendarFreeBusy as Handler;
45
+
46
+ interface Instance {
47
+ calendarId: string;
48
+ calendarObjectId: string;
49
+ recurrenceId: string;
50
+ summary: string;
51
+ start: string;
52
+ end: string;
53
+ }
54
+
55
+ interface Span {
56
+ start: string;
57
+ end: string;
58
+ }
59
+
60
+ const WINDOW = { from: "2026-09-01T00:00:00Z", to: "2026-10-31T00:00:00Z" };
61
+
62
+ let client: RemitClient;
63
+ let cleanup: () => void;
64
+ let minted = 0;
65
+
66
+ const contextOf = (request: {
67
+ params?: Record<string, string>;
68
+ query?: Record<string, unknown>;
69
+ headers?: Record<string, string>;
70
+ requestBody?: unknown;
71
+ }): Context => ({ request }) as unknown as Context;
72
+
73
+ const eventOf = (sub: string): APIGatewayProxyEvent =>
74
+ ({
75
+ requestContext: { authorizer: { claims: { sub } } },
76
+ }) as unknown as APIGatewayProxyEvent;
77
+
78
+ /** A caller nobody else in this file shares a calendar with. */
79
+ const anAccount = (): { sub: string; event: APIGatewayProxyEvent } => {
80
+ minted += 1;
81
+ const sub = `calendar-event-sub-${minted}`;
82
+ return { sub, event: eventOf(sub) };
83
+ };
84
+
85
+ const defaultCalendarId = async (
86
+ event: APIGatewayProxyEvent,
87
+ ): Promise<string> => {
88
+ const listed = (await listEvents(
89
+ contextOf({ query: WINDOW }),
90
+ event,
91
+ )) as unknown as { items: Instance[] };
92
+ assert.ok(Array.isArray(listed.items));
93
+ const [collection] = await client.calendarCollection.listByAccountConfig(
94
+ deriveAccountConfigId(
95
+ (event.requestContext.authorizer as { claims: { sub: string } }).claims
96
+ .sub,
97
+ ),
98
+ );
99
+ assert.ok(collection);
100
+ return collection.calendarId;
101
+ };
102
+
103
+ const seedEvent = async (
104
+ event: APIGatewayProxyEvent,
105
+ input: Record<string, unknown>,
106
+ ): Promise<{ calendarObjectId: string; etag: string; icalData: string }> => {
107
+ const created = await createEvent(contextOf({ requestBody: input }), event);
108
+ assert.equal(
109
+ created.statusCode,
110
+ undefined,
111
+ `create was refused: ${JSON.stringify(created)}`,
112
+ );
113
+ return created as unknown as {
114
+ calendarObjectId: string;
115
+ etag: string;
116
+ icalData: string;
117
+ };
118
+ };
119
+
120
+ before(async () => {
121
+ _resetForTest();
122
+ ({ client, cleanup } = await createCalendarSqliteClient());
123
+ setClient(client);
124
+ });
125
+
126
+ after(() => {
127
+ _resetForTest();
128
+ cleanup();
129
+ });
130
+
131
+ describe("an unauthenticated calendar request", () => {
132
+ const routes = [
133
+ { httpMethod: "GET", path: "/calendars" },
134
+ { httpMethod: "GET", path: "/calendar-events" },
135
+ { httpMethod: "POST", path: "/calendar-events" },
136
+ { httpMethod: "GET", path: "/calendar-free-busy" },
137
+ { httpMethod: "GET", path: "/calendar-suggestions" },
138
+ ];
139
+
140
+ for (const route of routes) {
141
+ it(`is answered 401 before ${route.httpMethod} ${route.path} runs`, async () => {
142
+ const refusal = await authenticateSelfHostRequest({
143
+ ...route,
144
+ headers: {},
145
+ } as unknown as APIGatewayProxyEvent);
146
+
147
+ assert.equal(refusal?.statusCode, 401);
148
+ });
149
+ }
150
+
151
+ it("never serves a calendar to a request carrying no claims", async () => {
152
+ // The gate above is what a browser meets. This is the second lock: a
153
+ // listing reached with no identity refuses rather than falling back to
154
+ // somebody's account.
155
+ await assert.rejects(
156
+ () =>
157
+ listEvents(
158
+ contextOf({ query: WINDOW }),
159
+ {} as unknown as APIGatewayProxyEvent,
160
+ ),
161
+ /Missing accountConfigId/,
162
+ );
163
+ });
164
+ });
165
+
166
+ describe("GET /calendar-events", () => {
167
+ it("covers every collection the caller holds when it names none", async () => {
168
+ const { event } = anAccount();
169
+ const defaultId = await defaultCalendarId(event);
170
+ const work = (await createCalendar(
171
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
172
+ event,
173
+ )) as unknown as { calendarId: string };
174
+ await seedEvent(event, {
175
+ calendarId: defaultId,
176
+ summary: "Dentist",
177
+ start: "2026-09-07T09:00:00Z",
178
+ end: "2026-09-07T10:00:00Z",
179
+ });
180
+ await seedEvent(event, {
181
+ calendarId: work.calendarId,
182
+ summary: "Stand-up",
183
+ start: "2026-09-08T09:00:00Z",
184
+ end: "2026-09-08T09:15:00Z",
185
+ });
186
+
187
+ const listed = (await listEvents(
188
+ contextOf({ query: WINDOW }),
189
+ event,
190
+ )) as unknown as { items: Instance[] };
191
+
192
+ assert.deepEqual(
193
+ listed.items.map((instance) => instance.summary),
194
+ ["Dentist", "Stand-up"],
195
+ );
196
+ });
197
+
198
+ it("covers only the subset it names", async () => {
199
+ const { event } = anAccount();
200
+ const defaultId = await defaultCalendarId(event);
201
+ const work = (await createCalendar(
202
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
203
+ event,
204
+ )) as unknown as { calendarId: string };
205
+ await seedEvent(event, {
206
+ calendarId: defaultId,
207
+ summary: "Dentist",
208
+ start: "2026-09-07T09:00:00Z",
209
+ end: "2026-09-07T10:00:00Z",
210
+ });
211
+ await seedEvent(event, {
212
+ calendarId: work.calendarId,
213
+ summary: "Stand-up",
214
+ start: "2026-09-08T09:00:00Z",
215
+ end: "2026-09-08T09:15:00Z",
216
+ });
217
+
218
+ const listed = (await listEvents(
219
+ contextOf({ query: { ...WINDOW, calendarId: [work.calendarId] } }),
220
+ event,
221
+ )) as unknown as { items: Instance[] };
222
+
223
+ assert.deepEqual(
224
+ listed.items.map((instance) => instance.summary),
225
+ ["Stand-up"],
226
+ );
227
+ });
228
+
229
+ it("answers not-found for a collection on another account rather than an empty day", async () => {
230
+ // An empty list here would read as "you have nothing on", which is the one
231
+ // answer a clash check must never get wrong.
232
+ const stranger = anAccount();
233
+ const strangersCalendarId = await defaultCalendarId(stranger.event);
234
+ const { event } = anAccount();
235
+
236
+ const listed = await listEvents(
237
+ contextOf({ query: { ...WINDOW, calendarId: [strangersCalendarId] } }),
238
+ event,
239
+ );
240
+
241
+ assert.equal(listed.statusCode, 404);
242
+ assert.equal((listed.body as { code: string }).code, "NotFound");
243
+ });
244
+
245
+ it("refuses a window that runs backwards or covers more than a year", async () => {
246
+ const { event } = anAccount();
247
+
248
+ const backwards = await listEvents(
249
+ contextOf({
250
+ query: { from: "2026-09-08T00:00:00Z", to: "2026-09-07T00:00:00Z" },
251
+ }),
252
+ event,
253
+ );
254
+ const tooWide = await listEvents(
255
+ contextOf({
256
+ query: { from: "2026-01-01T00:00:00Z", to: "2027-06-01T00:00:00Z" },
257
+ }),
258
+ event,
259
+ );
260
+
261
+ assert.equal(backwards.statusCode, 400);
262
+ assert.equal((backwards.body as { code: string }).code, "InvalidWindow");
263
+ assert.equal(tooWide.statusCode, 400);
264
+ });
265
+ });
266
+
267
+ describe("POST /calendar-events", () => {
268
+ it("refuses a recurrence rule it cannot read and writes nothing", async () => {
269
+ const { event } = anAccount();
270
+ const calendarId = await defaultCalendarId(event);
271
+
272
+ const created = await createEvent(
273
+ contextOf({
274
+ requestBody: {
275
+ calendarId,
276
+ summary: "Every other Tuesday",
277
+ start: "2026-09-07T09:00:00Z",
278
+ end: "2026-09-07T10:00:00Z",
279
+ recurrenceRule: "every other tuesday",
280
+ },
281
+ }),
282
+ event,
283
+ );
284
+
285
+ assert.equal(created.statusCode, 400);
286
+ assert.equal(
287
+ (created.body as { code: string }).code,
288
+ "InvalidRecurrenceRule",
289
+ );
290
+ assert.deepEqual(
291
+ await client.calendarObject.listByCalendar(calendarId),
292
+ [],
293
+ "the refused create left the calendar empty",
294
+ );
295
+ });
296
+
297
+ it("refuses a time zone this server cannot resolve", async () => {
298
+ // A Windows zone name is what a client that has not normalised its input
299
+ // sends. Storing it would draw the event hours from where it belongs.
300
+ const { event } = anAccount();
301
+ const calendarId = await defaultCalendarId(event);
302
+
303
+ const created = await createEvent(
304
+ contextOf({
305
+ requestBody: {
306
+ calendarId,
307
+ summary: "Review",
308
+ start: "2026-09-07T09:00:00+02:00",
309
+ end: "2026-09-07T10:00:00+02:00",
310
+ timeZone: "Pacific Standard Time",
311
+ },
312
+ }),
313
+ event,
314
+ );
315
+
316
+ assert.equal(created.statusCode, 400);
317
+ assert.equal((created.body as { code: string }).code, "UnknownTimeZone");
318
+ assert.deepEqual(
319
+ await client.calendarObject.listByCalendar(calendarId),
320
+ [],
321
+ );
322
+ });
323
+ });
324
+
325
+ describe("PATCH /calendar-events/{calendarObjectId}?scope=This", () => {
326
+ it("writes a RECURRENCE-ID override and leaves the rest of the series alone", async () => {
327
+ const { event } = anAccount();
328
+ const calendarId = await defaultCalendarId(event);
329
+ const created = await seedEvent(event, {
330
+ calendarId,
331
+ summary: "Stand-up",
332
+ start: "2026-09-07T09:00:00Z",
333
+ end: "2026-09-07T09:15:00Z",
334
+ recurrenceRule: "FREQ=WEEKLY;COUNT=5",
335
+ });
336
+
337
+ const updated = await updateEvent(
338
+ contextOf({
339
+ params: { calendarObjectId: created.calendarObjectId },
340
+ query: {
341
+ calendarId,
342
+ scope: "This",
343
+ recurrenceId: "2026-09-21T09:00:00Z",
344
+ },
345
+ requestBody: { summary: "Stand-up (in the big room)" },
346
+ }),
347
+ event,
348
+ );
349
+
350
+ assert.equal(
351
+ updated.statusCode,
352
+ undefined,
353
+ `the scoped update was refused: ${JSON.stringify(updated)}`,
354
+ );
355
+ assert.match(
356
+ String(updated.icalData),
357
+ /RECURRENCE-ID[^\r\n]*:20260921T090000Z/,
358
+ "the resource carries an override for the occurrence that was edited",
359
+ );
360
+
361
+ const listed = (await listEvents(
362
+ contextOf({ query: WINDOW }),
363
+ event,
364
+ )) as unknown as { items: Instance[] };
365
+ const renamed = listed.items.filter(
366
+ (instance) => instance.summary === "Stand-up (in the big room)",
367
+ );
368
+ assert.equal(listed.items.length, 5, "the series still has five drawings");
369
+ assert.deepEqual(
370
+ renamed.map((instance) => instance.start),
371
+ ["2026-09-21T09:00:00+00:00"],
372
+ "exactly the named occurrence took the new name",
373
+ );
374
+ });
375
+ });
376
+
377
+ describe("DELETE /calendar-events/{calendarObjectId}", () => {
378
+ it("refuses a delete built on an etag the resource no longer carries", async () => {
379
+ const { event } = anAccount();
380
+ const calendarId = await defaultCalendarId(event);
381
+ const created = await seedEvent(event, {
382
+ calendarId,
383
+ summary: "Stand-up",
384
+ start: "2026-09-07T09:00:00Z",
385
+ end: "2026-09-07T09:15:00Z",
386
+ });
387
+ await updateEvent(
388
+ contextOf({
389
+ params: { calendarObjectId: created.calendarObjectId },
390
+ query: { calendarId },
391
+ requestBody: { summary: "Stand-up (renamed)" },
392
+ }),
393
+ event,
394
+ );
395
+
396
+ const removed = await deleteEvent(
397
+ contextOf({
398
+ params: { calendarObjectId: created.calendarObjectId },
399
+ query: { calendarId },
400
+ headers: { "If-Match": `"${created.etag}"` },
401
+ }),
402
+ event,
403
+ );
404
+
405
+ assert.equal(removed.statusCode, 412);
406
+ assert.equal((removed.body as { code: string }).code, "EtagMismatch");
407
+ const survivor = await client.calendarObject.find(
408
+ calendarId,
409
+ created.calendarObjectId,
410
+ );
411
+ assert.equal(
412
+ survivor?.summary,
413
+ "Stand-up (renamed)",
414
+ "the refused delete left the other writer's version in place",
415
+ );
416
+ });
417
+
418
+ it("lets the delete through once the caller has read the current etag", async () => {
419
+ const { event } = anAccount();
420
+ const calendarId = await defaultCalendarId(event);
421
+ const created = await seedEvent(event, {
422
+ calendarId,
423
+ summary: "Stand-up",
424
+ start: "2026-09-07T09:00:00Z",
425
+ end: "2026-09-07T09:15:00Z",
426
+ });
427
+
428
+ const removed = await deleteEvent(
429
+ contextOf({
430
+ params: { calendarObjectId: created.calendarObjectId },
431
+ query: { calendarId },
432
+ headers: { "if-match": `W/"${created.etag}"` },
433
+ }),
434
+ event,
435
+ );
436
+
437
+ assert.equal(removed.statusCode, 204);
438
+ assert.equal(
439
+ await client.calendarObject.find(calendarId, created.calendarObjectId),
440
+ null,
441
+ );
442
+ });
443
+ });
444
+
445
+ describe("GET /calendar-free-busy", () => {
446
+ it("merges overlapping meetings across two collections into one busy stretch", async () => {
447
+ const { event } = anAccount();
448
+ const defaultId = await defaultCalendarId(event);
449
+ const work = (await createCalendar(
450
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
451
+ event,
452
+ )) as unknown as { calendarId: string };
453
+ await seedEvent(event, {
454
+ calendarId: defaultId,
455
+ summary: "Dentist",
456
+ start: "2026-09-07T09:00:00Z",
457
+ end: "2026-09-07T10:00:00Z",
458
+ });
459
+ await seedEvent(event, {
460
+ calendarId: work.calendarId,
461
+ summary: "Review",
462
+ start: "2026-09-07T09:30:00Z",
463
+ end: "2026-09-07T11:00:00Z",
464
+ });
465
+
466
+ const busy = (await listFreeBusy(
467
+ contextOf({ query: WINDOW }),
468
+ event,
469
+ )) as unknown as { items: Span[] };
470
+
471
+ assert.deepEqual(busy.items, [
472
+ { start: "2026-09-07T09:00:00+00:00", end: "2026-09-07T11:00:00+00:00" },
473
+ ]);
474
+ });
475
+
476
+ it("renders every span with an explicit +00:00 offset", async () => {
477
+ // A busy span is an interval on the clock, not a civil date, so it never
478
+ // carries a calendar's local zone — a caller renders it in whichever zone
479
+ // it is drawing.
480
+ const { event } = anAccount();
481
+ const calendarId = await defaultCalendarId(event);
482
+ await client.calendarCollection.update(
483
+ deriveAccountConfigId(
484
+ (event.requestContext.authorizer as { claims: { sub: string } }).claims
485
+ .sub,
486
+ ),
487
+ calendarId,
488
+ { timezone: "America/New_York" },
489
+ );
490
+ await seedEvent(event, {
491
+ calendarId,
492
+ summary: "Dentist",
493
+ start: "2026-09-07T09:00:00Z",
494
+ end: "2026-09-07T10:00:00Z",
495
+ });
496
+
497
+ const busy = (await listFreeBusy(
498
+ contextOf({ query: WINDOW }),
499
+ event,
500
+ )) as unknown as { items: Span[] };
501
+
502
+ assert.deepEqual(busy.items, [
503
+ { start: "2026-09-07T09:00:00+00:00", end: "2026-09-07T10:00:00+00:00" },
504
+ ]);
505
+ });
506
+
507
+ it("leaves out an event that was never busy time", async () => {
508
+ const { event } = anAccount();
509
+ const calendarId = await defaultCalendarId(event);
510
+ await seedEvent(event, {
511
+ calendarId,
512
+ summary: "Focus block",
513
+ start: "2026-09-07T09:00:00Z",
514
+ end: "2026-09-07T10:00:00Z",
515
+ transparency: "Transparent",
516
+ });
517
+ await seedEvent(event, {
518
+ calendarId,
519
+ summary: "Cancelled review",
520
+ start: "2026-09-07T11:00:00Z",
521
+ end: "2026-09-07T12:00:00Z",
522
+ status: "Cancelled",
523
+ });
524
+
525
+ const busy = (await listFreeBusy(
526
+ contextOf({ query: WINDOW }),
527
+ event,
528
+ )) as unknown as { items: Span[] };
529
+
530
+ assert.deepEqual(busy.items, []);
531
+ });
532
+
533
+ it("refuses the same windows the event listing refuses", async () => {
534
+ const { event } = anAccount();
535
+
536
+ const missing = await listFreeBusy(
537
+ contextOf({ query: { from: "2026-09-07T00:00:00Z" } }),
538
+ event,
539
+ );
540
+ const tooWide = await listFreeBusy(
541
+ contextOf({
542
+ query: { from: "2026-01-01T00:00:00Z", to: "2027-06-01T00:00:00Z" },
543
+ }),
544
+ event,
545
+ );
546
+
547
+ assert.equal(missing.statusCode, 400);
548
+ assert.equal((missing.body as { code: string }).code, "InvalidWindow");
549
+ assert.equal(tooWide.statusCode, 400);
550
+ assert.equal((tooWide.body as { code: string }).code, "InvalidWindow");
551
+ });
552
+ });
553
+
554
+ describe("GET /calendars", () => {
555
+ it("provisions the account's default collection on a first read", async () => {
556
+ const { event } = anAccount();
557
+
558
+ const listed = (await listCalendars(contextOf({}), event)) as unknown as {
559
+ items: Array<{ urlSegment: string; source: string }>;
560
+ };
561
+
562
+ assert.deepEqual(
563
+ listed.items.map((item) => item.urlSegment),
564
+ ["default"],
565
+ );
566
+ assert.equal(listed.items[0]?.source, "Default");
567
+ });
568
+ });
@@ -0,0 +1,64 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { buildSqliteClient } from "../service/compose-sqlite.js";
6
+ import type { RemitClient } from "../service/data-client.js";
7
+
8
+ /**
9
+ * A calendar handler test's store: the backend's own SQLite composition, over
10
+ * the DDL a self-host deployment actually runs.
11
+ *
12
+ * The migrations are read rather than pushed from the drizzle table objects, so
13
+ * a handler test exercises the shipped column shapes — the same reason
14
+ * `test-shipped-sqlite-schema.ts` exists on the repository side. The client is
15
+ * built by `compose-sqlite.ts`, which is the composition root a self-host
16
+ * process boots, so nothing here is a second wiring of the calendar repos that
17
+ * could drift from the one that ships.
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.
48
+ */
49
+ export const createCalendarSqliteClient = async (): Promise<{
50
+ client: RemitClient;
51
+ cleanup: () => void;
52
+ }> => {
53
+ const directory = mkdtempSync(join(tmpdir(), "remit-calendar-handlers-"));
54
+ const path = join(directory, "remit.db");
55
+ const sqlite = new Database(path);
56
+ applyEntityMigrations(sqlite);
57
+ sqlite.close();
58
+
59
+ process.env.SQLITE_DB_PATH = path;
60
+ return {
61
+ client: await buildSqliteClient(),
62
+ cleanup: () => rmSync(directory, { recursive: true, force: true }),
63
+ };
64
+ };