@remit/backend 0.0.95 → 0.0.96

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.95",
3
+ "version": "0.0.96",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Issue #1067: which timestamp a feed's validators are taken from.
3
+ *
4
+ * Driven against a store rather than through the API, because the decision only
5
+ * shows when the collection and the events in it carry times a test running
6
+ * inside one second cannot produce: here the calendar is stamped an hour after
7
+ * its newest surviving event, which is the shape a delete leaves behind.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import {
13
+ hashCalendarFeedToken,
14
+ mintCalendarFeedToken,
15
+ putCalendarObject,
16
+ } from "@remit/calendar-service";
17
+ import { MemoryCalendarStore } from "@remit/calendar-service/memory-store";
18
+ import type { CalendarDeps } from "./calendar.js";
19
+ import { serveCalendarFeed } from "./calendar-feed.js";
20
+
21
+ const ACCOUNT_CONFIG_ID = "feed-validators-account";
22
+ const EVENT_WRITTEN_AT = Date.UTC(2026, 8, 7, 9, 0, 0);
23
+ const CALENDAR_TOUCHED_AT = Date.UTC(2026, 8, 7, 10, 0, 0);
24
+
25
+ const ICAL = [
26
+ "BEGIN:VCALENDAR",
27
+ "VERSION:2.0",
28
+ "PRODID:-//test//test//EN",
29
+ "BEGIN:VEVENT",
30
+ "UID:kept@test",
31
+ "DTSTAMP:20260907T090000Z",
32
+ "DTSTART:20260907T090000Z",
33
+ "DTEND:20260907T100000Z",
34
+ "SUMMARY:Kept",
35
+ "END:VEVENT",
36
+ "END:VCALENDAR",
37
+ ].join("\r\n");
38
+
39
+ const depsOf = (store: MemoryCalendarStore): Promise<CalendarDeps> =>
40
+ store.transaction(async (repos) => ({
41
+ ...repos,
42
+ calendarUnitOfWork: store,
43
+ }));
44
+
45
+ const tokenFor = async (
46
+ store: MemoryCalendarStore,
47
+ calendarId: string,
48
+ ): Promise<string> => {
49
+ const minted = mintCalendarFeedToken();
50
+ await store.transaction(async (repos) =>
51
+ repos.calendarFeedToken.put({
52
+ accountConfigId: ACCOUNT_CONFIG_ID,
53
+ calendarId,
54
+ tokenHash: minted.tokenHash,
55
+ }),
56
+ );
57
+ return minted.token;
58
+ };
59
+
60
+ interface Fixture {
61
+ deps: CalendarDeps;
62
+ token: string;
63
+ readonly listedCollections: number;
64
+ }
65
+
66
+ const fixture = async (): Promise<Fixture> => {
67
+ const store = new MemoryCalendarStore();
68
+ const deps = await depsOf(store);
69
+
70
+ const collection = await store.transaction(async (repos) =>
71
+ repos.calendarCollection.create({
72
+ accountConfigId: ACCOUNT_CONFIG_ID,
73
+ urlSegment: "work",
74
+ displayName: "Work",
75
+ }),
76
+ );
77
+
78
+ const written = await putCalendarObject(store, {
79
+ accountConfigId: ACCOUNT_CONFIG_ID,
80
+ calendarId: collection.calendarId,
81
+ resourceName: "kept.ics",
82
+ icalData: ICAL,
83
+ });
84
+ assert.ok(written.ok, "the fixture's own event has to be storable");
85
+
86
+ // The shape a delete leaves: the calendar was stamped by the delete, and every
87
+ // event still in it is older than that.
88
+ store.objects.set(written.value.calendarObjectId, {
89
+ ...written.value,
90
+ updatedAt: EVENT_WRITTEN_AT,
91
+ });
92
+ const bumped = store.collections.get(collection.calendarId);
93
+ assert.ok(bumped, "the write should have stamped the collection");
94
+ store.collections.set(collection.calendarId, {
95
+ ...bumped,
96
+ updatedAt: CALENDAR_TOUCHED_AT,
97
+ });
98
+
99
+ const token = await tokenFor(store, collection.calendarId);
100
+
101
+ let listedCollections = 0;
102
+ const counted: CalendarDeps = {
103
+ ...deps,
104
+ calendarCollection: {
105
+ ...deps.calendarCollection,
106
+ listByAccountConfig: async (accountConfigId: string) => {
107
+ listedCollections += 1;
108
+ return deps.calendarCollection.listByAccountConfig(accountConfigId);
109
+ },
110
+ },
111
+ };
112
+
113
+ return {
114
+ deps: counted,
115
+ token,
116
+ get listedCollections() {
117
+ return listedCollections;
118
+ },
119
+ };
120
+ };
121
+
122
+ const NO_CONDITIONS = { ifNoneMatch: undefined, ifModifiedSince: undefined };
123
+
124
+ describe("the validators a feed serves", () => {
125
+ it("dates the calendar by its own last change, not by its newest event", async () => {
126
+ const { deps, token } = await fixture();
127
+
128
+ const served = await serveCalendarFeed(deps, token, NO_CONDITIONS);
129
+
130
+ assert.equal(served.statusCode, 200);
131
+ assert.match(served.body, /SUMMARY:Kept/);
132
+ assert.equal(
133
+ served.headers["Last-Modified"],
134
+ new Date(CALENDAR_TOUCHED_AT).toUTCString(),
135
+ "a delete takes the newest event, so every survivor is older than the change",
136
+ );
137
+ });
138
+
139
+ it("is unchanged for the date it served and changed for the one before it", async () => {
140
+ const { deps, token } = await fixture();
141
+ const served = await serveCalendarFeed(deps, token, NO_CONDITIONS);
142
+ const lastModified = served.headers["Last-Modified"];
143
+
144
+ const unchanged = await serveCalendarFeed(deps, token, {
145
+ ifNoneMatch: undefined,
146
+ ifModifiedSince: lastModified,
147
+ });
148
+ const stale = await serveCalendarFeed(deps, token, {
149
+ ifNoneMatch: undefined,
150
+ ifModifiedSince: new Date(CALENDAR_TOUCHED_AT - 1000).toUTCString(),
151
+ });
152
+
153
+ assert.equal(unchanged.statusCode, 304);
154
+ assert.equal(unchanged.body, "");
155
+ assert.equal(unchanged.headers["Last-Modified"], lastModified);
156
+ assert.equal(unchanged.headers.ETag, served.headers.ETag);
157
+ assert.equal(stale.statusCode, 200);
158
+ });
159
+
160
+ it("lets a tag that arrives settle the answer on its own", async () => {
161
+ // RFC 9110 13.2.2: the date is not consulted when a tag is present, so a
162
+ // tag nobody served re-sends the calendar however current the date is.
163
+ const { deps, token } = await fixture();
164
+ const served = await serveCalendarFeed(deps, token, NO_CONDITIONS);
165
+
166
+ const overridden = await serveCalendarFeed(deps, token, {
167
+ ifNoneMatch: '"not-the-tag"',
168
+ ifModifiedSince: served.headers["Last-Modified"],
169
+ });
170
+
171
+ assert.equal(overridden.statusCode, 200);
172
+ assert.match(overridden.body, /SUMMARY:Kept/);
173
+ });
174
+
175
+ it("reads the one collection it needs rather than every collection there is", async () => {
176
+ const found = await fixture();
177
+
178
+ await serveCalendarFeed(found.deps, found.token, NO_CONDITIONS);
179
+
180
+ assert.equal(
181
+ found.listedCollections,
182
+ 0,
183
+ "a feed poll is the hot path and the token already names its collection",
184
+ );
185
+ });
186
+
187
+ it("is absent when the token outlives the calendar it named", async () => {
188
+ const store = new MemoryCalendarStore();
189
+ const deps = await depsOf(store);
190
+ const token = await tokenFor(store, "a-calendar-that-is-gone");
191
+
192
+ const served = await serveCalendarFeed(deps, token, NO_CONDITIONS);
193
+
194
+ assert.equal(served.statusCode, 404);
195
+ assert.equal(
196
+ served.body.includes("BEGIN:VCALENDAR"),
197
+ false,
198
+ "a blank calendar would be worse than saying nothing",
199
+ );
200
+ });
201
+
202
+ it("is absent for an unknown, a malformed and an oversize token alike", async () => {
203
+ const store = new MemoryCalendarStore();
204
+ const deps = await depsOf(store);
205
+
206
+ const unknown = await serveCalendarFeed(
207
+ deps,
208
+ mintCalendarFeedToken().token,
209
+ NO_CONDITIONS,
210
+ );
211
+ const malformed = await serveCalendarFeed(deps, "nope", NO_CONDITIONS);
212
+ const oversize = await serveCalendarFeed(
213
+ deps,
214
+ "z".repeat(600),
215
+ NO_CONDITIONS,
216
+ );
217
+
218
+ for (const served of [unknown, malformed, oversize]) {
219
+ assert.equal(served.statusCode, 404);
220
+ assert.equal(served.body, unknown.body);
221
+ }
222
+ assert.equal(
223
+ hashCalendarFeedToken("nope").length,
224
+ 64,
225
+ "the digest is what the store is asked about, never the token",
226
+ );
227
+ });
228
+ });
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Issue #1067: subscribing to a calendar by its secret .ics address.
3
+ *
4
+ * Driven through the real OpenAPI document, so what is asserted is the routing,
5
+ * the status code, the headers and the bytes a subscribed Apple Calendar or
6
+ * Thunderbird receives — including that the feed answers with no session while
7
+ * the management sub-resource refuses without one. The store is the SQLite
8
+ * composition a self-host deployment boots, over the committed migrations.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { after, before, describe, it } from "node:test";
13
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
14
+ import {
15
+ _resetForTest,
16
+ type RemitClient,
17
+ setClient,
18
+ } from "../service/data-client.js";
19
+ import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
20
+
21
+ let handler: (
22
+ event: APIGatewayProxyEvent,
23
+ context: unknown,
24
+ ) => Promise<APIGatewayProxyResult>;
25
+ let client: RemitClient;
26
+ let cleanup: () => void;
27
+ let mintedAccounts = 0;
28
+
29
+ interface Requested {
30
+ method: string;
31
+ path: string;
32
+ sub?: string;
33
+ body?: Record<string, unknown>;
34
+ headers?: Record<string, string>;
35
+ query?: Record<string, string>;
36
+ }
37
+
38
+ const request = async ({
39
+ method,
40
+ path,
41
+ sub,
42
+ body,
43
+ headers,
44
+ query,
45
+ }: Requested): Promise<APIGatewayProxyResult> => {
46
+ const event = {
47
+ httpMethod: method,
48
+ path,
49
+ headers: headers ?? {},
50
+ queryStringParameters: query ?? null,
51
+ body: body === undefined ? null : JSON.stringify(body),
52
+ requestContext: sub ? { authorizer: { claims: { sub } } } : {},
53
+ } as unknown as APIGatewayProxyEvent;
54
+
55
+ return handler(event, { awsRequestId: `req-${path}`, functionName: "test" });
56
+ };
57
+
58
+ const json = (response: APIGatewayProxyResult): Record<string, unknown> =>
59
+ JSON.parse(response.body) as Record<string, unknown>;
60
+
61
+ const headerOf = (
62
+ response: APIGatewayProxyResult,
63
+ name: string,
64
+ ): string | undefined => {
65
+ for (const [key, value] of Object.entries(response.headers ?? {})) {
66
+ if (key.toLowerCase() === name) return String(value);
67
+ }
68
+ return undefined;
69
+ };
70
+
71
+ /** One caller, their calendar, and an event in it. */
72
+ class Subscriber {
73
+ readonly sub: string;
74
+
75
+ constructor() {
76
+ mintedAccounts += 1;
77
+ this.sub = `calendar-feed-sub-${mintedAccounts}`;
78
+ }
79
+
80
+ async calendar(displayName = "Work"): Promise<string> {
81
+ const created = await request({
82
+ method: "POST",
83
+ path: "/calendars",
84
+ sub: this.sub,
85
+ body: { urlSegment: `work-${mintedAccounts}`, displayName },
86
+ });
87
+ assert.equal(created.statusCode, 200, created.body);
88
+ return json(created).calendarId as string;
89
+ }
90
+
91
+ async event(calendarId: string, summary: string, recurrenceRule = "") {
92
+ const created = await request({
93
+ method: "POST",
94
+ path: "/calendar-events",
95
+ sub: this.sub,
96
+ body: {
97
+ calendarId,
98
+ summary,
99
+ start: "2026-09-07T09:00:00+00:00",
100
+ end: "2026-09-07T10:00:00+00:00",
101
+ recurrenceRule,
102
+ },
103
+ });
104
+ assert.equal(created.statusCode, 200, created.body);
105
+ return json(created);
106
+ }
107
+
108
+ async deleteEvent(calendarId: string, calendarObjectId: string) {
109
+ const removed = await request({
110
+ method: "DELETE",
111
+ path: `/calendar-events/${calendarObjectId}`,
112
+ sub: this.sub,
113
+ query: { calendarId },
114
+ });
115
+ assert.equal(removed.statusCode, 204, removed.body);
116
+ }
117
+
118
+ async mintFeed(calendarId: string): Promise<string> {
119
+ const minted = await request({
120
+ method: "PUT",
121
+ path: `/calendars/${calendarId}/feed`,
122
+ sub: this.sub,
123
+ });
124
+ assert.equal(minted.statusCode, 200, minted.body);
125
+ return json(minted).feedToken as string;
126
+ }
127
+ }
128
+
129
+ const fetchFeed = (
130
+ feedToken: string,
131
+ headers?: Record<string, string>,
132
+ ): Promise<APIGatewayProxyResult> =>
133
+ request({
134
+ method: "GET",
135
+ path: `/feeds/calendar/${feedToken}.ics`,
136
+ headers,
137
+ });
138
+
139
+ before(async () => {
140
+ _resetForTest();
141
+ // The self-host gate, so the feed's exemption from it is exercised rather
142
+ // than assumed. Set before the module loads: it reads the flag per request,
143
+ // but the boot-time bypass assertion reads NODE_ENV at import.
144
+ process.env.DATA_BACKEND = "sqlite";
145
+ ({ client, cleanup } = await createCalendarSqliteClient());
146
+ setClient(client);
147
+ ({ handler } = (await import("../index.js")) as unknown as {
148
+ handler: typeof handler;
149
+ });
150
+ });
151
+
152
+ after(() => {
153
+ _resetForTest();
154
+ cleanup();
155
+ });
156
+
157
+ describe("the feed a subscriber polls", () => {
158
+ it("serves the calendar as iCalendar text with no session at all", async () => {
159
+ const owner = new Subscriber();
160
+ const calendarId = await owner.calendar("Team");
161
+ await owner.event(calendarId, "Stand-up");
162
+ const feedToken = await owner.mintFeed(calendarId);
163
+
164
+ const feed = await fetchFeed(feedToken);
165
+
166
+ assert.equal(feed.statusCode, 200, feed.body);
167
+ assert.equal(
168
+ headerOf(feed, "content-type"),
169
+ "text/calendar; charset=utf-8",
170
+ );
171
+ assert.match(feed.body, /^BEGIN:VCALENDAR/);
172
+ assert.match(feed.body, /END:VCALENDAR\r\n$/);
173
+ assert.match(feed.body, /X-WR-CALNAME:Team/);
174
+ assert.match(feed.body, /SUMMARY:Stand-up/);
175
+ assert.ok(headerOf(feed, "etag"), "a poller needs a tag to send back");
176
+ assert.ok(headerOf(feed, "last-modified"));
177
+ });
178
+
179
+ it("carries a recurring event as its master and rule, not as occurrences", async () => {
180
+ const owner = new Subscriber();
181
+ const calendarId = await owner.calendar();
182
+ await owner.event(calendarId, "Weekly", "FREQ=WEEKLY;COUNT=5");
183
+ const feedToken = await owner.mintFeed(calendarId);
184
+
185
+ const feed = await fetchFeed(feedToken);
186
+
187
+ assert.equal(feed.body.match(/BEGIN:VEVENT/g)?.length, 1);
188
+ assert.match(feed.body, /RRULE:FREQ=WEEKLY;COUNT=5/);
189
+ assert.equal(
190
+ feed.body.includes("RECURRENCE-ID"),
191
+ false,
192
+ "expanding the series would end it at whatever horizon this server chose",
193
+ );
194
+ });
195
+
196
+ it("answers 304 to the tag it just served, and re-serves a changed calendar", async () => {
197
+ const owner = new Subscriber();
198
+ const calendarId = await owner.calendar();
199
+ await owner.event(calendarId, "Stand-up");
200
+ const feedToken = await owner.mintFeed(calendarId);
201
+ const first = await fetchFeed(feedToken);
202
+ const etag = headerOf(first, "etag") as string;
203
+
204
+ const unchanged = await fetchFeed(feedToken, { "If-None-Match": etag });
205
+
206
+ assert.equal(unchanged.statusCode, 304);
207
+ assert.equal(unchanged.body, "");
208
+ assert.equal(headerOf(unchanged, "etag"), etag);
209
+ assert.equal(
210
+ headerOf(unchanged, "last-modified"),
211
+ headerOf(first, "last-modified"),
212
+ "a 304 carries the validators a 200 would have carried",
213
+ );
214
+
215
+ await owner.event(calendarId, "Retro");
216
+ const changed = await fetchFeed(feedToken, { "If-None-Match": etag });
217
+
218
+ assert.equal(changed.statusCode, 200);
219
+ assert.match(changed.body, /SUMMARY:Retro/);
220
+ assert.match(changed.body, /SUMMARY:Stand-up/);
221
+ assert.equal(changed.body.match(/BEGIN:VEVENT/g)?.length, 2);
222
+ assert.notEqual(headerOf(changed, "etag"), etag);
223
+ });
224
+
225
+ it("does not serve the feed at an address the token only starts", async () => {
226
+ // The route is built by substituting the token into the literal path, which
227
+ // leaves the dot of ".ics" matching any character, so /feeds/calendar/
228
+ // <token>Xics reaches the feed operation with the token intact. Asserted at
229
+ // both layers that refuse it: the public-route gate does not recognise the
230
+ // address, and the handler re-derives the token from the raw path rather
231
+ // than trusting what the router captured — so a caller who does hold a
232
+ // session gets the same 404 rather than somebody's calendar.
233
+ const owner = new Subscriber();
234
+ const calendarId = await owner.calendar();
235
+ await owner.event(calendarId, "Stand-up");
236
+ const feedToken = await owner.mintFeed(calendarId);
237
+ const nearlyPath = `/feeds/calendar/${feedToken}Xics`;
238
+
239
+ const served = await fetchFeed(feedToken);
240
+ const unauthenticated = await request({ method: "GET", path: nearlyPath });
241
+ const authenticated = await request({
242
+ method: "GET",
243
+ path: nearlyPath,
244
+ sub: owner.sub,
245
+ });
246
+
247
+ assert.equal(served.statusCode, 200, served.body);
248
+ assert.equal(unauthenticated.statusCode, 401);
249
+ assert.equal(authenticated.statusCode, 404);
250
+ for (const response of [unauthenticated, authenticated]) {
251
+ assert.equal(
252
+ response.body.includes("BEGIN:VCALENDAR"),
253
+ false,
254
+ "an address that is not the one handed out must not serve the calendar",
255
+ );
256
+ }
257
+ });
258
+
259
+ it("answers an oversize token the same 404 as an unknown one", async () => {
260
+ // Not a 400: a length the spec enforces makes an oversize address
261
+ // distinguishable from a wrong one, and error-logs the path it refused.
262
+ const unknown = await fetchFeed("z".repeat(43));
263
+ const oversize = await fetchFeed("z".repeat(600));
264
+
265
+ assert.equal(oversize.statusCode, 404);
266
+ assert.equal(oversize.body, unknown.body);
267
+ assert.equal(
268
+ headerOf(oversize, "content-type"),
269
+ headerOf(unknown, "content-type"),
270
+ );
271
+ });
272
+
273
+ it("keeps Last-Modified moving forward when an event is deleted", async () => {
274
+ // The newest surviving event is not when the calendar last changed: deleting
275
+ // the newest one leaves every survivor older than the delete, and a
276
+ // validator that moved backwards would tell a subscriber its stale copy is
277
+ // still current.
278
+ const owner = new Subscriber();
279
+ const calendarId = await owner.calendar();
280
+ await owner.event(calendarId, "Stand-up");
281
+ const retro = await owner.event(calendarId, "Retro");
282
+ const feedToken = await owner.mintFeed(calendarId);
283
+ const full = await fetchFeed(feedToken);
284
+ const before = Date.parse(headerOf(full, "last-modified") as string);
285
+
286
+ await owner.deleteEvent(calendarId, retro.calendarObjectId as string);
287
+ const shrunk = await fetchFeed(feedToken);
288
+
289
+ assert.equal(shrunk.statusCode, 200, shrunk.body);
290
+ assert.equal(shrunk.body.includes("SUMMARY:Retro"), false);
291
+ assert.ok(
292
+ Date.parse(headerOf(shrunk, "last-modified") as string) >= before,
293
+ "the calendar changed, so its modification time cannot have gone back",
294
+ );
295
+ });
296
+
297
+ it("answers 304 to the date it just served, and lets the tag override it", async () => {
298
+ const owner = new Subscriber();
299
+ const calendarId = await owner.calendar();
300
+ await owner.event(calendarId, "Stand-up");
301
+ const feedToken = await owner.mintFeed(calendarId);
302
+ const first = await fetchFeed(feedToken);
303
+ const lastModified = headerOf(first, "last-modified") as string;
304
+
305
+ const unchanged = await fetchFeed(feedToken, {
306
+ "If-Modified-Since": lastModified,
307
+ });
308
+
309
+ assert.equal(unchanged.statusCode, 304);
310
+ assert.equal(headerOf(unchanged, "etag"), headerOf(first, "etag"));
311
+ assert.equal(headerOf(unchanged, "last-modified"), lastModified);
312
+
313
+ // RFC 9110 13.2.2: a tag that arrives is the whole condition, and the date
314
+ // is not consulted. Sending a tag nobody served alongside a date that would
315
+ // have earned a 304 has to re-serve the calendar.
316
+ const overridden = await fetchFeed(feedToken, {
317
+ "If-None-Match": '"not-the-tag"',
318
+ "If-Modified-Since": lastModified,
319
+ });
320
+
321
+ assert.equal(overridden.statusCode, 200);
322
+ assert.match(overridden.body, /SUMMARY:Stand-up/);
323
+ });
324
+ it("answers the same plain 404 to an unknown and to a malformed token", async () => {
325
+ const owner = new Subscriber();
326
+ const calendarId = await owner.calendar();
327
+ await owner.mintFeed(calendarId);
328
+
329
+ const unknown = await fetchFeed("z".repeat(43));
330
+ const malformed = await fetchFeed("not-a-token");
331
+
332
+ assert.equal(unknown.statusCode, 404);
333
+ assert.equal(malformed.statusCode, 404);
334
+ assert.equal(
335
+ headerOf(unknown, "content-type"),
336
+ "text/plain; charset=utf-8",
337
+ );
338
+ assert.equal(unknown.body, malformed.body);
339
+ assert.equal(
340
+ unknown.body.includes("calendar"),
341
+ true,
342
+ "the body says nothing about which of the two it was",
343
+ );
344
+ });
345
+ });
346
+
347
+ describe("the feed a person manages", () => {
348
+ it("hands out the token once and rotates the address on a second write", async () => {
349
+ const owner = new Subscriber();
350
+ const calendarId = await owner.calendar();
351
+ await owner.event(calendarId, "Stand-up");
352
+ const first = await owner.mintFeed(calendarId);
353
+
354
+ const second = await owner.mintFeed(calendarId);
355
+
356
+ assert.notEqual(second, first);
357
+ assert.equal((await fetchFeed(first)).statusCode, 404);
358
+ assert.equal((await fetchFeed(second)).statusCode, 200);
359
+
360
+ const state = await request({
361
+ method: "GET",
362
+ path: `/calendars/${calendarId}/feed`,
363
+ sub: owner.sub,
364
+ });
365
+ assert.equal(state.statusCode, 200);
366
+ assert.equal(json(state).feedToken, undefined);
367
+ assert.ok((json(state).rotatedAt as number) > 0);
368
+ });
369
+
370
+ it("stops the address resolving when it is revoked", async () => {
371
+ const owner = new Subscriber();
372
+ const calendarId = await owner.calendar();
373
+ const feedToken = await owner.mintFeed(calendarId);
374
+
375
+ const revoked = await request({
376
+ method: "DELETE",
377
+ path: `/calendars/${calendarId}/feed`,
378
+ sub: owner.sub,
379
+ });
380
+
381
+ assert.equal(revoked.statusCode, 204);
382
+ assert.equal((await fetchFeed(feedToken)).statusCode, 404);
383
+ assert.equal(
384
+ (
385
+ await request({
386
+ method: "DELETE",
387
+ path: `/calendars/${calendarId}/feed`,
388
+ sub: owner.sub,
389
+ })
390
+ ).statusCode,
391
+ 404,
392
+ "revoking a feed that is not there is not something to confirm",
393
+ );
394
+ });
395
+
396
+ it("takes the address with the calendar it belonged to", async () => {
397
+ const owner = new Subscriber();
398
+ const calendarId = await owner.calendar();
399
+ const feedToken = await owner.mintFeed(calendarId);
400
+
401
+ const removed = await request({
402
+ method: "DELETE",
403
+ path: `/calendars/${calendarId}`,
404
+ sub: owner.sub,
405
+ });
406
+
407
+ assert.equal(removed.statusCode, 204);
408
+ assert.equal((await fetchFeed(feedToken)).statusCode, 404);
409
+ });
410
+
411
+ it("is absent for a calendar the caller does not own", async () => {
412
+ const owner = new Subscriber();
413
+ const stranger = new Subscriber();
414
+ const calendarId = await owner.calendar();
415
+ await owner.mintFeed(calendarId);
416
+
417
+ for (const method of ["GET", "PUT", "DELETE"]) {
418
+ const response = await request({
419
+ method,
420
+ path: `/calendars/${calendarId}/feed`,
421
+ sub: stranger.sub,
422
+ });
423
+ assert.equal(response.statusCode, 404, `${method} ${response.body}`);
424
+ }
425
+ });
426
+
427
+ it("refuses to mint or revoke without a session", async () => {
428
+ const owner = new Subscriber();
429
+ const calendarId = await owner.calendar();
430
+
431
+ for (const method of ["GET", "PUT", "DELETE"]) {
432
+ const response = await request({
433
+ method,
434
+ path: `/calendars/${calendarId}/feed`,
435
+ });
436
+ assert.equal(response.statusCode, 401, `${method} ${response.body}`);
437
+ }
438
+ });
439
+ });