@remit/backend 0.0.131 → 0.0.132
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
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type CalendarOutcome,
|
|
47
47
|
calendarDepsOf,
|
|
48
48
|
findCalendarFor,
|
|
49
|
+
findWritableCalendarFor,
|
|
49
50
|
listCalendarsFor,
|
|
50
51
|
notFound,
|
|
51
52
|
preconditionFailed,
|
|
@@ -348,7 +349,7 @@ export const createCalendarEventFor = async (
|
|
|
348
349
|
accountConfigId: string,
|
|
349
350
|
input: CreateCalendarEventInput,
|
|
350
351
|
): Promise<CalendarOutcome<CalendarObjectItem>> => {
|
|
351
|
-
const collection = await
|
|
352
|
+
const collection = await findWritableCalendarFor(
|
|
352
353
|
deps,
|
|
353
354
|
accountConfigId,
|
|
354
355
|
input.calendarId,
|
|
@@ -388,12 +389,12 @@ const resolveResource = async (
|
|
|
388
389
|
deps: CalendarEventDeps,
|
|
389
390
|
accountConfigId: string,
|
|
390
391
|
request: Pick<ScopedRequest, "calendarId" | "calendarObjectId">,
|
|
392
|
+
intent: "read" | "write",
|
|
391
393
|
): Promise<CalendarOutcome<ResolvedResource>> => {
|
|
392
|
-
const collection =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
);
|
|
394
|
+
const collection =
|
|
395
|
+
intent === "write"
|
|
396
|
+
? await findWritableCalendarFor(deps, accountConfigId, request.calendarId)
|
|
397
|
+
: await findCalendarFor(deps, accountConfigId, request.calendarId);
|
|
397
398
|
if (!collection.ok) return collection;
|
|
398
399
|
|
|
399
400
|
const object = await deps.calendarObject.find(
|
|
@@ -415,7 +416,12 @@ export const updateCalendarEventFor = async (
|
|
|
415
416
|
request: ScopedRequest,
|
|
416
417
|
patch: Partial<CalendarEventFields>,
|
|
417
418
|
): Promise<CalendarOutcome<CalendarObjectItem | null>> => {
|
|
418
|
-
const resolved = await resolveResource(
|
|
419
|
+
const resolved = await resolveResource(
|
|
420
|
+
deps,
|
|
421
|
+
accountConfigId,
|
|
422
|
+
request,
|
|
423
|
+
"write",
|
|
424
|
+
);
|
|
419
425
|
if (!resolved.ok) return resolved;
|
|
420
426
|
const { collection, object } = resolved.value;
|
|
421
427
|
|
|
@@ -458,7 +464,12 @@ export const deleteCalendarEventFor = async (
|
|
|
458
464
|
accountConfigId: string,
|
|
459
465
|
request: ScopedRequest,
|
|
460
466
|
): Promise<CalendarOutcome<CalendarObjectItem | null>> => {
|
|
461
|
-
const resolved = await resolveResource(
|
|
467
|
+
const resolved = await resolveResource(
|
|
468
|
+
deps,
|
|
469
|
+
accountConfigId,
|
|
470
|
+
request,
|
|
471
|
+
"write",
|
|
472
|
+
);
|
|
462
473
|
if (!resolved.ok) return resolved;
|
|
463
474
|
const { collection, object } = resolved.value;
|
|
464
475
|
|
|
@@ -646,6 +657,7 @@ export const CalendarEventDetailOperations: Record<
|
|
|
646
657
|
deps,
|
|
647
658
|
accountConfigId,
|
|
648
659
|
request.value,
|
|
660
|
+
"read",
|
|
649
661
|
);
|
|
650
662
|
if (!resolved.ok) return answerRefusal(resolved.error);
|
|
651
663
|
return toEventResponse(resolved.value.object);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
11
11
|
import {
|
|
12
|
+
CalendarSource,
|
|
12
13
|
CalendarSuggestionState,
|
|
13
14
|
FilterClauseField,
|
|
14
15
|
FilterMatchOperator,
|
|
@@ -224,7 +225,15 @@ export const CalendarSuggestionActionOperations: Record<
|
|
|
224
225
|
// Reads the collection through the caller's own account config, so a
|
|
225
226
|
// calendarId naming somebody else's collection is a 404 before anything
|
|
226
227
|
// is written into it.
|
|
227
|
-
await client.calendarCollection.get(
|
|
228
|
+
const collection = await client.calendarCollection.get(
|
|
229
|
+
accountConfigId,
|
|
230
|
+
calendarId,
|
|
231
|
+
);
|
|
232
|
+
if (collection.source === CalendarSource.Subscribed) {
|
|
233
|
+
throw new BadRequestError(
|
|
234
|
+
`"${collection.displayName}" is subscribed to a feed and is read-only — accept the invitation into another calendar`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
228
237
|
|
|
229
238
|
const accepted = await acceptCalendarSuggestion(client.calendarUnitOfWork, {
|
|
230
239
|
accountConfigId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { after, before, describe, it } from "node:test";
|
|
3
|
+
import type { CalendarFeedFetcher } from "@remit/calendar-service";
|
|
3
4
|
import type {
|
|
4
5
|
CalendarCollectionItem,
|
|
5
6
|
CalendarEventIndexItem,
|
|
@@ -862,3 +863,218 @@ describe("the calendar collection wrappers", () => {
|
|
|
862
863
|
);
|
|
863
864
|
});
|
|
864
865
|
});
|
|
866
|
+
|
|
867
|
+
const FEED_URL =
|
|
868
|
+
"https://calendar.example/calendar/ical/rota%40example.com/private-0ddba11/basic.ics";
|
|
869
|
+
|
|
870
|
+
const feedOf = (...events: string[][]): string =>
|
|
871
|
+
`${[
|
|
872
|
+
"BEGIN:VCALENDAR",
|
|
873
|
+
"VERSION:2.0",
|
|
874
|
+
"PRODID:-//Example//Published Calendar//EN",
|
|
875
|
+
"METHOD:PUBLISH",
|
|
876
|
+
...events.flat(),
|
|
877
|
+
"END:VCALENDAR",
|
|
878
|
+
].join("\r\n")}\r\n`;
|
|
879
|
+
|
|
880
|
+
const FERRY = [
|
|
881
|
+
"BEGIN:VEVENT",
|
|
882
|
+
"UID:ferry@calendar.example",
|
|
883
|
+
"DTSTAMP:20260901T000000Z",
|
|
884
|
+
"DTSTART:20260908T070000Z",
|
|
885
|
+
"DTEND:20260908T080000Z",
|
|
886
|
+
"RRULE:FREQ=DAILY;COUNT=3",
|
|
887
|
+
"SUMMARY:Ferry crossing",
|
|
888
|
+
"END:VEVENT",
|
|
889
|
+
];
|
|
890
|
+
|
|
891
|
+
const LOCKS = [
|
|
892
|
+
"BEGIN:VEVENT",
|
|
893
|
+
"UID:locks@calendar.example",
|
|
894
|
+
"DTSTAMP:20260901T000000Z",
|
|
895
|
+
"DTSTART;VALUE=DATE:20260910",
|
|
896
|
+
"DTEND;VALUE=DATE:20260911",
|
|
897
|
+
"SUMMARY:Locks closed",
|
|
898
|
+
"END:VEVENT",
|
|
899
|
+
];
|
|
900
|
+
|
|
901
|
+
const serving =
|
|
902
|
+
(body: string, status = 200): CalendarFeedFetcher =>
|
|
903
|
+
async () =>
|
|
904
|
+
new Response(body, { status });
|
|
905
|
+
|
|
906
|
+
const subscribe = async (
|
|
907
|
+
account: CalendarAccount,
|
|
908
|
+
fetcher: CalendarFeedFetcher = serving(feedOf(FERRY, LOCKS)),
|
|
909
|
+
) =>
|
|
910
|
+
createCalendarFor(
|
|
911
|
+
account.eventDeps(),
|
|
912
|
+
account.accountConfigId,
|
|
913
|
+
{
|
|
914
|
+
urlSegment: "harbour",
|
|
915
|
+
displayName: "Harbour",
|
|
916
|
+
subscriptionUrl: FEED_URL.replace("https:", "webcal:"),
|
|
917
|
+
},
|
|
918
|
+
fetcher,
|
|
919
|
+
1_000,
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
describe("a subscribed calendar", () => {
|
|
923
|
+
it("is created with the feed's events already in it", async () => {
|
|
924
|
+
const account = anAccount();
|
|
925
|
+
|
|
926
|
+
const created = await subscribe(account);
|
|
927
|
+
|
|
928
|
+
assert.ok(created.ok, JSON.stringify(created));
|
|
929
|
+
assert.equal(created.value.source, CalendarSource.Subscribed);
|
|
930
|
+
assert.equal(created.value.subscriptionUrl, FEED_URL);
|
|
931
|
+
assert.equal(created.value.subscriptionEnabled, true);
|
|
932
|
+
assert.equal(created.value.subscriptionFetchedAt, 1_000);
|
|
933
|
+
assert.equal(created.value.subscriptionError, "");
|
|
934
|
+
const objects = await account.objects();
|
|
935
|
+
assert.deepEqual(objects.map((object) => object.summary).sort(), [
|
|
936
|
+
"Ferry crossing",
|
|
937
|
+
"Locks closed",
|
|
938
|
+
]);
|
|
939
|
+
const ferry = objects.find((object) => object.summary === "Ferry crossing");
|
|
940
|
+
assert.ok(ferry);
|
|
941
|
+
assert.equal((await account.occurrences(ferry)).length, 3);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("is not created when the feed cannot be read, and says why without the address", async () => {
|
|
945
|
+
const account = anAccount();
|
|
946
|
+
|
|
947
|
+
const created = await subscribe(account, serving("nope", 401));
|
|
948
|
+
|
|
949
|
+
assert.ok(!created.ok);
|
|
950
|
+
assert.equal(created.error.code, "SubscriptionUnreachable");
|
|
951
|
+
assert.equal(
|
|
952
|
+
created.error.message,
|
|
953
|
+
"the calendar was not created: the feed answered HTTP 401",
|
|
954
|
+
);
|
|
955
|
+
assert.equal(created.error.message.includes("private-0ddba11"), false);
|
|
956
|
+
assert.deepEqual(await account.collections(), []);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("refuses an address that is not a web address", async () => {
|
|
960
|
+
const account = anAccount();
|
|
961
|
+
|
|
962
|
+
const created = await createCalendarFor(
|
|
963
|
+
account.deps(),
|
|
964
|
+
account.accountConfigId,
|
|
965
|
+
{
|
|
966
|
+
urlSegment: "harbour",
|
|
967
|
+
displayName: "Harbour",
|
|
968
|
+
subscriptionUrl: "file:///etc/passwd",
|
|
969
|
+
},
|
|
970
|
+
serving(feedOf(FERRY)),
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
assert.ok(!created.ok);
|
|
974
|
+
assert.equal(created.error.code, "InvalidSubscriptionUrl");
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("refuses a new event, an edit and a delete", async () => {
|
|
978
|
+
const account = anAccount();
|
|
979
|
+
const created = await subscribe(account);
|
|
980
|
+
assert.ok(created.ok);
|
|
981
|
+
const deps = account.eventDeps();
|
|
982
|
+
const [ferry] = (await account.objects()).filter(
|
|
983
|
+
(object) => object.summary === "Ferry crossing",
|
|
984
|
+
);
|
|
985
|
+
assert.ok(ferry);
|
|
986
|
+
const request = {
|
|
987
|
+
calendarId: created.value.calendarId,
|
|
988
|
+
calendarObjectId: ferry.calendarObjectId,
|
|
989
|
+
scope: RecurrenceScope.All,
|
|
990
|
+
recurrenceId: "",
|
|
991
|
+
ifMatch: undefined,
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
const added = await createCalendarEventFor(deps, account.accountConfigId, {
|
|
995
|
+
calendarId: created.value.calendarId,
|
|
996
|
+
summary: "Smuggled in",
|
|
997
|
+
start: "2026-09-09T09:00:00Z",
|
|
998
|
+
end: "2026-09-09T10:00:00Z",
|
|
999
|
+
});
|
|
1000
|
+
const edited = await updateCalendarEventFor(
|
|
1001
|
+
deps,
|
|
1002
|
+
account.accountConfigId,
|
|
1003
|
+
request,
|
|
1004
|
+
{ summary: "Renamed" },
|
|
1005
|
+
);
|
|
1006
|
+
const removed = await deleteCalendarEventFor(
|
|
1007
|
+
deps,
|
|
1008
|
+
account.accountConfigId,
|
|
1009
|
+
request,
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
for (const outcome of [added, edited, removed]) {
|
|
1013
|
+
assert.ok(!outcome.ok);
|
|
1014
|
+
assert.equal(outcome.error.code, "ReadOnlyCalendar");
|
|
1015
|
+
}
|
|
1016
|
+
const stored = await account.object(
|
|
1017
|
+
created.value.calendarId,
|
|
1018
|
+
ferry.calendarObjectId,
|
|
1019
|
+
);
|
|
1020
|
+
assert.equal(stored?.etag, ferry.etag);
|
|
1021
|
+
assert.equal((await account.objects()).length, 2);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("turns its refresh off without removing a collection or an event", async () => {
|
|
1025
|
+
const account = anAccount();
|
|
1026
|
+
const created = await subscribe(account);
|
|
1027
|
+
assert.ok(created.ok);
|
|
1028
|
+
const held = await account.objects();
|
|
1029
|
+
|
|
1030
|
+
const paused = await updateCalendarFor(
|
|
1031
|
+
account.deps(),
|
|
1032
|
+
account.accountConfigId,
|
|
1033
|
+
created.value.calendarId,
|
|
1034
|
+
{ subscriptionEnabled: false },
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
assert.ok(paused.ok);
|
|
1038
|
+
assert.equal(paused.value.subscriptionEnabled, false);
|
|
1039
|
+
assert.equal(paused.value.source, CalendarSource.Subscribed);
|
|
1040
|
+
assert.deepEqual(
|
|
1041
|
+
(await account.objects()).map((object) => object.etag).sort(),
|
|
1042
|
+
held.map((object) => object.etag).sort(),
|
|
1043
|
+
);
|
|
1044
|
+
const enabled = await client.calendarCollection.listEnabledSubscriptions();
|
|
1045
|
+
assert.equal(
|
|
1046
|
+
enabled.some(
|
|
1047
|
+
(collection) => collection.calendarId === created.value.calendarId,
|
|
1048
|
+
),
|
|
1049
|
+
false,
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
const resumed = await updateCalendarFor(
|
|
1053
|
+
account.deps(),
|
|
1054
|
+
account.accountConfigId,
|
|
1055
|
+
created.value.calendarId,
|
|
1056
|
+
{ subscriptionEnabled: true },
|
|
1057
|
+
);
|
|
1058
|
+
assert.ok(resumed.ok);
|
|
1059
|
+
assert.equal(resumed.value.subscriptionEnabled, true);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it("refuses to turn a refresh on for a calendar that has no feed", async () => {
|
|
1063
|
+
const account = anAccount();
|
|
1064
|
+
const [own] = await listCalendarsFor(
|
|
1065
|
+
account.deps(),
|
|
1066
|
+
account.accountConfigId,
|
|
1067
|
+
);
|
|
1068
|
+
assert.ok(own);
|
|
1069
|
+
|
|
1070
|
+
const updated = await updateCalendarFor(
|
|
1071
|
+
account.deps(),
|
|
1072
|
+
account.accountConfigId,
|
|
1073
|
+
own.calendarId,
|
|
1074
|
+
{ subscriptionEnabled: true },
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
assert.ok(!updated.ok);
|
|
1078
|
+
assert.equal(updated.error.code, "NotSubscribed");
|
|
1079
|
+
});
|
|
1080
|
+
});
|
package/src/handlers/calendar.ts
CHANGED
|
@@ -4,10 +4,14 @@ import type {
|
|
|
4
4
|
UpdateCalendarInput,
|
|
5
5
|
} from "@remit/api-openapi-types";
|
|
6
6
|
import {
|
|
7
|
+
type CalendarFeedFetcher,
|
|
7
8
|
DEFAULT_CALENDAR_URL_SEGMENT,
|
|
8
9
|
isResolvableZone,
|
|
9
10
|
provisionDefaultCalendar,
|
|
10
11
|
putCalendarObject,
|
|
12
|
+
readCalendarFeed,
|
|
13
|
+
readSubscriptionUrl,
|
|
14
|
+
writeCalendarFeed,
|
|
11
15
|
} from "@remit/calendar-service";
|
|
12
16
|
import type {
|
|
13
17
|
CalendarCollectionItem,
|
|
@@ -114,6 +118,11 @@ const toCalendarResponse = (
|
|
|
114
118
|
source: item.source,
|
|
115
119
|
timezone: item.timezone,
|
|
116
120
|
syncSequence: item.syncSequence,
|
|
121
|
+
subscriptionUrl: item.subscriptionUrl,
|
|
122
|
+
subscriptionEnabled: item.subscriptionEnabled,
|
|
123
|
+
subscriptionCheckedAt: item.subscriptionCheckedAt,
|
|
124
|
+
subscriptionFetchedAt: item.subscriptionFetchedAt,
|
|
125
|
+
subscriptionError: item.subscriptionError,
|
|
117
126
|
createdAt: item.createdAt,
|
|
118
127
|
updatedAt: item.updatedAt,
|
|
119
128
|
});
|
|
@@ -159,6 +168,28 @@ export const findCalendarFor = async (
|
|
|
159
168
|
return { ok: true, value: found };
|
|
160
169
|
};
|
|
161
170
|
|
|
171
|
+
/**
|
|
172
|
+
* One of the caller's collections that a person may write events into. A
|
|
173
|
+
* subscribed collection holds what its feed holds: an edit made here would be
|
|
174
|
+
* overwritten by the next refresh, so it is refused rather than accepted and
|
|
175
|
+
* silently lost.
|
|
176
|
+
*/
|
|
177
|
+
export const findWritableCalendarFor = async (
|
|
178
|
+
deps: CalendarDeps,
|
|
179
|
+
accountConfigId: string,
|
|
180
|
+
calendarId: string,
|
|
181
|
+
): Promise<CalendarOutcome<CalendarCollectionItem>> => {
|
|
182
|
+
const found = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
183
|
+
if (!found.ok) return found;
|
|
184
|
+
if (found.value.source === CalendarSource.Subscribed) {
|
|
185
|
+
return refuseCalendar(
|
|
186
|
+
"ReadOnlyCalendar",
|
|
187
|
+
`"${found.value.displayName}" is subscribed to a feed and is read-only — change the event where the feed comes from`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
};
|
|
192
|
+
|
|
162
193
|
/**
|
|
163
194
|
* A collection's timezone is what every floating time in it is read in, so a
|
|
164
195
|
* name this server cannot resolve is not a cosmetic setting — it silently moves
|
|
@@ -179,10 +210,66 @@ export const readCollectionTimezone = (
|
|
|
179
210
|
return { ok: true, value: timezone };
|
|
180
211
|
};
|
|
181
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Creates a collection subscribed to a feed. The feed is read before anything
|
|
215
|
+
* is written, so an address that cannot be fetched leaves no empty calendar
|
|
216
|
+
* behind; its events then land through the one write path, in the same unit as
|
|
217
|
+
* the collection itself.
|
|
218
|
+
*/
|
|
219
|
+
const createSubscribedCalendarFor = async (
|
|
220
|
+
deps: CalendarDeps,
|
|
221
|
+
accountConfigId: string,
|
|
222
|
+
input: CreateCalendarInput & { subscriptionUrl: string },
|
|
223
|
+
collection: { urlSegment: string; timezone: string },
|
|
224
|
+
fetcher: CalendarFeedFetcher,
|
|
225
|
+
now: number,
|
|
226
|
+
): Promise<CalendarOutcome<CalendarCollectionItem>> => {
|
|
227
|
+
const url = readSubscriptionUrl(input.subscriptionUrl);
|
|
228
|
+
if (!url.ok) return url;
|
|
229
|
+
|
|
230
|
+
const feed = await readCalendarFeed(url.value, fetcher);
|
|
231
|
+
if (!feed.ok) {
|
|
232
|
+
return refuseCalendar(
|
|
233
|
+
"SubscriptionUnreachable",
|
|
234
|
+
`the calendar was not created: ${feed.reason}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const created = await deps.calendarUnitOfWork.transaction(async (repos) => {
|
|
239
|
+
const made = await repos.calendarCollection.createExclusive({
|
|
240
|
+
accountConfigId,
|
|
241
|
+
urlSegment: collection.urlSegment,
|
|
242
|
+
displayName: input.displayName,
|
|
243
|
+
color: input.color,
|
|
244
|
+
timezone: collection.timezone,
|
|
245
|
+
source: CalendarSource.Subscribed,
|
|
246
|
+
subscriptionUrl: url.value,
|
|
247
|
+
subscriptionEnabled: true,
|
|
248
|
+
});
|
|
249
|
+
if (!made) return null;
|
|
250
|
+
await writeCalendarFeed(
|
|
251
|
+
deps.calendarUnitOfWork,
|
|
252
|
+
{ accountConfigId, calendarId: made.calendarId },
|
|
253
|
+
feed.value,
|
|
254
|
+
now,
|
|
255
|
+
);
|
|
256
|
+
return repos.calendarCollection.get(accountConfigId, made.calendarId);
|
|
257
|
+
});
|
|
258
|
+
if (!created) {
|
|
259
|
+
return refuseCalendar(
|
|
260
|
+
"UrlSegmentTaken",
|
|
261
|
+
`"${collection.urlSegment}" already addresses a calendar on this account — pick another`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, value: created };
|
|
265
|
+
};
|
|
266
|
+
|
|
182
267
|
export const createCalendarFor = async (
|
|
183
268
|
deps: CalendarDeps,
|
|
184
269
|
accountConfigId: string,
|
|
185
270
|
input: CreateCalendarInput,
|
|
271
|
+
fetcher: CalendarFeedFetcher = fetch,
|
|
272
|
+
now: number = Date.now(),
|
|
186
273
|
): Promise<CalendarOutcome<CalendarCollectionItem>> => {
|
|
187
274
|
const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
|
|
188
275
|
if (urlSegment === "") {
|
|
@@ -195,6 +282,18 @@ export const createCalendarFor = async (
|
|
|
195
282
|
const timezone = readCollectionTimezone(input.timezone);
|
|
196
283
|
if (!timezone.ok) return timezone;
|
|
197
284
|
|
|
285
|
+
const { subscriptionUrl } = input;
|
|
286
|
+
if (subscriptionUrl !== undefined) {
|
|
287
|
+
return createSubscribedCalendarFor(
|
|
288
|
+
deps,
|
|
289
|
+
accountConfigId,
|
|
290
|
+
{ ...input, subscriptionUrl },
|
|
291
|
+
{ urlSegment, timezone: timezone.value },
|
|
292
|
+
fetcher,
|
|
293
|
+
now,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
198
297
|
// The write decides, not a prior read: two creates of one segment arriving
|
|
199
298
|
// together would both find it free, and the loser would silently be handed
|
|
200
299
|
// the winner's calendar to write into.
|
|
@@ -264,6 +363,9 @@ export const pickCalendarUpdate = (
|
|
|
264
363
|
if (Object.hasOwn(body, "displayName")) patch.displayName = body.displayName;
|
|
265
364
|
if (Object.hasOwn(body, "color")) patch.color = body.color;
|
|
266
365
|
if (Object.hasOwn(body, "timezone")) patch.timezone = body.timezone;
|
|
366
|
+
if (Object.hasOwn(body, "subscriptionEnabled")) {
|
|
367
|
+
patch.subscriptionEnabled = body.subscriptionEnabled;
|
|
368
|
+
}
|
|
267
369
|
return patch;
|
|
268
370
|
};
|
|
269
371
|
|
|
@@ -288,6 +390,15 @@ export const updateCalendarFor = async (
|
|
|
288
390
|
if (!current.ok) return current;
|
|
289
391
|
|
|
290
392
|
const patch = pickCalendarUpdate(body);
|
|
393
|
+
if (
|
|
394
|
+
patch.subscriptionEnabled !== undefined &&
|
|
395
|
+
current.value.source !== CalendarSource.Subscribed
|
|
396
|
+
) {
|
|
397
|
+
return refuseCalendar(
|
|
398
|
+
"NotSubscribed",
|
|
399
|
+
`"${current.value.displayName}" is not subscribed to a feed, so there is no refresh to turn on or off`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
291
402
|
if (patch.timezone !== undefined) {
|
|
292
403
|
const timezone = readCollectionTimezone(patch.timezone);
|
|
293
404
|
if (!timezone.ok) return timezone;
|
|
@@ -83,6 +83,35 @@ describe("what a request leaves in the log", () => {
|
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
it("never writes the address a calendar subscribes to", async () => {
|
|
87
|
+
const secret = "private-7c0ffee5eed";
|
|
88
|
+
const { result: response, logged } = await captureStdout(() =>
|
|
89
|
+
send({
|
|
90
|
+
httpMethod: "POST",
|
|
91
|
+
path: "/calendars",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
urlSegment: "harbour",
|
|
95
|
+
displayName: "Harbour",
|
|
96
|
+
subscriptionUrl: `http://127.0.0.1:9/calendar/ical/${secret}/basic.ics`,
|
|
97
|
+
}),
|
|
98
|
+
requestContext: {
|
|
99
|
+
authorizer: { claims: { sub: "log-redaction-subscriber" } },
|
|
100
|
+
} as unknown as APIGatewayProxyEvent["requestContext"],
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
assert.equal(response.statusCode, 400, response.body);
|
|
105
|
+
assert.match(response.body, /subscription_unreachable/);
|
|
106
|
+
assert.equal(response.body.includes(secret), false);
|
|
107
|
+
assert.ok(logged.length > 0, "the request has to have logged something");
|
|
108
|
+
assert.equal(
|
|
109
|
+
logged.includes(secret),
|
|
110
|
+
false,
|
|
111
|
+
"for a Google secret address the URL is the whole credential",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
86
115
|
it("never writes the credentials a failing request arrived with", async () => {
|
|
87
116
|
// A session is already established, so the bearer is never verified and the
|
|
88
117
|
// request reaches validation — where the whole parsed request, headers and
|