Files
onyx/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift
Scarriffle fde3e864c0 Zwei-Faktor-Anmeldung bei Calendarr
Deine Zugangsdaten stimmten. Der Fehler lag im gewählten Endpunkt:

  auth_router.py:81   if user.totp_enabled:
                          raise HTTPException(401, detail="2fa_required")

/api/auth/token nimmt nur ein Formular mit Benutzername und Passwort entgegen
und weist Konten mit zweitem Faktor grundsätzlich ab — mit demselben 401 wie
ein falsches Passwort. Deshalb stand da „Benutzername oder Passwort stimmen
nicht", obwohl beides stimmte.

Angemeldet wird jetzt über /api/auth/login: der Weg nimmt den Code entgegen
und kennt remember_me. Das ist hier keine Bequemlichkeit, sondern die
Bedingung — mit zweitem Faktor kann Onyx sich nicht selbst neu anmelden, den
Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
remember_me hält hundertachtzig.

Damit wandert auch das Token in den Schlüsselbund. Bei einem Konto mit
zweitem Faktor ist es das einzige, womit Onyx noch hineinkommt; ginge es beim
Beenden verloren, müsste man sich bei jedem Start neu anmelden.

Die drei Fälle sind jetzt auseinandergehalten und einzeln beschriftet:
falsches Passwort, fehlender Code, falscher Code. Der Unterschied ist der
zwischen „Passwort neu tippen" und „App aufmachen" — und er steckt beim
Server nur im Begleittext eines 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:44:26 +02:00

209 lines
9.2 KiB
Swift

