feat: accept client-style CalDAV week start rules; test all-day, TZID and alarm payloads
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91b42c8907
commit
ca82002bd6
3 files changed
+129
-1
No files matched your search
@@ -486,7 +486,7 @@ public static class CalDavEndpoints
|
||||
return Error(StatusCodes.Status403Forbidden, C + "supported-calendar-component", "Der Familienplaner speichert nur Termine (VEVENT), keine Aufgaben oder Journale.");
|
||||
}
|
||||
|
||||
var parsed = IcsImporter.Parse(text, zone);
|
||||
var parsed = IcsImporter.Parse(NormalizeWeekStart(text), zone);
|
||||
if (parsed.IsInvalid)
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-data", "Keine gültige iCalendar-Datei.");
|
||||
@@ -520,6 +520,37 @@ public static class CalDavEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly Regex RuleLine = new(@"^RRULE:(?<rule>.*)$", RegexOptions.Multiline | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Android/Google senden oft <c>WKST=SU</c>. Der Wochenbeginn ändert die Termine nur bei „alle n Wochen“ (INTERVAL > 1) mit mehreren Wochentagen;
|
||||
/// in allen anderen Fällen ist er unerheblich und wird entfernt, damit die Regel in die unterstützte Teilmenge (WKST=MO) fällt. Sonst bleibt er stehen
|
||||
/// und der Termin wird abgelehnt (ehrlicher als eine still abweichende Serie).
|
||||
/// </summary>
|
||||
internal static string NormalizeWeekStart(string ics) =>
|
||||
RuleLine.Replace(ics, match =>
|
||||
{
|
||||
var rule = match.Groups["rule"].Value.TrimEnd('\r');
|
||||
var parts = rule.Split(';').ToList();
|
||||
var weekStart = parts.FirstOrDefault(p => p.StartsWith("WKST=", StringComparison.OrdinalIgnoreCase));
|
||||
if (weekStart is null || weekStart.Equals("WKST=MO", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
var weekly = parts.Contains("FREQ=WEEKLY", StringComparer.OrdinalIgnoreCase);
|
||||
var interval = parts.FirstOrDefault(p => p.StartsWith("INTERVAL=", StringComparison.OrdinalIgnoreCase));
|
||||
var intervalValue = interval is not null && int.TryParse(interval["INTERVAL=".Length..], out var n) ? n : 1;
|
||||
var multipleDays = parts.FirstOrDefault(p => p.StartsWith("BYDAY=", StringComparison.OrdinalIgnoreCase)) is { } byDay && byDay.Contains(',', StringComparison.Ordinal);
|
||||
if (weekly && intervalValue > 1 && multipleDays)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
parts.Remove(weekStart);
|
||||
return "RRULE:" + string.Join(';', parts) + (match.Value.EndsWith('\r') ? "\r" : string.Empty);
|
||||
});
|
||||
|
||||
private static void Attach(CalendarEvent o, CalendarEvent master)
|
||||
{
|
||||
o.CalendarId = master.CalendarId;
|
||||
|
||||
@@ -250,6 +250,69 @@ public sealed class CalDavTests(AppFixture fixture)
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(dav, CalendarPath(calendarId))).Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_style_payloads_all_day_events_tzid_with_vtimezone_and_alarm_and_week_start_roundtrip()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
|
||||
var day = TestDay().Date;
|
||||
string D8(DateTime d) => d.ToString("yyyyMMdd");
|
||||
|
||||
// Ganztägig, ein Tag (iOS: DTEND = Folgetag, exklusiv)
|
||||
var single = Guid.NewGuid().ToString("D").ToUpperInvariant();
|
||||
var singleIcs = Wrap($"BEGIN:VEVENT\r\nUID:{single}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART;VALUE=DATE:{D8(day)}\r\nDTEND;VALUE=DATE:{D8(day.AddDays(1))}\r\nSUMMARY:Feiertag\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n");
|
||||
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, ResourcePath(calendarId, single), singleIcs)).Response.StatusCode);
|
||||
|
||||
// Ganztägig, drei Tage (11.–13., DTEND = 14. exklusiv)
|
||||
var multi = Guid.NewGuid().ToString("D").ToUpperInvariant();
|
||||
var multiIcs = Wrap($"BEGIN:VEVENT\r\nUID:{multi}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART;VALUE=DATE:{D8(day.AddDays(10))}\r\nDTEND;VALUE=DATE:{D8(day.AddDays(13))}\r\nSUMMARY:Urlaub\r\nEND:VEVENT\r\n");
|
||||
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, ResourcePath(calendarId, multi), multiIcs)).Response.StatusCode);
|
||||
|
||||
var range = (await RangeAsync(admin, day, day.AddDays(20))).Occurrences;
|
||||
var feiertag = range.Single(o => o.Title == "Feiertag");
|
||||
var urlaub = range.Single(o => o.Title == "Urlaub");
|
||||
Assert.True(feiertag.IsAllDay);
|
||||
Assert.Equal((DateOnly.FromDateTime(day), DateOnly.FromDateTime(day)), (feiertag.StartDate, feiertag.EndDate));
|
||||
Assert.True(urlaub.IsAllDay);
|
||||
Assert.Equal((DateOnly.FromDateTime(day.AddDays(10)), DateOnly.FromDateTime(day.AddDays(12))), (urlaub.StartDate, urlaub.EndDate)); // Ende im Web inklusiv
|
||||
|
||||
var singleGet = (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, single))).Body;
|
||||
Assert.Contains($"DTSTART;VALUE=DATE:{D8(day)}", singleGet);
|
||||
Assert.Contains($"DTEND;VALUE=DATE:{D8(day.AddDays(1))}", singleGet);
|
||||
var multiGet = (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, multi))).Body;
|
||||
Assert.Contains($"DTSTART;VALUE=DATE:{D8(day.AddDays(10))}", multiGet);
|
||||
Assert.Contains($"DTEND;VALUE=DATE:{D8(day.AddDays(13))}", multiGet);
|
||||
|
||||
// erneutes Speichern durch den Client ändert nichts (kein Daten-Drift bei Ganztägig)
|
||||
var etag = await ETagOfAsync(dav, ResourcePath(calendarId, multi));
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, ResourcePath(calendarId, multi), multiGet)).Response.StatusCode);
|
||||
Assert.Equal(etag, await ETagOfAsync(dav, ResourcePath(calendarId, multi)));
|
||||
|
||||
// iOS-Stil: TZID mit mitgeliefertem VTIMEZONE und VALARM
|
||||
var timed = Guid.NewGuid().ToString("D").ToUpperInvariant();
|
||||
var local = day.AddDays(3).AddHours(10);
|
||||
var timedIcs = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//iPhone OS 18//EN\r\nCALSCALE:GREGORIAN\r\n" +
|
||||
"BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nDTSTART:19810329T020000\r\nTZNAME:MESZ\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\n" +
|
||||
"BEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nDTSTART:19961027T030000\r\nTZNAME:MEZ\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n" +
|
||||
$"BEGIN:VEVENT\r\nCREATED:20400101T000000Z\r\nDTEND;TZID=Europe/Berlin:{local.AddHours(1):yyyyMMdd'T'HHmmss}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART;TZID=Europe/Berlin:{local:yyyyMMdd'T'HHmmss}\r\nSEQUENCE:0\r\nSUMMARY:Zahnarzt iOS\r\nTRANSP:OPAQUE\r\nUID:{timed}\r\n" +
|
||||
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Erinnerung\r\nTRIGGER:-PT15M\r\nUID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, ResourcePath(calendarId, timed), timedIcs)).Response.StatusCode);
|
||||
var zahnarzt = (await RangeAsync(admin, local.Date, local.Date)).Occurrences.Single(o => o.Title == "Zahnarzt iOS");
|
||||
Assert.Equal((new TimeOnly(10, 0), new TimeOnly(11, 0)), (zahnarzt.StartTime, zahnarzt.EndTime));
|
||||
var timedGet = (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, timed))).Body;
|
||||
Assert.Contains($"DTSTART;TZID=Europe/Berlin:{local:yyyyMMdd'T'HHmmss}", timedGet);
|
||||
Assert.DoesNotContain("VALARM", timedGet); // bekannter Verlust, siehe ADR-0017
|
||||
|
||||
// Regeln, wie Clients sie senden: INTERVAL=1, UNTIL in UTC, WKST=SU (Android/Google)
|
||||
foreach (var rule in new[] { "FREQ=WEEKLY;INTERVAL=1;WKST=SU;UNTIL=20991231T225959Z", "FREQ=DAILY;INTERVAL=1;COUNT=5", "FREQ=MONTHLY;BYDAY=2TU;WKST=SU;COUNT=6", "FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=3" })
|
||||
{
|
||||
var uid = Guid.NewGuid().ToString("D");
|
||||
var put = await PutAsync(dav, ResourcePath(calendarId, uid), Wrap(Vevent(uid, "Serie", day.AddHours(18), $"RRULE:{rule}\r\n")));
|
||||
Assert.True(put.Response.StatusCode == HttpStatusCode.Created, $"{rule}: {(int)put.Response.StatusCode} {put.Body}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountOf(string text, string needle) => (text.Length - text.Replace(needle, string.Empty, StringComparison.Ordinal).Length) / needle.Length;
|
||||
|
||||
[Fact]
|
||||
@@ -431,6 +494,8 @@ public sealed class CalDavTests(AppFixture fixture)
|
||||
|
||||
// Info + Liste: nie das Geheimnis im Klartext, Anzeige nur mit Präfix
|
||||
var info = (await client.GetFromJsonAsync<CalDavInfoDto>("/api/caldav/info", Ct))!;
|
||||
Assert.StartsWith("https://", info.ServerUrl); // Schema kommt über Caddy/Web/YARP bis zur Api (Voraussetzung für CalDav:RequireHttps)
|
||||
Assert.True(info.IsSecure);
|
||||
Assert.EndsWith("/caldav/", info.ServerUrl);
|
||||
Assert.Equal(user.UserName, info.UserName);
|
||||
var listBody = await client.GetStringAsync("/api/caldav/credentials", Ct);
|
||||
|
||||
@@ -441,6 +441,38 @@ public sealed class CalDavProtocolTests
|
||||
Assert.Contains("valid-calendar-data", garbage.Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FREQ=WEEKLY;INTERVAL=1;WKST=SU;UNTIL=20261231T225959Z", "FREQ=WEEKLY;INTERVAL=1;UNTIL=20261231T225959Z")]
|
||||
[InlineData("FREQ=DAILY;WKST=SU;COUNT=3", "FREQ=DAILY;COUNT=3")]
|
||||
[InlineData("FREQ=WEEKLY;WKST=SU;BYDAY=MO,SU", "FREQ=WEEKLY;BYDAY=MO,SU")]
|
||||
[InlineData("FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO")]
|
||||
[InlineData("FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO,SU", "FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO,SU")] // ändert die Termine: bleibt stehen -> abgelehnt
|
||||
[InlineData("FREQ=WEEKLY;WKST=MO", "FREQ=WEEKLY;WKST=MO")]
|
||||
public void Week_start_is_dropped_only_when_it_cannot_change_the_occurrences(string input, string expected) =>
|
||||
Assert.Equal($"RRULE:{expected}\r\n", CalDavEndpoints.NormalizeWeekStart($"RRULE:{input}\r\n"));
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_client_style_rules_with_week_start_and_rejects_changing_ones()
|
||||
{
|
||||
Assert.Null(Validate(Ics(Vevent("u1", "RRULE:FREQ=WEEKLY;INTERVAL=1;WKST=SU;UNTIL=20991231T225959Z\r\n")), "u1", out var master, out _));
|
||||
Assert.Equal("FREQ=WEEKLY;UNTIL=20991231T225959Z", master!.RecurrenceRule);
|
||||
Assert.NotNull(Validate(Ics(Vevent("u1", "RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO,SU\r\n")), "u1", out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_maps_all_day_events_with_exclusive_end_and_ignores_alarms()
|
||||
{
|
||||
var ics = Ics(
|
||||
"BEGIN:VEVENT\r\nUID:u1\r\nDTSTAMP:20261001T000000Z\r\nDTSTART;VALUE=DATE:20261110\r\nDTEND;VALUE=DATE:20261113\r\nSUMMARY:Urlaub\r\n" +
|
||||
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-P1D\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT\r\n");
|
||||
|
||||
Assert.Null(Validate(ics, "u1", out var master, out _));
|
||||
|
||||
Assert.True(master!.IsAllDay);
|
||||
Assert.Equal(FamilyTime.StartOfDayUtc(new DateOnly(2026, 11, 10), FamilyTime.Berlin), master.StartUtc);
|
||||
Assert.Equal(FamilyTime.StartOfDayUtc(new DateOnly(2026, 11, 13), FamilyTime.Berlin), master.EndUtc);
|
||||
}
|
||||
|
||||
// ---------- Export als CalDAV-Ressource ----------
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in new issue
Block a user