test: unit tests for CalDAV protocol logic
This commit is contained in:
1 parent
88a6375d96
commit
8bd463ed55
1 file changed
+479
@@ -0,0 +1,479 @@
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using FamilyPlanner.Api.Features.Calendar.Ics;
|
||||
using FamilyPlanner.Api.Features.CalDav;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.CalDav;
|
||||
|
||||
public sealed class CalDavProtocolTests
|
||||
{
|
||||
private static readonly Guid CalendarId = Guid.NewGuid();
|
||||
private static readonly DateTimeOffset T0 = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static CalendarEvent Row(string uid, DateTimeOffset updated, Guid? master = null, bool deleted = false, byte version = 1, DateTimeOffset? recurrenceId = null) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarId = CalendarId,
|
||||
Uid = uid,
|
||||
Title = uid,
|
||||
SeriesMasterId = master,
|
||||
RecurrenceId = recurrenceId,
|
||||
UpdatedAt = updated,
|
||||
IsDeleted = deleted,
|
||||
RowVersion = [version],
|
||||
StartUtc = T0,
|
||||
EndUtc = T0.AddHours(1),
|
||||
};
|
||||
|
||||
// ---------- Adressen ----------
|
||||
|
||||
[Theory]
|
||||
[InlineData("/caldav", DavKind.Root)]
|
||||
[InlineData("/caldav/", DavKind.Root)]
|
||||
[InlineData("/caldav/principals/", DavKind.PrincipalsRoot)]
|
||||
[InlineData("/caldav/calendars/", DavKind.Home)]
|
||||
[InlineData("/caldav/calendars", DavKind.Home)]
|
||||
[InlineData("/caldav/unbekannt/x", DavKind.Unknown)]
|
||||
[InlineData("/caldav/calendars/kein-guid/", DavKind.Unknown)]
|
||||
[InlineData("/ics/abc", DavKind.Unknown)]
|
||||
[InlineData("", DavKind.Unknown)]
|
||||
public void Parse_recognizes_collections(string path, DavKind expected) => Assert.Equal(expected, DavPath.Parse(path).Kind);
|
||||
|
||||
[Fact]
|
||||
public void Parse_extracts_principal_calendar_and_event()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
Assert.Equal(new DavPath(DavKind.Principal, PrincipalId: user), DavPath.Parse(DavPath.Principal(user)));
|
||||
Assert.Equal(new DavPath(DavKind.Calendar, CalendarId: CalendarId), DavPath.Parse(DavPath.Calendar(CalendarId)));
|
||||
Assert.Equal(new DavPath(DavKind.Event, CalendarId: CalendarId, Uid: "abc@familyplanner"), DavPath.Parse(DavPath.Event(CalendarId, "abc@familyplanner")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0f7e3c2a8b1d4e5f9a6b7c8d9e0f1a2b@familyplanner")]
|
||||
[InlineData("A1B2C3D4-E5F6-47A8-9B0C-D1E2F3A4B5C6")]
|
||||
[InlineData("termin mit leerzeichen")]
|
||||
[InlineData("a/b\\c:d?e#f%g+h&i=j")]
|
||||
[InlineData("Müllabfuhr–Ärger €")]
|
||||
[InlineData("100%25")]
|
||||
public void Event_href_roundtrips_odd_uids_as_single_segment(string uid)
|
||||
{
|
||||
var href = DavPath.Event(CalendarId, uid);
|
||||
|
||||
Assert.DoesNotContain('/', href[DavPath.Calendar(CalendarId).Length..]);
|
||||
Assert.Equal(uid, DavPath.Parse(href).Uid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_accepts_absolute_urls_and_queries_and_decodes_only_once()
|
||||
{
|
||||
var href = "https://familie.example/" + DavPath.Event(CalendarId, "x%25y")[1..] + "?a=b";
|
||||
|
||||
Assert.Equal("x%25y", DavPath.Parse(href).Uid);
|
||||
Assert.Equal(DavKind.Unknown, DavPath.Parse(DavPath.Calendar(CalendarId) + "ohne-endung").Kind);
|
||||
}
|
||||
|
||||
// ---------- ETag, Gruppierung, Sync ----------
|
||||
|
||||
[Fact]
|
||||
public void ETag_is_stable_quoted_and_changes_with_any_row()
|
||||
{
|
||||
var master = Row("u1", T0);
|
||||
var change = Row("u1", T0, master.Id, recurrenceId: T0.AddDays(1));
|
||||
|
||||
var etag = CalDavResources.ETag([master, change]);
|
||||
|
||||
Assert.StartsWith("\"", etag);
|
||||
Assert.EndsWith("\"", etag);
|
||||
Assert.Equal(etag, CalDavResources.ETag([change, master]));
|
||||
change.RowVersion = [2];
|
||||
Assert.NotEqual(etag, CalDavResources.ETag([master, change]));
|
||||
Assert.NotEqual(CalDavResources.ETag([master]), CalDavResources.ETag([master, change]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"a\"", "\"a\"", true)]
|
||||
[InlineData("\"b\", \"a\"", "\"a\"", true)]
|
||||
[InlineData("*", "\"a\"", true)]
|
||||
[InlineData("W/\"a\"", "\"a\"", true)]
|
||||
[InlineData("\"b\"", "\"a\"", false)]
|
||||
[InlineData("", "\"a\"", false)]
|
||||
[InlineData(null, "\"a\"", false)]
|
||||
public void MatchesAny_handles_lists_wildcards_and_weak_tags(string? header, string etag, bool expected) =>
|
||||
Assert.Equal(expected, CalDavResources.MatchesAny(header, etag));
|
||||
|
||||
[Fact]
|
||||
public void Build_groups_overrides_into_one_resource_and_reports_deleted_series_as_tombstones()
|
||||
{
|
||||
var master = Row("serie", T0);
|
||||
var change = Row("serie", T0.AddMinutes(5), master.Id, recurrenceId: T0);
|
||||
var gone = Row("weg", T0.AddMinutes(10), deleted: true);
|
||||
var single = Row("einzel", T0);
|
||||
|
||||
var state = CalDavResources.Build(CalendarId, T0.AddDays(-1), [master, change, gone, single]);
|
||||
|
||||
Assert.Equal(["einzel", "serie"], state.Resources.Select(r => r.Uid));
|
||||
Assert.Equal(change, Assert.Single(state.Resources.Single(r => r.Uid == "serie").Overrides));
|
||||
Assert.Equal(T0.AddMinutes(5), state.Resources.Single(r => r.Uid == "serie").LastModified);
|
||||
Assert.Equal("weg", Assert.Single(state.Tombstones).Uid);
|
||||
Assert.Equal(T0.AddMinutes(10), state.Stamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleted_override_changes_the_resource_but_is_not_listed()
|
||||
{
|
||||
var master = Row("serie", T0);
|
||||
var removed = Row("serie", T0.AddHours(1), master.Id, deleted: true, recurrenceId: T0);
|
||||
|
||||
var resource = Assert.Single(CalDavResources.Build(CalendarId, T0, [master, removed]).Resources);
|
||||
|
||||
Assert.Empty(resource.Overrides);
|
||||
Assert.Equal(T0.AddHours(1), resource.LastModified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recreated_uid_is_a_live_resource_not_a_tombstone()
|
||||
{
|
||||
var old = Row("u", T0, deleted: true);
|
||||
var fresh = Row("u", T0.AddHours(1));
|
||||
|
||||
var state = CalDavResources.Build(CalendarId, T0, [old, fresh]);
|
||||
|
||||
Assert.Single(state.Resources);
|
||||
Assert.Empty(state.Tombstones);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sync_token_roundtrips_and_rejects_garbage()
|
||||
{
|
||||
var token = CalDavSyncToken.Create(T0);
|
||||
|
||||
Assert.True(CalDavSyncToken.TryParse(token, out var since));
|
||||
Assert.Equal(T0, since);
|
||||
Assert.True(CalDavSyncToken.TryParse(null, out var initial));
|
||||
Assert.Null(initial);
|
||||
Assert.True(CalDavSyncToken.TryParse(" ", out _));
|
||||
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:abc", out _));
|
||||
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:-5", out _));
|
||||
Assert.False(CalDavSyncToken.TryParse("http://example.com/token", out _));
|
||||
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:99999999999999999999", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delta_returns_everything_initially_and_only_newer_changes_afterwards()
|
||||
{
|
||||
var old = Row("alt", T0);
|
||||
var fresh = Row("neu", T0.AddMinutes(1));
|
||||
var gone = Row("weg", T0.AddMinutes(2), deleted: true);
|
||||
var earlierGone = Row("frueher-weg", T0.AddSeconds(-5), deleted: true);
|
||||
var state = CalDavResources.Build(CalendarId, T0, [old, fresh, gone, earlierGone]);
|
||||
|
||||
var (all, noTombstones) = CalDavSyncToken.Delta(state, null);
|
||||
var (changed, removed) = CalDavSyncToken.Delta(state, T0);
|
||||
|
||||
Assert.Equal(["alt", "neu"], all.Select(r => r.Uid));
|
||||
Assert.Empty(noTombstones);
|
||||
Assert.Equal(["neu"], changed.Select(r => r.Uid)); // strikt größer: Gleichstand gilt als gesehen
|
||||
Assert.Equal(["weg"], removed.Select(t => t.Uid));
|
||||
Assert.Empty(CalDavSyncToken.Delta(state, state.Stamp).Changed);
|
||||
}
|
||||
|
||||
// ---------- App-Passwörter ----------
|
||||
|
||||
[Fact]
|
||||
public void Secret_has_expected_format_and_is_hashed_independent_of_formatting()
|
||||
{
|
||||
var secret = CalDavSecret.Generate();
|
||||
|
||||
Assert.Matches(@"^([a-z2-9]{4}-){4}[a-z2-9]{4}$", secret);
|
||||
Assert.NotEqual(secret, CalDavSecret.Generate());
|
||||
Assert.True(CalDavSecret.IsWellFormed(secret));
|
||||
Assert.True(CalDavSecret.IsWellFormed(secret.ToUpperInvariant().Replace('-', ' ')));
|
||||
Assert.Equal(CalDavSecret.Hash(secret), CalDavSecret.Hash(secret.ToUpperInvariant().Replace("-", string.Empty)));
|
||||
Assert.Equal(64, CalDavSecret.Hash(secret).Length);
|
||||
Assert.DoesNotContain(secret.Replace("-", string.Empty), CalDavSecret.Hash(secret));
|
||||
Assert.Equal(secret[..4], CalDavSecret.Prefix(secret));
|
||||
Assert.False(CalDavSecret.IsWellFormed("Eigenes Passwort 456"));
|
||||
Assert.False(CalDavSecret.IsWellFormed(""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Basic YW5uYTpzZWNyZXQ=", true, "anna", "secret")]
|
||||
[InlineData("basic YW5uYTpzZWNyZXQ=", true, "anna", "secret")]
|
||||
[InlineData("Basic YW5uYTpzZTpjcmV0", true, "anna", "se:cret")] // Doppelpunkt im Passwort bleibt erhalten
|
||||
[InlineData("Basic OnNlY3JldA==", false, "", "")] // leerer Benutzer
|
||||
[InlineData("Basic YW5uYTo=", false, "", "")] // leeres Passwort
|
||||
[InlineData("Basic !!!", false, "", "")]
|
||||
[InlineData("Bearer abc", false, "", "")]
|
||||
public void Basic_header_is_decoded(string header, bool ok, string user, string secret)
|
||||
{
|
||||
Assert.Equal(ok, CalDavBasicHandler.TryDecode(header, out var u, out var s));
|
||||
Assert.Equal((user, secret), (u, s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_throttle_blocks_per_user_and_ip_then_expires_and_resets()
|
||||
{
|
||||
var clock = new MutableTimeProvider(T0);
|
||||
var throttle = new CalDavFailureThrottle(clock, Options.Create(new CalDavOptions { MaxFailuresPerUser = 3, MaxFailuresPerIp = 5, FailureWindowMinutes = 10 }));
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
throttle.RegisterFailure("1.1.1.1", "anna");
|
||||
}
|
||||
|
||||
Assert.Null(throttle.BlockedFor("1.1.1.1", "anna"));
|
||||
throttle.RegisterFailure("1.1.1.1", "Anna");
|
||||
Assert.NotNull(throttle.BlockedFor("1.1.1.1", "ANNA"));
|
||||
Assert.Null(throttle.BlockedFor("1.1.1.1", "ben")); // andere Benutzer derselben IP bleiben möglich
|
||||
Assert.Null(throttle.BlockedFor("2.2.2.2", "anna")); // Fremde sperren niemanden von anderen Adressen aus
|
||||
|
||||
clock.Now = T0.AddMinutes(11);
|
||||
Assert.Null(throttle.BlockedFor("1.1.1.1", "anna"));
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
throttle.RegisterFailure("3.3.3.3", $"user{i}");
|
||||
}
|
||||
|
||||
Assert.NotNull(throttle.BlockedFor("3.3.3.3", "irgendwer"));
|
||||
throttle.Reset("3.3.3.3", "user0");
|
||||
}
|
||||
|
||||
private sealed class MutableTimeProvider(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
// ---------- XML ----------
|
||||
|
||||
[Fact]
|
||||
public void Propfind_body_variants_are_understood()
|
||||
{
|
||||
var prop = CalDavXml.TryParse("<D:propfind xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/><C:calendar-data/><x:foo xmlns:x='urn:x'/></D:prop></D:propfind>");
|
||||
|
||||
var request = DavPropRequest.FromPropfind(prop);
|
||||
|
||||
Assert.False(request.AllProp);
|
||||
Assert.Equal([CalDavXml.D + "getetag", CalDavXml.C + "calendar-data", XName.Get("foo", "urn:x")], request.Names);
|
||||
Assert.True(DavPropRequest.FromPropfind(null).AllProp);
|
||||
Assert.True(DavPropRequest.FromPropfind(CalDavXml.TryParse("<D:propfind xmlns:D='DAV:'><D:allprop/></D:propfind>")).AllProp);
|
||||
Assert.True(DavPropRequest.FromPropfind(CalDavXml.TryParse("<D:propfind xmlns:D='DAV:'><D:propname/></D:propfind>")).PropNameOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Xml_parser_rejects_dtd_and_garbage()
|
||||
{
|
||||
Assert.Null(CalDavXml.TryParse("<?xml version='1.0'?><!DOCTYPE x [<!ENTITY e SYSTEM 'file:///etc/passwd'>]><x>&e;</x>"));
|
||||
Assert.Null(CalDavXml.TryParse("<unclosed"));
|
||||
Assert.Null(CalDavXml.TryParse(""));
|
||||
Assert.Null(CalDavXml.TryParse(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multistatus_has_found_and_missing_props_status_responses_and_sync_token()
|
||||
{
|
||||
var xml = CalDavXml.Multistatus(
|
||||
[
|
||||
CalDavXml.Response("/caldav/calendars/x/a.ics", [new XElement(CalDavXml.D + "getetag", "\"e\"")], [CalDavXml.C + "unbekannt"]),
|
||||
CalDavXml.StatusResponse("/caldav/calendars/x/b.ics", 404),
|
||||
],
|
||||
"urn:familyplanner:sync:1");
|
||||
|
||||
var doc = XDocument.Parse(xml);
|
||||
var responses = doc.Root!.Elements(CalDavXml.D + "response").ToList();
|
||||
|
||||
Assert.Equal(CalDavXml.D + "multistatus", doc.Root.Name);
|
||||
Assert.Equal(2, responses.Count);
|
||||
Assert.Equal(["HTTP/1.1 200 OK", "HTTP/1.1 404 Not Found"], responses[0].Elements(CalDavXml.D + "propstat").Select(p => (string)p.Element(CalDavXml.D + "status")!));
|
||||
Assert.Equal("\"e\"", (string?)responses[0].Descendants(CalDavXml.D + "getetag").Single());
|
||||
Assert.Equal("HTTP/1.1 404 Not Found", (string?)responses[1].Element(CalDavXml.D + "status"));
|
||||
Assert.Equal("urn:familyplanner:sync:1", (string?)doc.Root.Element(CalDavXml.D + "sync-token"));
|
||||
Assert.StartsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>", xml);
|
||||
}
|
||||
|
||||
// ---------- calendar-query ----------
|
||||
|
||||
private static DavResource Resource(CalendarEvent master, params CalendarEvent[] overrides) =>
|
||||
new(CalendarId, master.Uid, master, overrides, T0, CalDavResources.ETag(overrides.Prepend(master)));
|
||||
|
||||
private static CalendarQuery Query(string filterXml) => CalendarQuery.Parse(XDocument.Parse(
|
||||
$"<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav' xmlns:D='DAV:'><D:prop><D:getetag/></D:prop><C:filter>{filterXml}</C:filter></C:calendar-query>").Root!);
|
||||
|
||||
[Fact]
|
||||
public void Query_time_range_matches_single_events_and_expands_series()
|
||||
{
|
||||
var zone = FamilyTime.Berlin;
|
||||
var single = Row("einzel", T0);
|
||||
single.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 11, 5, 10, 0, 0), zone);
|
||||
single.EndUtc = single.StartUtc.AddHours(1);
|
||||
var weekly = Row("woechentlich", T0);
|
||||
weekly.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 10, 1, 9, 0, 0), zone);
|
||||
weekly.EndUtc = weekly.StartUtc.AddHours(1);
|
||||
weekly.RecurrenceRule = "FREQ=WEEKLY";
|
||||
|
||||
var november = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261101T000000Z' end='20261201T000000Z'/></C:comp-filter></C:comp-filter>");
|
||||
var october = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261001T000000Z' end='20261031T000000Z'/></C:comp-filter></C:comp-filter>");
|
||||
var farFuture = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20300101T000000Z'/></C:comp-filter></C:comp-filter>");
|
||||
|
||||
Assert.True(november.Matches(Resource(single), T0));
|
||||
Assert.False(october.Matches(Resource(single), T0));
|
||||
Assert.True(october.Matches(Resource(weekly), T0));
|
||||
Assert.True(farFuture.Matches(Resource(weekly), T0));
|
||||
Assert.False(farFuture.Matches(Resource(single), T0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_respects_exdate_and_prop_filters_and_other_components()
|
||||
{
|
||||
var zone = FamilyTime.Berlin;
|
||||
var daily = Row("taeglich", T0);
|
||||
daily.Title = "Gassi gehen";
|
||||
daily.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 10, 1, 9, 0, 0), zone);
|
||||
daily.EndUtc = daily.StartUtc.AddHours(1);
|
||||
daily.RecurrenceRule = "FREQ=DAILY;COUNT=2";
|
||||
daily.ExceptionDates = [daily.StartUtc.AddDays(1)];
|
||||
var day2 = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261002T000000Z' end='20261003T000000Z'/></C:comp-filter></C:comp-filter>");
|
||||
var summary = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='SUMMARY'><C:text-match>gassi</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
|
||||
var notSummary = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='SUMMARY'><C:text-match negate-condition='yes'>gassi</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
|
||||
var uid = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='UID'><C:text-match match-type='equals'>taeglich</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
|
||||
var todos = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VTODO'/></C:comp-filter>");
|
||||
|
||||
Assert.False(day2.Matches(Resource(daily), T0)); // EXDATE
|
||||
Assert.True(summary.Matches(Resource(daily), T0));
|
||||
Assert.False(notSummary.Matches(Resource(daily), T0));
|
||||
Assert.True(uid.Matches(Resource(daily), T0));
|
||||
Assert.False(todos.Matches(Resource(daily), T0));
|
||||
Assert.True(CalendarQuery.Parse(XDocument.Parse("<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav'/>").Root!).Matches(Resource(daily), T0));
|
||||
}
|
||||
|
||||
// ---------- PUT-Prüfung ----------
|
||||
|
||||
private static string Ics(params string[] events) =>
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + string.Concat(events) + "END:VCALENDAR\r\n";
|
||||
|
||||
private static string Vevent(string uid, string extra = "", string start = "20261110T100000Z") =>
|
||||
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\nDTSTART:{start}\r\nDTEND:20261110T110000Z\r\nSUMMARY:Test\r\n{extra}END:VEVENT\r\n";
|
||||
|
||||
private static async Task<(int Status, string Body)> Execute(IResult result)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
await result.ExecuteAsync(context);
|
||||
context.Response.Body.Position = 0;
|
||||
return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync());
|
||||
}
|
||||
|
||||
private static IResult? Validate(string text, string uid, out CalendarEvent? master, out List<CalendarEvent> overrides) =>
|
||||
CalDavEndpoints.Validate(text, uid, FamilyTime.Berlin, out master, out overrides);
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_single_event_and_series_with_override()
|
||||
{
|
||||
Assert.Null(Validate(Ics(Vevent("u1")), "u1", out var single, out var none));
|
||||
Assert.Equal("Test", single!.Title);
|
||||
Assert.Empty(none);
|
||||
|
||||
var series = Ics(
|
||||
Vevent("u2", "RRULE:FREQ=DAILY;COUNT=3\r\n"),
|
||||
Vevent("u2", "RECURRENCE-ID:20261111T100000Z\r\n", "20261111T120000Z"));
|
||||
Assert.Null(Validate(series, "u2", out var master, out var overrides));
|
||||
Assert.Equal("FREQ=DAILY;COUNT=3", master!.RecurrenceRule);
|
||||
var change = Assert.Single(overrides);
|
||||
Assert.Equal(new DateTimeOffset(2026, 11, 11, 10, 0, 0, TimeSpan.Zero), change.RecurrenceId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("todo")]
|
||||
[InlineData("journal")]
|
||||
[InlineData("garbage")]
|
||||
[InlineData("rule")]
|
||||
[InlineData("uid")]
|
||||
[InlineData("two-uids")]
|
||||
[InlineData("override-only")]
|
||||
[InlineData("override-without-series")]
|
||||
[InlineData("empty")]
|
||||
public async Task Validate_rejects_with_403_and_precondition(string kind)
|
||||
{
|
||||
var text = kind switch
|
||||
{
|
||||
"todo" => Ics("BEGIN:VTODO\r\nUID:u1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:x\r\nEND:VTODO\r\n"),
|
||||
"journal" => Ics(Vevent("u1"), "BEGIN:VJOURNAL\r\nUID:j\r\nDTSTAMP:20261001T000000Z\r\nEND:VJOURNAL\r\n"),
|
||||
"garbage" => "das ist kein iCalendar",
|
||||
"rule" => Ics(Vevent("u1", "RRULE:FREQ=HOURLY;COUNT=3\r\n")),
|
||||
"uid" => Ics(Vevent("anders")),
|
||||
"two-uids" => Ics(Vevent("u1"), Vevent("u2")),
|
||||
"override-only" => Ics(Vevent("u1", "RECURRENCE-ID:20261111T100000Z\r\n")),
|
||||
"override-without-series" => Ics(Vevent("u1"), Vevent("u1", "RECURRENCE-ID:20261111T100000Z\r\n")),
|
||||
_ => Ics(),
|
||||
};
|
||||
|
||||
var result = Validate(text, "u1", out var master, out _);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(master);
|
||||
var (status, body) = await Execute(result);
|
||||
Assert.Equal(403, status);
|
||||
Assert.Contains("<D:error", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_reports_specific_preconditions()
|
||||
{
|
||||
var todo = await Execute(Validate(Ics("BEGIN:VTODO\r\nUID:u1\r\nEND:VTODO\r\n"), "u1", out _, out _)!);
|
||||
var rule = await Execute(Validate(Ics(Vevent("u1", "RRULE:FREQ=HOURLY\r\n")), "u1", out _, out _)!);
|
||||
var garbage = await Execute(Validate("nope", "u1", out _, out _)!);
|
||||
|
||||
Assert.Contains("supported-calendar-component", todo.Body);
|
||||
Assert.Contains("valid-calendar-object-resource", rule.Body);
|
||||
Assert.Contains("valid-calendar-data", garbage.Body);
|
||||
}
|
||||
|
||||
// ---------- Export als CalDAV-Ressource ----------
|
||||
|
||||
[Fact]
|
||||
public void Resource_export_has_no_person_line_feed_headers_or_categories_so_edits_do_not_accumulate()
|
||||
{
|
||||
var anna = Guid.NewGuid();
|
||||
var e = Row("u1", T0);
|
||||
e.Notes = "Bitte Kuchen mitbringen";
|
||||
e.PersonIds = [anna];
|
||||
e.ForWholeFamily = true;
|
||||
var names = new Dictionary<Guid, string> { [CalendarId] = "Familie" };
|
||||
|
||||
var resource = IcsBuilder.Build(string.Empty, "Europe/Berlin", [e], names, new Dictionary<Guid, string> { [anna] = "Anna" }, [], IcsTaskMode.None, resourceMode: true);
|
||||
var feed = IcsBuilder.Build("Familie", "Europe/Berlin", [e], names, new Dictionary<Guid, string> { [anna] = "Anna" }, [], IcsTaskMode.None);
|
||||
|
||||
Assert.Contains("DESCRIPTION:Bitte Kuchen mitbringen", resource);
|
||||
Assert.DoesNotContain("Für:", resource);
|
||||
Assert.DoesNotContain("X-WR-CALNAME", resource);
|
||||
Assert.DoesNotContain("REFRESH-INTERVAL", resource);
|
||||
Assert.DoesNotContain("CATEGORIES", resource);
|
||||
Assert.Contains("BEGIN:VTIMEZONE", resource);
|
||||
Assert.Contains("Für:", feed); // Feed unverändert
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resource_export_roundtrips_through_the_importer_unchanged()
|
||||
{
|
||||
var e = Row("u1", T0);
|
||||
e.Title = "Zahnarzt";
|
||||
e.Location = "Praxis";
|
||||
e.Notes = "Karte mitnehmen";
|
||||
e.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 11, 3, 14, 0, 0), FamilyTime.Berlin);
|
||||
e.EndUtc = e.StartUtc.AddHours(1);
|
||||
e.RecurrenceRule = "FREQ=WEEKLY;BYDAY=TU;COUNT=4";
|
||||
|
||||
var ics = IcsBuilder.Build(string.Empty, "Europe/Berlin", [e], new Dictionary<Guid, string>(), new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true);
|
||||
Assert.Null(Validate(ics, "u1", out var back, out _));
|
||||
|
||||
Assert.Equal((e.Title, e.Location, e.Notes, e.StartUtc, e.EndUtc), (back!.Title, back.Location, back.Notes, back.StartUtc, back.EndUtc));
|
||||
Assert.Equal(e.RecurrenceRule, back.RecurrenceRule);
|
||||
Assert.Equal("Europe/Berlin", back.TimeZoneId);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user