import Testing
import Foundation
@testable import CalendarProvider
@Suite("Calendarr direkt vom Server")
struct CalendarrLiveTests {
// MARK: - Adresse
@Test("Eine eingetippte Adresse wird zu einer brauchbaren")
func serverNormalisation() {
// Niemand tippt https://" mit. Und wer es tut, hängt gern noch einen
// Schrägstrich an.
#expect(CalendarrLiveDecoder.server(from: "calendarr.example.com")?.absoluteString
== "https://calendarr.example.com")
#expect(CalendarrLiveDecoder.server(from: "https://calendarr.example.com/")?.absoluteString
== "https://calendarr.example.com")
#expect(CalendarrLiveDecoder.server(from: " http://192.168.1.5:8080 ")?.absoluteString
== "http://192.168.1.5:8080")
}
@Test("Unsinn ergibt keine Adresse")
func serverRejectsGarbage() {
#expect(CalendarrLiveDecoder.server(from: "") == nil)
#expect(CalendarrLiveDecoder.server(from: " ") == nil)
}
@Test("Der Abruf trägt den Zeitraum in der Adresse")
func eventsURLCarriesInterval() throws {
let url = try CalendarrLiveDecoder.eventsURL(
server: URL(string: "https://c.example.com")!,
interval: DateInterval(start: Date(timeIntervalSince1970: 0),
end: Date(timeIntervalSince1970: 86400)))
#expect(url.absoluteString.contains("/api/caldav/events"))
#expect(url.absoluteString.contains("start=1970-01-01"))
#expect(url.absoluteString.contains("end=1970-01-02"))
}
// MARK: - Anmeldung
@Test("Sonderzeichen im Passwort überstehen die Anmeldung")
func passwordEscaping() {
// Der Klassiker: `+` bedeutet im Formularkörper ein Leerzeichen. Ohne
// eigene Kodierung meldet sich niemand mit a+b" jemals an und der
// Fehler tritt nur bei genau diesem Passwort auf.
let body = String(decoding: CalendarrLiveDecoder.formBody(username: "guido",
password: "a+b c&d"),
as: UTF8.self)
#expect(body == "username=guido&password=a%2Bb%20c%26d")
}
@Test("Die Gültigkeit steht im Token selbst")
func tokenExpiry() throws {
// Ein JWT trägt sie im Mittelteil. Die Alternative sieben Tage
// annehmen steht heute so im Server und morgen vielleicht nicht mehr.
let payload = try JSONSerialization.data(withJSONObject: ["exp": 1_800_000_000.0])
let encoded = payload.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let token = "kopf.\(encoded).unterschrift"
#expect(CalendarrLiveDecoder.expiry(ofToken: token)
== Date(timeIntervalSince1970: 1_800_000_000))
}
@Test("Ein kaputtes Token ergibt kein Datum")
func brokenTokenHasNoExpiry() {
#expect(CalendarrLiveDecoder.expiry(ofToken: "nur-ein-wort") == nil)
#expect(CalendarrLiveDecoder.expiry(ofToken: "a.b.c") == nil)
}
@Test("Ein Konto mit zweitem Faktor wird als solches erkannt")
func twoFactorIsToldApart() {
// Der Fehler, der die erste Fassung unbrauchbar machte: der Server
// antwortet auf beides mit 401. Stand da Passwort falsch", obwohl es
// stimmte, sucht man an der falschen Stelle.
#expect(CalendarrLiveDecoder.loginFailure(status: 401, detail: "2fa_required")
== .twoFactorRequired)
#expect(CalendarrLiveDecoder.loginFailure(status: 401, detail: "Ungültiger 2FA-Code")
== .twoFactorWrong)
#expect(CalendarrLiveDecoder.loginFailure(status: 401,
detail: "Benutzername oder Passwort falsch")
== .unauthorized)
#expect(CalendarrLiveDecoder.loginFailure(status: 401, detail: nil) == .unauthorized)
}
@Test("Andere Antworten bleiben, was sie sind")
func otherStatusesPassThrough() {
#expect(CalendarrLiveDecoder.loginFailure(status: 200, detail: nil) == nil)
#expect(CalendarrLiveDecoder.loginFailure(status: 502, detail: nil) == .status(502))
}
// MARK: - Termine
private func json(_ object: Any) throws -> Data {
try JSONSerialization.data(withJSONObject: object)
}
@Test("Termine kommen aus dem Umschlag heraus")
func decodesEnvelope() throws {
let data = try json([
"events": [[
"id": "abc", "title": "Zahnarzt",
"start": "2026-08-14T09:00:00+02:00", "end": "2026-08-14T10:00:00+02:00",
"allDay": false, "location": "Praxis",
"color": "#FF0000", "calendar_id": "local-3", "calendar_name": "Persönlich",
]],
"errors": [],
])
let events = try CalendarrLiveDecoder.events(from: data)
#expect(events.count == 1)
#expect(events[0].title == "Zahnarzt")
#expect(events[0].location == "Praxis")
#expect(events[0].calendarID == "local-3")
#expect(events[0].calendarTitle == "Persönlich")
#expect(events[0].colorHex == "#FF0000")
#expect(!events[0].isAllDay)
}
@Test("Ganztägige Termine kommen ohne Uhrzeit")
func decodesAllDay() throws {
let data = try json(["events": [[
"id": "b", "title": "Urlaub", "start": "2026-08-14", "end": "2026-08-16",
"allDay": true, "calendar_id": "local-1", "calendar_name": "Familie",
]]])
let events = try CalendarrLiveDecoder.events(from: data)
#expect(events.count == 1)
#expect(events[0].isAllDay)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
#expect(calendar.component(.day, from: events[0].start) == 14)
#expect(calendar.component(.hour, from: events[0].start) == 0)
}
@Test("Ohne Ende bekommt ein Termin trotzdem eine Länge")
func missingEndGetsALength() throws {
// Sonst wäre er null Sekunden lang und damit unsichtbar.
let data = try json(["events": [
["id": "c", "title": "Kurz", "start": "2026-08-14T09:00:00Z",
"calendar_id": "x", "calendar_name": "X"],
["id": "d", "title": "Ganztags", "start": "2026-08-15", "allDay": true,
"calendar_id": "x", "calendar_name": "X"],
]])
let events = try CalendarrLiveDecoder.events(from: data)
#expect(events[0].end.timeIntervalSince(events[0].start) == 3600)
#expect(events[1].end.timeIntervalSince(events[1].start) == 86400)
}
@Test("Die Kalenderfarbe springt ein, wenn der Termin keine hat")
func calendarColourIsTheFallback() throws {
let data = try json(["events": [[
"id": "e", "title": "T", "start": "2026-08-14T09:00:00Z",
"calendarColor": "#00FF00", "calendar_id": "x", "calendar_name": "X",
]]])
#expect(try CalendarrLiveDecoder.events(from: data)[0].colorHex == "#00FF00")
}
@Test("Termine kommen sortiert an")
func eventsAreSorted() throws {
let data = try json(["events": [
["id": "spät", "title": "B", "start": "2026-08-16T09:00:00Z",
"calendar_id": "x", "calendar_name": "X"],
["id": "früh", "title": "A", "start": "2026-08-14T09:00:00Z",
"calendar_id": "x", "calendar_name": "X"],
]])
#expect(try CalendarrLiveDecoder.events(from: data).map(\.title) == ["A", "B"])
}
@Test("Ein Eintrag ohne Anfang fällt heraus, der Rest bleibt")
func brokenEntriesAreSkipped() throws {
// Ein einziger kaputter Termin darf nicht die ganze Liste kosten.
let data = try json(["events": [
["id": "kaputt", "title": "Ohne Anfang", "calendar_id": "x", "calendar_name": "X"],
["id": "heil", "title": "Mit Anfang", "start": "2026-08-14T09:00:00Z",
"calendar_id": "x", "calendar_name": "X"],
]])
#expect(try CalendarrLiveDecoder.events(from: data).map(\.title) == ["Mit Anfang"])
}
@Test("Eine Antwort ohne Umschlag geht auch")
func bareArrayWorks() throws {
let data = try json([["id": "f", "title": "T", "start": "2026-08-14T09:00:00Z",
"calendar_id": "x", "calendar_name": "X"]])
#expect(try CalendarrLiveDecoder.events(from: data).count == 1)
}
@Test("Was kein JSON ist, wird als solches gemeldet")
func garbageThrows() {
#expect(throws: CalendarrLiveError.badResponse) {
try CalendarrLiveDecoder.events(from: Data("kein json".utf8))
}
}
// MARK: - Zeitangaben
@Test("Die verbreiteten Zeitformate werden alle gelesen")
func dateFormats() {
#expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00Z") != nil)
#expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00.123Z") != nil)
#expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00+02:00") != nil)
// Ohne Zeitzone: dann gilt die des Rechners.
#expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00") != nil)
#expect(CalendarrLiveDecoder.date(from: "2026-08-14") != nil)
#expect(CalendarrLiveDecoder.date(from: "irgendwann") == nil)
}
}