@remit/calendar-service 0.0.4 → 0.0.6
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/expand.test.ts +18 -0
- package/src/expand.ts +12 -5
- package/src/feed.test.ts +47 -0
- package/src/feed.ts +26 -2
- package/src/window.test.ts +46 -0
package/package.json
CHANGED
package/src/expand.test.ts
CHANGED
|
@@ -328,6 +328,24 @@ describe("expandCalendar", () => {
|
|
|
328
328
|
assert.notEqual(expansion.expandedThrough, "");
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
it("marks a series whose first occurrence falls past the horizon", async () => {
|
|
332
|
+
// The rule's own first instance is excluded, so the iterator's first slot
|
|
333
|
+
// is 2029 and the horizon closes in 2027. Nothing is written, but the
|
|
334
|
+
// series is not complete: `""` here would hide it from the live expansion
|
|
335
|
+
// and the event would render nowhere, ever.
|
|
336
|
+
const expansion = await expand(
|
|
337
|
+
singleEvent(
|
|
338
|
+
"DTSTART:20250301T090000Z",
|
|
339
|
+
"DTEND:20250301T100000Z",
|
|
340
|
+
"RRULE:FREQ=YEARLY;INTERVAL=4",
|
|
341
|
+
"EXDATE:20250301T090000Z",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
assert.deepEqual(expansion.occurrences, []);
|
|
346
|
+
assert.equal(expansion.expandedThrough, "2025-03-01T09:00:00Z");
|
|
347
|
+
});
|
|
348
|
+
|
|
331
349
|
it("says nothing about a horizon for a series that ends inside it", async () => {
|
|
332
350
|
const expansion = await expand(
|
|
333
351
|
singleEvent(
|
package/src/expand.ts
CHANGED
|
@@ -107,7 +107,7 @@ interface Walk {
|
|
|
107
107
|
occurrences: CalendarOccurrenceInput[];
|
|
108
108
|
/** Whether the walk stopped on a bound rather than on the series ending. */
|
|
109
109
|
truncated: boolean;
|
|
110
|
-
/** Start of the last slot the
|
|
110
|
+
/** Start of the last slot the walk collected, or `""` when it collected none. */
|
|
111
111
|
lastIteratedStart: string;
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -258,12 +258,19 @@ export const expandCalendar = (
|
|
|
258
258
|
maxSteps: Number.POSITIVE_INFINITY,
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
// Every truncated walk is marked, including one that collected nothing — a
|
|
262
|
+
// series whose first occurrence lands past the horizon. Its index is empty
|
|
263
|
+
// from the series start, which is the honest floor to write; `""` would
|
|
264
|
+
// claim the whole series is held and the live expansion would never run.
|
|
261
265
|
return {
|
|
262
266
|
occurrences: walk.occurrences,
|
|
263
|
-
expandedThrough:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
expandedThrough: walk.truncated
|
|
268
|
+
? toUtcIso(
|
|
269
|
+
walk.lastIteratedStart
|
|
270
|
+
? Date.parse(walk.lastIteratedStart)
|
|
271
|
+
: seriesStart,
|
|
272
|
+
)
|
|
273
|
+
: "",
|
|
267
274
|
};
|
|
268
275
|
};
|
|
269
276
|
|
package/src/feed.test.ts
CHANGED
|
@@ -49,6 +49,10 @@ const object = (
|
|
|
49
49
|
updatedAt,
|
|
50
50
|
}) as CalendarObjectItem;
|
|
51
51
|
|
|
52
|
+
/** Content lines as a subscriber reads them, with RFC 5545 3.1 folding undone. */
|
|
53
|
+
const unfolded = (icalData: string): string =>
|
|
54
|
+
icalData.replace(/\r\n[ \t]/g, "");
|
|
55
|
+
|
|
52
56
|
describe("a feed token", () => {
|
|
53
57
|
it("is base64url over the declared number of random bytes", () => {
|
|
54
58
|
const minted = mintCalendarFeedToken();
|
|
@@ -122,6 +126,49 @@ describe("the calendar a feed serves", () => {
|
|
|
122
126
|
assert.match(feed.icalData, /SUMMARY:Stand-up/);
|
|
123
127
|
});
|
|
124
128
|
|
|
129
|
+
it("escapes the separators a name may carry", () => {
|
|
130
|
+
const feed = buildCalendarFeed(
|
|
131
|
+
collection({ displayName: "Work, Home; C:\\shared" }),
|
|
132
|
+
[],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
assert.match(
|
|
136
|
+
unfolded(feed.icalData),
|
|
137
|
+
/X-WR-CALNAME:Work\\, Home\\; C:\\\\shared\r\n/,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("cannot be made to smuggle a component through its name", () => {
|
|
142
|
+
const feed = buildCalendarFeed(
|
|
143
|
+
collection({
|
|
144
|
+
displayName:
|
|
145
|
+
"Work\r\nBEGIN:VEVENT\r\nUID:injected\r\nDTSTART:20250101T000000Z\r\nSUMMARY:pwned\r\nEND:VEVENT",
|
|
146
|
+
}),
|
|
147
|
+
[
|
|
148
|
+
object(
|
|
149
|
+
"a.ics",
|
|
150
|
+
singleEvent("SUMMARY:Stand-up", "DTSTART:20260907T090000Z"),
|
|
151
|
+
),
|
|
152
|
+
],
|
|
153
|
+
);
|
|
154
|
+
const served = unfolded(feed.icalData);
|
|
155
|
+
|
|
156
|
+
assert.equal(served.match(/^BEGIN:VEVENT\r\n/gm)?.length, 1);
|
|
157
|
+
assert.doesNotMatch(served, /^UID:injected/m);
|
|
158
|
+
assert.doesNotMatch(served, /^SUMMARY:pwned/m);
|
|
159
|
+
assert.match(served, /X-WR-CALNAME:Work\\nBEGIN:VEVENT\\nUID:injected/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("leaves no bare carriage return in what it serves", () => {
|
|
163
|
+
const feed = buildCalendarFeed(
|
|
164
|
+
collection({ displayName: "Work\rHome" }),
|
|
165
|
+
[],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
assert.match(unfolded(feed.icalData), /X-WR-CALNAME:Work\\nHome\r\n/);
|
|
169
|
+
assert.doesNotMatch(feed.icalData, /\r(?!\n)/);
|
|
170
|
+
});
|
|
171
|
+
|
|
125
172
|
it("carries the collection's zone so a client reads floating times the same way", () => {
|
|
126
173
|
const feed = buildCalendarFeed(
|
|
127
174
|
collection({ timezone: "Europe/Amsterdam" }),
|
package/src/feed.ts
CHANGED
|
@@ -80,6 +80,24 @@ export const redactCalendarFeedPath = (path: string): string =>
|
|
|
80
80
|
? path
|
|
81
81
|
: `${CALENDAR_FEED_PATH_PREFIX}<redacted>${CALENDAR_FEED_PATH_SUFFIX}`;
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A TEXT value as RFC 5545 3.3.11 writes one.
|
|
85
|
+
*
|
|
86
|
+
* ical.js escapes TEXT only for the properties in its design set, and no `X-`
|
|
87
|
+
* property is in it, so a value goes out verbatim: a comma or a semicolon
|
|
88
|
+
* reads back as a value list, and a line break as the start of a whole
|
|
89
|
+
* component the subscriber never had. TEXT has no encoding for a control
|
|
90
|
+
* character, so one is dropped rather than served.
|
|
91
|
+
*/
|
|
92
|
+
const escapeIcalText = (value: string): string =>
|
|
93
|
+
value
|
|
94
|
+
.replace(/\\/g, "\\\\")
|
|
95
|
+
.replace(/;/g, "\\;")
|
|
96
|
+
.replace(/,/g, "\\,")
|
|
97
|
+
.replace(/\r\n|\r|\n/g, "\\n")
|
|
98
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: RFC 5545 TEXT excludes CONTROL.
|
|
99
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
|
|
100
|
+
|
|
83
101
|
/** The bytes a feed serves, and what a conditional request needs to skip them. */
|
|
84
102
|
export interface CalendarFeed {
|
|
85
103
|
icalData: string;
|
|
@@ -106,9 +124,15 @@ export const buildCalendarFeed = (
|
|
|
106
124
|
feed.updatePropertyWithValue("prodid", CALENDAR_PRODID);
|
|
107
125
|
feed.updatePropertyWithValue("version", "2.0");
|
|
108
126
|
feed.updatePropertyWithValue("calscale", "GREGORIAN");
|
|
109
|
-
feed.updatePropertyWithValue(
|
|
127
|
+
feed.updatePropertyWithValue(
|
|
128
|
+
"x-wr-calname",
|
|
129
|
+
escapeIcalText(collection.displayName),
|
|
130
|
+
);
|
|
110
131
|
if (collection.timezone !== "") {
|
|
111
|
-
feed.updatePropertyWithValue(
|
|
132
|
+
feed.updatePropertyWithValue(
|
|
133
|
+
"x-wr-timezone",
|
|
134
|
+
escapeIcalText(collection.timezone),
|
|
135
|
+
);
|
|
112
136
|
}
|
|
113
137
|
|
|
114
138
|
const zonesSeen = new Set<string>();
|
package/src/window.test.ts
CHANGED
|
@@ -245,6 +245,52 @@ describe("listCalendarInstances", () => {
|
|
|
245
245
|
assert.equal(instances[0]?.summary, "Weekly one-to-one");
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
it("expands a series whose index is empty because its first occurrence is past the horizon", async () => {
|
|
249
|
+
const calendar = collection();
|
|
250
|
+
// The rule's own first instance is excluded, so the first occurrence is in
|
|
251
|
+
// 2029 and the horizon closed in 2027. The index holds no row at all, and
|
|
252
|
+
// the only thing that can serve the event is the live expansion.
|
|
253
|
+
const distant = await store(
|
|
254
|
+
calendar,
|
|
255
|
+
"distant.ics",
|
|
256
|
+
ical(
|
|
257
|
+
"BEGIN:VCALENDAR",
|
|
258
|
+
"VERSION:2.0",
|
|
259
|
+
"BEGIN:VEVENT",
|
|
260
|
+
"UID:distant@example.com",
|
|
261
|
+
"DTSTART:20250301T090000Z",
|
|
262
|
+
"DTEND:20250301T100000Z",
|
|
263
|
+
"SUMMARY:Leap day review",
|
|
264
|
+
"RRULE:FREQ=YEARLY;INTERVAL=4",
|
|
265
|
+
"EXDATE:20250301T090000Z",
|
|
266
|
+
"END:VEVENT",
|
|
267
|
+
"END:VCALENDAR",
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(distant.rows, [], "the index really is empty");
|
|
272
|
+
assert.deepEqual(
|
|
273
|
+
await repositories([distant]).calendarObject.listIncompleteExpansions(
|
|
274
|
+
calendar.calendarId,
|
|
275
|
+
"2029-04-01T00:00:00Z",
|
|
276
|
+
),
|
|
277
|
+
[distant.object],
|
|
278
|
+
"and the resource is still offered to the live expansion",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const instances = await listCalendarInstances(
|
|
282
|
+
repositories([distant]),
|
|
283
|
+
[calendar],
|
|
284
|
+
{ from: "2029-03-01T00:00:00Z", to: "2029-04-01T00:00:00Z" },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
assert.deepEqual(
|
|
288
|
+
instances.map((instance) => instance.startAt),
|
|
289
|
+
["2029-03-01T09:00:00Z"],
|
|
290
|
+
);
|
|
291
|
+
assert.equal(instances[0]?.summary, "Leap day review");
|
|
292
|
+
});
|
|
293
|
+
|
|
248
294
|
it("serves a live-expanded series from one source, never twice", async () => {
|
|
249
295
|
const calendar = collection();
|
|
250
296
|
const openEnded = await store(
|