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>
209 lines
9.2 KiB
Swift
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)
|
|
}
|
|
}
|