test: CalDAV integration tests

This commit is contained in:
Jannis committed 2026-10-10 22:07:28 +02:00
1 parent 8bd463ed55
commit 6b6cbe953d
1 file changed
+515
@@ -0,0 +1,515 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Xml.Linq;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Shared.Calendar;
namespace FamilyPlanner.IntegrationTests;
/// <summary>CalDAV-Server (ADR-0017) über den BFF: Entdeckung, Round-Trip, ETag-Konflikte, Sync, Rechte und App-Passwörter.</summary>
public sealed class CalDavTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly XNamespace D = "DAV:";
private static readonly XNamespace C = "urn:ietf:params:xml:ns:caldav";
private static readonly XNamespace CS = "http://calendarserver.org/ns/";
private static readonly HttpMethod PropFind = new("PROPFIND");
private static readonly HttpMethod Report = new("REPORT");
/// <summary>Jeder Test arbeitet in einer eigenen Woche weit in der Zukunft (unabhängig von CalendarTests).</summary>
private static DateTime TestDay() => new DateTime(2041, 1, 7).AddDays(7 * Random.Shared.Next(0, 300));
private static string Ics(string uid, string summary, DateTime start, string extra = "", int hours = 1) =>
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" +
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART:{start:yyyyMMdd'T'HHmmss}Z\r\nDTEND:{start.AddHours(hours):yyyyMMdd'T'HHmmss}Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\n" +
"END:VCALENDAR\r\n";
private static string Override(string uid, string summary, DateTime recurrenceId, DateTime start) =>
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20400101T000000Z\r\nRECURRENCE-ID:{recurrenceId:yyyyMMdd'T'HHmmss}Z\r\nDTSTART:{start:yyyyMMdd'T'HHmmss}Z\r\nDTEND:{start.AddHours(1):yyyyMMdd'T'HHmmss}Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\n";
private static string Wrap(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 summary, DateTime start, string extra = "") =>
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART:{start:yyyyMMdd'T'HHmmss}Z\r\nDTEND:{start.AddHours(1):yyyyMMdd'T'HHmmss}Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\n";
private async Task<string> NewSecretAsync(HttpClient cookieClient, string label = "Testgerät")
{
var response = await cookieClient.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest(label), Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<CalDavCredentialSecretDto>(Ct))!.Secret;
}
private HttpClient DavClient(string userName, string secret)
{
var client = fixture.CreateBrowserClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{secret}")));
return client;
}
private static async Task<(HttpResponseMessage Response, string Body)> SendAsync(
HttpClient client, HttpMethod method, string path, string? body = null, string contentType = "application/xml", Action<HttpRequestMessage>? tweak = null)
{
using var request = new HttpRequestMessage(method, path);
if (body is not null)
{
request.Content = new StringContent(body, Encoding.UTF8, contentType);
}
tweak?.Invoke(request);
var response = await client.SendAsync(request, Ct);
return (response, await response.Content.ReadAsStringAsync(Ct));
}
private static Task<(HttpResponseMessage Response, string Body)> PropFindAsync(HttpClient client, string path, int depth, string props) =>
SendAsync(client, PropFind, path,
$"<D:propfind xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav' xmlns:CS='http://calendarserver.org/ns/'><D:prop>{props}</D:prop></D:propfind>",
tweak: r => r.Headers.Add("Depth", depth.ToString()));
private static Task<(HttpResponseMessage Response, string Body)> PutAsync(HttpClient client, string path, string ics, Action<HttpRequestMessage>? tweak = null) =>
SendAsync(client, HttpMethod.Put, path, ics, "text/calendar; charset=utf-8", tweak);
private static Task<(HttpResponseMessage Response, string Body)> DeleteAsync(HttpClient client, string path, string? ifMatch = null) =>
SendAsync(client, HttpMethod.Delete, path, tweak: r => { if (ifMatch is not null) { r.Headers.TryAddWithoutValidation("If-Match", ifMatch); } });
private static async Task<Guid> FamilyCalendarIdAsync(HttpClient client) =>
(await client.GetFromJsonAsync<List<CalendarDto>>("/api/calendars", Ct))!.Single(c => c.Name == "Familie").Id;
private static string CalendarPath(Guid id) => $"/caldav/calendars/{id:D}/";
private static string ResourcePath(Guid calendarId, string uid) => $"{CalendarPath(calendarId)}{uid}.ics";
private static XDocument Xml(string body) => XDocument.Parse(body);
private static async Task<string?> ETagOfAsync(HttpClient client, string path)
{
var (response, body) = await PropFindAsync(client, path, 0, "<D:getetag/>");
Assert.Equal((HttpStatusCode)207, response.StatusCode);
return (string?)Xml(body).Descendants(D + "getetag").SingleOrDefault();
}
private static async Task<CalendarRangeDto> RangeAsync(HttpClient client, DateTime from, DateTime to) =>
(await client.GetFromJsonAsync<CalendarRangeDto>($"/api/calendar/events?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}&includeTasks=false", Ct))!;
// ---------- Entdeckung ----------
[Fact]
public async Task Discovery_redirects_well_known_and_walks_root_principal_home_calendars_through_the_bff()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var secret = await NewSecretAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, secret);
using var anonymous = fixture.CreateBrowserClient();
var wellKnown = await anonymous.GetAsync("/.well-known/caldav", Ct);
var wellKnownPropfind = await SendAsync(anonymous, PropFind, "/.well-known/caldav");
Assert.Equal(HttpStatusCode.MovedPermanently, wellKnown.StatusCode);
Assert.Equal("/caldav/", wellKnown.Headers.Location?.OriginalString);
Assert.Equal(HttpStatusCode.MovedPermanently, wellKnownPropfind.Response.StatusCode);
var options = await SendAsync(dav, HttpMethod.Options, "/caldav/");
Assert.Equal(HttpStatusCode.OK, options.Response.StatusCode);
Assert.Contains("calendar-access", string.Join(",", options.Response.Headers.GetValues("DAV")));
Assert.Contains("PROPFIND", string.Join(",", options.Response.Content.Headers.Allow));
var root = await PropFindAsync(dav, "/caldav/", 0, "<D:current-user-principal/><D:resourcetype/>");
Assert.Equal((HttpStatusCode)207, root.Response.StatusCode);
var principalHref = (string)Xml(root.Body).Descendants(D + "current-user-principal").Single().Element(D + "href")!;
Assert.StartsWith("/caldav/principals/", principalHref);
var principal = await PropFindAsync(dav, principalHref, 0, "<C:calendar-home-set/><D:displayname/><D:resourcetype/>");
var homeHref = (string)Xml(principal.Body).Descendants(C + "calendar-home-set").Single().Element(D + "href")!;
Assert.Equal("/caldav/calendars/", homeHref);
Assert.NotNull(Xml(principal.Body).Descendants(D + "principal").FirstOrDefault());
var home = await PropFindAsync(dav, homeHref, 1,
"<D:resourcetype/><D:displayname/><CS:getctag/><D:sync-token/><C:supported-calendar-component-set/><D:current-user-privilege-set/><x:nope xmlns:x='urn:x'/>");
var responses = Xml(home.Body).Root!.Elements(D + "response").ToList();
var family = responses.Single(r => (string?)r.Descendants(D + "displayname").FirstOrDefault() == "Familie");
Assert.NotNull(family.Descendants(C + "calendar").FirstOrDefault());
Assert.Equal("VEVENT", (string?)family.Descendants(C + "comp").Single().Attribute("name"));
Assert.StartsWith("urn:familyplanner:sync:", (string?)family.Descendants(CS + "getctag").Single());
Assert.NotNull(family.Descendants(D + "write-content").FirstOrDefault());
Assert.Contains("HTTP/1.1 404 Not Found", family.Descendants(D + "status").Select(s => (string)s)); // unbekannte Eigenschaft
}
// ---------- Round-Trip, ETag ----------
[Fact]
public async Task Put_get_propfind_delete_roundtrip_with_etag_conflicts_and_web_visibility()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var uid = Guid.NewGuid().ToString("D").ToUpperInvariant();
var path = ResourcePath(calendarId, uid);
var day = TestDay().AddHours(9);
var created = await PutAsync(dav, path, Ics(uid, "Zahnarzt", day, "LOCATION:Praxis\r\nDESCRIPTION:Karte mitnehmen\r\n"), r => r.Headers.TryAddWithoutValidation("If-None-Match", "*"));
Assert.Equal(HttpStatusCode.Created, created.Response.StatusCode);
Assert.Null(created.Response.Headers.ETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await PutAsync(dav, path, Ics(uid, "Doppelt", day), r => r.Headers.TryAddWithoutValidation("If-None-Match", "*"))).Response.StatusCode);
// im Web sichtbar: eigene Termine werden dem Anleger zugeordnet
var occurrence = (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.Title == "Zahnarzt");
Assert.Equal(("Praxis", "Karte mitnehmen"), (occurrence.Location, occurrence.Notes));
var get = await SendAsync(dav, HttpMethod.Get, path);
Assert.Equal(HttpStatusCode.OK, get.Response.StatusCode);
Assert.Equal("text/calendar", get.Response.Content.Headers.ContentType?.MediaType);
Assert.Contains($"UID:{uid}", get.Body);
Assert.Contains("SUMMARY:Zahnarzt", get.Body);
Assert.Contains("BEGIN:VTIMEZONE", get.Body);
var etag1 = get.Response.Headers.ETag!.Tag;
Assert.Equal(etag1, await ETagOfAsync(dav, path));
Assert.Equal(HttpStatusCode.NotModified, (await SendAsync(dav, HttpMethod.Get, path, tweak: r => r.Headers.TryAddWithoutValidation("If-None-Match", etag1))).Response.StatusCode);
// Änderung mit passendem If-Match; veraltetes If-Match -> 412
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, Ics(uid, "Zahnarzt Kontrolle", day, "LOCATION:Praxis\r\n"), r => r.Headers.TryAddWithoutValidation("If-Match", etag1))).Response.StatusCode);
var etag2 = await ETagOfAsync(dav, path);
Assert.NotEqual(etag1, etag2);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await PutAsync(dav, path, Ics(uid, "Veraltet", day), r => r.Headers.TryAddWithoutValidation("If-Match", etag1))).Response.StatusCode);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await DeleteAsync(dav, path, etag1)).Response.StatusCode);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await PutAsync(dav, ResourcePath(calendarId, Guid.NewGuid().ToString("D")), Ics("x", "x", day), r => r.Headers.TryAddWithoutValidation("If-Match", etag1))).Response.StatusCode);
// Notiz wurde nicht verändert, obwohl der Client keine DESCRIPTION mehr sendet -> jetzt leer (Client ist maßgeblich), Personen-Tags bleiben
var reloaded = (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.EventId == occurrence.EventId);
Assert.Equal(("Zahnarzt Kontrolle", null), (reloaded.Title, reloaded.Notes));
Assert.Equal(occurrence.PersonIds, reloaded.PersonIds);
// GET/PUT erzeugen keine „Für:“-Zeilen: wiederholtes Speichern verändert die Notiz nicht
var withNotes = Ics(uid, "Zahnarzt Kontrolle", day, "DESCRIPTION:Karte\r\n");
for (var i = 0; i < 3; i++)
{
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, withNotes)).Response.StatusCode);
}
Assert.Equal("Karte", (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.EventId == occurrence.EventId).Notes);
// Web-Änderung ändert den ETag
var before = await ETagOfAsync(dav, path);
var webOccurrence = (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.EventId == occurrence.EventId);
var save = new SaveEventRequest(null, calendarId, "Zahnarzt (Web)", null, "Karte", false, DateOnly.FromDateTime(day), TimeOnly.FromDateTime(day), DateOnly.FromDateTime(day), TimeOnly.FromDateTime(day).AddHours(1),
webOccurrence.PersonIds, false, null, webOccurrence.Version);
Assert.Equal(HttpStatusCode.NoContent, (await admin.PutAsJsonAsync($"/api/calendar/events/{occurrence.EventId}", save, Ct)).StatusCode);
Assert.NotEqual(before, await ETagOfAsync(dav, path));
Assert.Contains("SUMMARY:Zahnarzt (Web)", (await SendAsync(dav, HttpMethod.Get, path)).Body);
var currentEtag = (await SendAsync(dav, HttpMethod.Get, path)).Response.Headers.ETag!.Tag;
Assert.Equal(HttpStatusCode.NoContent, (await DeleteAsync(dav, path, currentEtag)).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(dav, HttpMethod.Get, path)).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await DeleteAsync(dav, path)).Response.StatusCode);
Assert.DoesNotContain((await RangeAsync(admin, day.Date, day.Date)).Occurrences, o => o.EventId == occurrence.EventId);
}
[Fact]
public async Task Series_with_override_roundtrips_and_unsupported_input_is_rejected()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var uid = Guid.NewGuid().ToString("D");
var path = ResourcePath(calendarId, uid);
var monday = TestDay().AddHours(17);
var series = Wrap(
Vevent(uid, "Schwimmen", monday, $"RRULE:FREQ=WEEKLY;COUNT=3\r\nEXDATE:{monday.AddDays(14):yyyyMMdd'T'HHmmss}Z\r\n"),
Override(uid, "Schwimmen (Halle zu)", monday.AddDays(7), monday.AddDays(7).AddHours(2)));
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, path, series)).Response.StatusCode);
var occurrences = (await RangeAsync(admin, monday.Date, monday.Date.AddDays(30))).Occurrences.Where(o => o.Title.StartsWith("Schwimmen")).ToList();
Assert.Equal(["Schwimmen", "Schwimmen (Halle zu)"], occurrences.OrderBy(o => o.StartUtc).Select(o => o.Title));
var get = (await SendAsync(dav, HttpMethod.Get, path)).Body;
Assert.Contains("RRULE:FREQ=WEEKLY;COUNT=3", get);
Assert.Contains("EXDATE", get);
Assert.Equal(2, CountOf(get, "BEGIN:VEVENT"));
Assert.Contains("RECURRENCE-ID", get);
// Override wieder entfernen (Client schickt nur noch die Serie): Ausnahme verschwindet, ETag ändert sich
var etag = await ETagOfAsync(dav, path);
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, Wrap(Vevent(uid, "Schwimmen", monday, "RRULE:FREQ=WEEKLY;COUNT=3\r\n")))).Response.StatusCode);
Assert.NotEqual(etag, await ETagOfAsync(dav, path));
Assert.Equal(1, CountOf((await SendAsync(dav, HttpMethod.Get, path)).Body, "BEGIN:VEVENT"));
// nicht unterstützt -> 403 mit Vorbedingung, nichts wird gespeichert
var todo = await PutAsync(dav, ResourcePath(calendarId, "todo-1"), Wrap("BEGIN:VTODO\r\nUID:todo-1\r\nDTSTAMP:20400101T000000Z\r\nSUMMARY:x\r\nEND:VTODO\r\n"));
Assert.Equal(HttpStatusCode.Forbidden, todo.Response.StatusCode);
Assert.Contains("supported-calendar-component", todo.Body);
var hourly = await PutAsync(dav, ResourcePath(calendarId, "hourly-1"), Wrap(Vevent("hourly-1", "Stündlich", monday, "RRULE:FREQ=HOURLY;COUNT=3\r\n")));
Assert.Equal(HttpStatusCode.Forbidden, hourly.Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, "hourly-1"))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(dav, ResourcePath(calendarId, "anders"), Wrap(Vevent("etwas-anderes", "x", monday)))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(dav, ResourcePath(calendarId, "kaputt"), "kein iCalendar")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await SendAsync(dav, new HttpMethod("MKCALENDAR"), "/caldav/calendars/neu/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(dav, CalendarPath(calendarId))).Response.StatusCode);
}
private static int CountOf(string text, string needle) => (text.Length - text.Replace(needle, string.Empty, StringComparison.Ordinal).Length) / needle.Length;
[Fact]
public async Task Uids_with_special_characters_roundtrip_through_the_proxy()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var day = TestDay().AddHours(8);
foreach (var uid in new[] { "abc123@example.com", "uid mit leerzeichen", "a/b:c" })
{
var href = $"{CalendarPath(calendarId)}{Uri.EscapeDataString(uid).Replace("%40", "@")}.ics";
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, href, Wrap(Vevent(uid, "Sonderzeichen", day)))).Response.StatusCode);
Assert.Contains($"UID:{uid}", (await SendAsync(dav, HttpMethod.Get, href)).Body);
var listing = await PropFindAsync(dav, CalendarPath(calendarId), 1, "<D:getetag/>");
Assert.Contains(Xml(listing.Body).Descendants(D + "href").Select(h => Uri.UnescapeDataString((string)h)), h => h.EndsWith(uid + ".ics", StringComparison.Ordinal));
Assert.Equal(HttpStatusCode.NoContent, (await DeleteAsync(dav, href)).Response.StatusCode);
}
}
// ---------- Sync und Reports ----------
[Fact]
public async Task Sync_collection_multiget_and_query_report_changes_deletions_and_ranges()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarResponse = await admin.PostAsJsonAsync("/api/admin/calendars", new SaveCalendarRequest($"Sync {Guid.NewGuid():N}"[..12], "#00897B", false, []), Ct);
var calendarId = (await calendarResponse.Content.ReadFromJsonAsync<CalendarAdminDto>(Ct))!.Id;
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var calendar = CalendarPath(calendarId);
var day = TestDay().AddHours(10);
var (a, b) = (Guid.NewGuid().ToString("D"), Guid.NewGuid().ToString("D"));
string SyncBody(string token) => $"<D:sync-collection xmlns:D='DAV:'><D:sync-token>{token}</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>";
var initial = await SendAsync(dav, Report, calendar, SyncBody(string.Empty));
Assert.Equal((HttpStatusCode)207, initial.Response.StatusCode);
var token0 = (string)Xml(initial.Body).Root!.Element(D + "sync-token")!;
Assert.Empty(Xml(initial.Body).Root!.Elements(D + "response"));
await PutAsync(dav, ResourcePath(calendarId, a), Ics(a, "Eins", day));
await PutAsync(dav, ResourcePath(calendarId, b), Ics(b, "Zwei", day.AddDays(40)));
var firstSync = await SendAsync(dav, Report, calendar, SyncBody(token0));
var changed = Xml(firstSync.Body).Root!.Elements(D + "response").ToList();
Assert.Equal(2, changed.Count);
Assert.All(changed, r => Assert.NotNull(r.Descendants(D + "getetag").SingleOrDefault()));
var token1 = (string)Xml(firstSync.Body).Root!.Element(D + "sync-token")!;
Assert.NotEqual(token0, token1);
Assert.Equal(token1, (string)Xml((await PropFindAsync(dav, calendar, 0, "<CS:getctag/>")).Body).Descendants(CS + "getctag").Single());
// nichts geändert -> leer; Änderung + Löschung -> nur das Neue
Assert.Empty(Xml((await SendAsync(dav, Report, calendar, SyncBody(token1))).Body).Root!.Elements(D + "response"));
await PutAsync(dav, ResourcePath(calendarId, a), Ics(a, "Eins geändert", day));
await DeleteAsync(dav, ResourcePath(calendarId, b));
var secondSync = Xml((await SendAsync(dav, Report, calendar, SyncBody(token1))).Body).Root!.Elements(D + "response").ToList();
Assert.Equal(2, secondSync.Count);
Assert.EndsWith($"{a}.ics", (string)secondSync.Single(r => r.Element(D + "propstat") is not null).Element(D + "href")!);
var removed = secondSync.Single(r => r.Element(D + "status") is not null);
Assert.EndsWith($"{b}.ics", (string)removed.Element(D + "href")!);
Assert.Equal("HTTP/1.1 404 Not Found", (string)removed.Element(D + "status")!);
// ungültiges Token -> 403 valid-sync-token
var invalid = await SendAsync(dav, Report, calendar, SyncBody("urn:familyplanner:sync:kaputt"));
Assert.Equal(HttpStatusCode.Forbidden, invalid.Response.StatusCode);
Assert.Contains("valid-sync-token", invalid.Body);
// multiget: vorhandene mit calendar-data, fehlende mit 404
var multiget = await SendAsync(dav, Report, calendar,
$"<C:calendar-multiget xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/><C:calendar-data/></D:prop><D:href>{ResourcePath(calendarId, a)}</D:href><D:href>{ResourcePath(calendarId, Guid.NewGuid().ToString("D"))}</D:href></C:calendar-multiget>");
var multigetResponses = Xml(multiget.Body).Root!.Elements(D + "response").ToList();
Assert.Equal(2, multigetResponses.Count);
Assert.Contains("SUMMARY:Eins geändert", (string)multigetResponses[0].Descendants(C + "calendar-data").Single());
Assert.Equal("HTTP/1.1 404 Not Found", (string)multigetResponses[1].Element(D + "status")!);
// calendar-query: Zeitbereich um 'a' herum, dann leer
string Query(DateTime from, DateTime to) =>
$"<C:calendar-query xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='{from:yyyyMMdd'T'HHmmss}Z' end='{to:yyyyMMdd'T'HHmmss}Z'/></C:comp-filter></C:comp-filter></C:filter></C:calendar-query>";
Assert.Single(Xml((await SendAsync(dav, Report, calendar, Query(day.AddDays(-1), day.AddDays(1)))).Body).Root!.Elements(D + "response"));
Assert.Empty(Xml((await SendAsync(dav, Report, calendar, Query(day.AddDays(5), day.AddDays(9)))).Body).Root!.Elements(D + "response"));
}
// ---------- Rechte ----------
[Fact]
public async Task Permissions_guest_reads_only_child_edits_only_own_events_and_hidden_calendars_stay_hidden()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (family, familyClient) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (child, childClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (guest, guestClient) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _f = familyClient;
using var _c = childClient;
using var _g = guestClient;
var calendarId = await FamilyCalendarIdAsync(admin);
using var parent = DavClient(family.UserName, await NewSecretAsync(familyClient));
using var kid = DavClient(child.UserName, await NewSecretAsync(childClient));
using var visitor = DavClient(guest.UserName, await NewSecretAsync(guestClient));
var day = TestDay().AddHours(15);
var (parentUid, kidUid) = (Guid.NewGuid().ToString("D"), Guid.NewGuid().ToString("D"));
Assert.Equal(HttpStatusCode.Created, (await PutAsync(parent, ResourcePath(calendarId, parentUid), Ics(parentUid, "Elternabend", day))).Response.StatusCode);
// Kind: lesen, eigenen Termin anlegen/ändern/löschen, fremden nicht
Assert.Equal(HttpStatusCode.OK, (await SendAsync(kid, HttpMethod.Get, ResourcePath(calendarId, parentUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await PutAsync(kid, ResourcePath(calendarId, kidUid), Ics(kidUid, "Kindergeburtstag", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(kid, ResourcePath(calendarId, kidUid), Ics(kidUid, "Kindergeburtstag Lena", day))).Response.StatusCode);
var kidOnParent = await PutAsync(kid, ResourcePath(calendarId, parentUid), Ics(parentUid, "Gekapert", day));
Assert.Equal(HttpStatusCode.Forbidden, kidOnParent.Response.StatusCode);
Assert.Contains("need-privileges", kidOnParent.Body);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(kid, ResourcePath(calendarId, parentUid))).Response.StatusCode);
Assert.Contains("SUMMARY:Elternabend", (await SendAsync(parent, HttpMethod.Get, ResourcePath(calendarId, parentUid))).Body);
// Privilegien je Ressource: Kind darf eigenen schreiben, fremden nur lesen
var ownPrivileges = Xml((await PropFindAsync(kid, ResourcePath(calendarId, kidUid), 0, "<D:current-user-privilege-set/>")).Body);
var foreignPrivileges = Xml((await PropFindAsync(kid, ResourcePath(calendarId, parentUid), 0, "<D:current-user-privilege-set/>")).Body);
Assert.NotNull(ownPrivileges.Descendants(D + "write-content").FirstOrDefault());
Assert.Null(foreignPrivileges.Descendants(D + "write-content").FirstOrDefault());
Assert.NotNull(foreignPrivileges.Descendants(D + "read").FirstOrDefault());
// Familie darf auch fremde Termine ändern (Kalender mit Schreibrecht)
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(parent, ResourcePath(calendarId, kidUid), Ics(kidUid, "Kindergeburtstag (Abholung 17 Uhr)", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await DeleteAsync(kid, ResourcePath(calendarId, kidUid))).Response.StatusCode);
// Gast: lesen ja, schreiben/löschen nein, Kalender nur lesend angekündigt
Assert.Equal(HttpStatusCode.OK, (await SendAsync(visitor, HttpMethod.Get, ResourcePath(calendarId, parentUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(visitor, ResourcePath(calendarId, Guid.NewGuid().ToString("D")), Ics("g", "Gast", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(visitor, ResourcePath(calendarId, parentUid), Ics(parentUid, "Gast", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(visitor, ResourcePath(calendarId, parentUid))).Response.StatusCode);
var guestCalendar = Xml((await PropFindAsync(visitor, CalendarPath(calendarId), 0, "<D:current-user-privilege-set/>")).Body);
Assert.Null(guestCalendar.Descendants(D + "bind").FirstOrDefault());
// Kalender ohne Freigabe: für die Rolle unsichtbar (404), nur lesend freigegeben: 403
var hiddenId = await CreateCalendarAsync(admin, "Versteckt", new CalendarGrantDto(Roles.Family, null, CalendarAccess.None), new CalendarGrantDto(Roles.Child, null, CalendarAccess.None));
var readOnlyId = await CreateCalendarAsync(admin, "Nur lesen", new CalendarGrantDto(Roles.Family, null, CalendarAccess.Read));
using var adminDav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var adminUid = Guid.NewGuid().ToString("D");
Assert.Equal(HttpStatusCode.Created, (await PutAsync(adminDav, ResourcePath(hiddenId, adminUid), Ics(adminUid, "Geheim", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await PutAsync(adminDav, ResourcePath(readOnlyId, adminUid), Ics(adminUid, "Lesbar", day))).Response.StatusCode);
foreach (var client in new[] { parent, kid, visitor })
{
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(client, HttpMethod.Get, ResourcePath(hiddenId, adminUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await PutAsync(client, ResourcePath(hiddenId, Guid.NewGuid().ToString("D")), Ics("h", "h", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await PropFindAsync(client, CalendarPath(hiddenId), 0, "<D:displayname/>")).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(client, Report, CalendarPath(hiddenId), "<D:sync-collection xmlns:D='DAV:'><D:sync-token/></D:sync-collection>")).Response.StatusCode);
var home = await PropFindAsync(client, "/caldav/calendars/", 1, "<D:displayname/>");
Assert.DoesNotContain(CalendarPath(hiddenId), Xml(home.Body).Descendants(D + "href").Select(h => (string)h));
}
Assert.Equal(HttpStatusCode.OK, (await SendAsync(parent, HttpMethod.Get, ResourcePath(readOnlyId, adminUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(parent, ResourcePath(readOnlyId, Guid.NewGuid().ToString("D")), Ics("r", "Nö", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(parent, ResourcePath(readOnlyId, adminUid), Ics(adminUid, "Nö", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(parent, ResourcePath(readOnlyId, adminUid))).Response.StatusCode);
// Fremde Principals sind nicht erreichbar
Assert.Equal(HttpStatusCode.NotFound, (await PropFindAsync(parent, $"/caldav/principals/{child.Id:D}/", 0, "<D:displayname/>")).Response.StatusCode);
}
private async Task<Guid> CreateCalendarAsync(HttpClient admin, string name, params CalendarGrantDto[] grants)
{
var response = await admin.PostAsJsonAsync("/api/admin/calendars", new SaveCalendarRequest($"{name} {Guid.NewGuid():N}"[..14], "#6D4C41", false, grants), Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<CalendarAdminDto>(Ct))!.Id;
}
// ---------- Zugangsdaten ----------
[Fact]
public async Task Credentials_wrong_normal_password_revoked_and_cross_scheme_access_are_rejected()
{
var (user, client) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _ = client;
var created = await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest("iPhone von Anna"), Ct);
var secret = (await created.Content.ReadFromJsonAsync<CalDavCredentialSecretDto>(Ct))!;
Assert.Matches(@"^([a-z2-9]{4}-){4}[a-z2-9]{4}$", secret.Secret);
// Info + Liste: nie das Geheimnis im Klartext, Anzeige nur mit Präfix
var info = (await client.GetFromJsonAsync<CalDavInfoDto>("/api/caldav/info", Ct))!;
Assert.EndsWith("/caldav/", info.ServerUrl);
Assert.Equal(user.UserName, info.UserName);
var listBody = await client.GetStringAsync("/api/caldav/credentials", Ct);
Assert.DoesNotContain(secret.Secret, listBody);
Assert.DoesNotContain("SecretHash", listBody, StringComparison.OrdinalIgnoreCase);
using var good = DavClient(user.UserName, secret.Secret);
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(good, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
var item = (await client.GetFromJsonAsync<List<CalDavCredentialDto>>("/api/caldav/credentials", Ct))!.Single(c => c.Id == secret.Credential.Id);
Assert.NotNull(item.LastUsedAt);
Assert.Equal("iPhone von Anna", item.Label);
// Spaces/Großschreibung beim Abtippen sind egal
using var typed = DavClient(user.UserName.ToUpperInvariant(), secret.Secret.ToUpperInvariant().Replace('-', ' '));
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(typed, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
// falsch: falsches Geheimnis, normales Passwort, fremder Benutzername, kein Header, Cookie ohne Basic
var challenge = await SendAsync(DavClient(user.UserName, CalDavSecret.Generate()), HttpMethod.Options, "/caldav/");
Assert.Equal(HttpStatusCode.Unauthorized, challenge.Response.StatusCode);
Assert.Contains("Basic", challenge.Response.Headers.WwwAuthenticate.ToString());
Assert.Contains("realm", challenge.Response.Headers.WwwAuthenticate.ToString());
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(DavClient(user.UserName, AppFixture.UserPassword), PropFind, "/caldav/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(DavClient(AppFixture.AdminUserName, secret.Secret), PropFind, "/caldav/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(fixture.CreateBrowserClient(), PropFind, "/caldav/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(client, PropFind, "/caldav/")).Response.StatusCode); // Cookie gilt hier nicht
// App-Passwort öffnet keine /api-Endpunkte
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(good, HttpMethod.Get, "/api/calendars")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(good, HttpMethod.Get, "/api/caldav/credentials")).Response.StatusCode);
// widerrufen -> sofort ungültig; Widerruf anderer Benutzer wirkt nicht
var (_, other) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var __ = other;
await other.DeleteAsync($"/api/caldav/credentials/{secret.Credential.Id}", Ct);
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(good, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/caldav/credentials/{secret.Credential.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await PropFindAsync(good, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
Assert.NotNull((await client.GetFromJsonAsync<List<CalDavCredentialDto>>("/api/caldav/credentials", Ct))!.Single(c => c.Id == secret.Credential.Id).RevokedAt);
}
[Fact]
public async Task Deactivated_users_lose_access_and_credential_creation_is_validated_and_limited()
{
var (user, client) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _ = client;
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest(" "), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest(new string('x', 61)), Ct)).StatusCode);
var secret = await NewSecretAsync(client);
for (var i = 1; i < CalDavCredential.MaxActivePerUser; i++)
{
await NewSecretAsync(client, $"Gerät {i}");
}
Assert.Equal(HttpStatusCode.Conflict, (await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest("Eins zu viel"), Ct)).StatusCode);
using var dav = DavClient(user.UserName, secret);
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(dav, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
using var admin = await fixture.CreateAdminClientAsync(Ct);
var disable = await admin.PutAsJsonAsync($"/api/admin/users/{user.Id}", new FamilyPlanner.Shared.Admin.UpdateUserRequest(user.DisplayName, null, user.Role, user.Color, false, null), Ct);
Assert.True(disable.IsSuccessStatusCode, await disable.Content.ReadAsStringAsync(Ct));
Assert.Equal(HttpStatusCode.Unauthorized, (await PropFindAsync(dav, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
}
[Fact]
public async Task Repeated_wrong_credentials_are_throttled_with_429()
{
var (user, client) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _ = client;
var secret = await NewSecretAsync(client);
using var wrong = DavClient(user.UserName, CalDavSecret.Generate());
HttpStatusCode last = default;
for (var i = 0; i < 12 && last != HttpStatusCode.TooManyRequests; i++)
{
last = (await SendAsync(wrong, HttpMethod.Options, "/caldav/")).Response.StatusCode;
}
Assert.Equal(HttpStatusCode.TooManyRequests, last);
// auch das richtige Geheimnis ist dann kurz gesperrt (Schutz vor Raten), andere Benutzernamen derselben Adresse nicht automatisch
using var good = DavClient(user.UserName, secret);
Assert.Equal(HttpStatusCode.TooManyRequests, (await SendAsync(good, HttpMethod.Options, "/caldav/")).Response.StatusCode);
}
}