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>
This commit is contained in:
@@ -1689,13 +1689,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Ohne Verbindung liest Onyx den Schnappschuss, den die Calendarr-App schreibt — der ist nur so aktuell wie ihr letzter Start."
|
"value": "Die Anmeldung gilt ein halbes Jahr. Mit Zwei-Faktor-Code kann Onyx sie nicht selbst erneuern — dann meldest du dich danach einmal neu an."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Without a connection Onyx reads the snapshot the Calendarr app writes — only as current as its last launch."
|
"value": "The sign-in lasts half a year. With a two-factor code Onyx can’t renew it on its own — you’ll sign in again once after that."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1716,6 +1716,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"options.calendar.server.totp": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zwei-Faktor-Code (falls eingerichtet)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Two-factor code (if enabled)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"options.calendar.server.username": {
|
"options.calendar.server.username": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ private struct CalendarrServerSettings: View {
|
|||||||
@State private var server = ""
|
@State private var server = ""
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
|
@State private var totpCode = ""
|
||||||
@State private var isChecking = false
|
@State private var isChecking = false
|
||||||
@State private var failure: String?
|
@State private var failure: String?
|
||||||
|
|
||||||
@@ -324,6 +325,11 @@ private struct CalendarrServerSettings: View {
|
|||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
SecureField(text: $password) { Text("options.calendar.server.password") }
|
SecureField(text: $password) { Text("options.calendar.server.password") }
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
|
// Nur ausfüllen, wer einen zweiten Faktor hat. Ein Pflichtfeld
|
||||||
|
// wäre es für alle anderen eine Frage, die sie nicht beantworten
|
||||||
|
// können.
|
||||||
|
TextField(text: $totpCode) { Text("options.calendar.server.totp") }
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let failure {
|
if let failure {
|
||||||
@@ -357,12 +363,15 @@ private struct CalendarrServerSettings: View {
|
|||||||
Task {
|
Task {
|
||||||
defer { isChecking = false }
|
defer { isChecking = false }
|
||||||
switch await CalendarrLiveSource.check(server: url, username: username,
|
switch await CalendarrLiveSource.check(server: url, username: username,
|
||||||
password: password) {
|
password: password,
|
||||||
case .success:
|
totpCode: totpCode) {
|
||||||
credentials.save(server: url, username: username, password: password)
|
case .success(let token):
|
||||||
// Das Passwort nicht im Formular stehen lassen — es liegt jetzt
|
credentials.save(server: url, username: username,
|
||||||
// im Schlüsselbund, und ein zweiter Ort ist einer zu viel.
|
password: password, token: token)
|
||||||
|
// Passwort und Code nicht im Formular stehen lassen — beide
|
||||||
|
// liegen jetzt dort, wo sie hingehören.
|
||||||
password = ""
|
password = ""
|
||||||
|
totpCode = ""
|
||||||
model.calendarrAccountChanged()
|
model.calendarrAccountChanged()
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
failure = error.localizedDescription
|
failure = error.localizedDescription
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ public final class CalendarrLiveSource: CalendarSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
||||||
guard let account = store.account, let password = store.password() else {
|
guard let account = store.account else { return .neverWritten }
|
||||||
return .neverWritten
|
let password = store.password()
|
||||||
}
|
await tokens.adopt(store.token())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let token = try await tokens.token(for: account, password: password,
|
let token = try await tokens.token(for: account, password: password,
|
||||||
@@ -110,11 +110,13 @@ public final class CalendarrLiveSource: CalendarSource {
|
|||||||
/// Einstellungen. Ein Feld, das man ausfüllt und bei dem man erst am
|
/// Einstellungen. Ein Feld, das man ausfüllt und bei dem man erst am
|
||||||
/// nächsten Tag merkt, dass ein Tippfehler drin war, ist keins.
|
/// nächsten Tag merkt, dass ein Tippfehler drin war, ist keins.
|
||||||
public static func check(server: URL, username: String, password: String,
|
public static func check(server: URL, username: String, password: String,
|
||||||
session: URLSession = .shared) async -> Result<Void, Error> {
|
totpCode: String?,
|
||||||
|
session: URLSession = .shared) async -> Result<String, Error> {
|
||||||
do {
|
do {
|
||||||
_ = try await TokenCache.fetchToken(server: server, username: username,
|
let token = try await TokenCache.fetchToken(server: server, username: username,
|
||||||
password: password, session: session)
|
password: password, totpCode: totpCode,
|
||||||
return .success(())
|
session: session)
|
||||||
|
return .success(token)
|
||||||
} catch {
|
} catch {
|
||||||
return .failure(error)
|
return .failure(error)
|
||||||
}
|
}
|
||||||
@@ -133,36 +135,66 @@ private actor TokenCache {
|
|||||||
|
|
||||||
func discard() { token = nil; expiry = nil }
|
func discard() { token = nil; expiry = nil }
|
||||||
|
|
||||||
func token(for account: CalendarrAccount, password: String,
|
func adopt(_ stored: String?) {
|
||||||
|
guard token == nil, let stored else { return }
|
||||||
|
token = stored
|
||||||
|
expiry = CalendarrLiveDecoder.expiry(ofToken: stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func token(for account: CalendarrAccount, password: String?,
|
||||||
session: URLSession) async throws -> String {
|
session: URLSession) async throws -> String {
|
||||||
// Fünf Minuten Sicherheitsabstand: ein Token, das während des Abrufs
|
// Fünf Minuten Sicherheitsabstand: ein Token, das während des Abrufs
|
||||||
// abläuft, kostet einen zweiten Durchgang.
|
// abläuft, kostet einen zweiten Durchgang.
|
||||||
if let token, let expiry, expiry.timeIntervalSinceNow > 300 { return token }
|
if let token, let expiry, expiry.timeIntervalSinceNow > 300 { return token }
|
||||||
|
|
||||||
|
// Ohne Passwort — oder mit zweitem Faktor — kann sich niemand von
|
||||||
|
// selbst neu anmelden. Dann muss der Mensch ran, und das sagt der
|
||||||
|
// Fehler auch.
|
||||||
|
guard let password, !password.isEmpty else { throw CalendarrLiveError.twoFactorRequired }
|
||||||
|
|
||||||
let fresh = try await Self.fetchToken(server: account.server,
|
let fresh = try await Self.fetchToken(server: account.server,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
password: password, session: session)
|
password: password, totpCode: nil,
|
||||||
|
session: session)
|
||||||
token = fresh
|
token = fresh
|
||||||
expiry = CalendarrLiveDecoder.expiry(ofToken: fresh)
|
expiry = CalendarrLiveDecoder.expiry(ofToken: fresh)
|
||||||
?? Date().addingTimeInterval(6 * 24 * 3600)
|
?? Date().addingTimeInterval(6 * 24 * 3600)
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meldet an und gibt ein Langzeit-Token zurück.
|
||||||
|
///
|
||||||
|
/// Über `/api/auth/login` statt `/api/auth/token`: das Formular-Endstück
|
||||||
|
/// kennt keinen zweiten Faktor und antwortet Konten mit aktivem 2FA
|
||||||
|
/// **immer** mit „falsches Passwort" — auch wenn es stimmt. Der JSON-Weg
|
||||||
|
/// nimmt den Code entgegen und kann obendrein `remember_me`.
|
||||||
|
///
|
||||||
|
/// Und das ist hier keine Bequemlichkeit, sondern die Bedingung: mit 2FA
|
||||||
|
/// kann Onyx sich nicht selbst neu anmelden, wenn ein Token abläuft — den
|
||||||
|
/// Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
|
||||||
|
/// `remember_me` hält hundertachtzig.
|
||||||
static func fetchToken(server: URL, username: String, password: String,
|
static func fetchToken(server: URL, username: String, password: String,
|
||||||
session: URLSession) async throws -> String {
|
totpCode: String?, session: URLSession) async throws -> String {
|
||||||
var request = URLRequest(url: server.appendingPathComponent("api/auth/token"))
|
var request = URLRequest(url: server.appendingPathComponent("api/auth/login"))
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = CalendarrLiveDecoder.formBody(username: username, password: password)
|
|
||||||
|
var body: [String: Any] = ["username": username, "password": password,
|
||||||
|
"remember_me": true]
|
||||||
|
if let totpCode, !totpCode.isEmpty { body["totp_code"] = totpCode }
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
request.timeoutInterval = 15
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse }
|
guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse }
|
||||||
if http.statusCode == 401 { throw CalendarrLiveError.unauthorized }
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) }
|
|
||||||
|
|
||||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
if let failure = CalendarrLiveDecoder.loginFailure(status: http.statusCode,
|
||||||
let token = object["access_token"] as? String, !token.isEmpty
|
detail: object?["detail"] as? String) {
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = object?["access_token"] as? String, !token.isEmpty
|
||||||
else { throw CalendarrLiveError.badResponse }
|
else { throw CalendarrLiveError.badResponse }
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
@@ -170,6 +202,9 @@ private actor TokenCache {
|
|||||||
|
|
||||||
public enum CalendarrLiveError: LocalizedError, Equatable {
|
public enum CalendarrLiveError: LocalizedError, Equatable {
|
||||||
case unauthorized
|
case unauthorized
|
||||||
|
/// Das Konto verlangt einen zweiten Faktor — ohne Code geht hier nichts.
|
||||||
|
case twoFactorRequired
|
||||||
|
case twoFactorWrong
|
||||||
case badResponse
|
case badResponse
|
||||||
case status(Int)
|
case status(Int)
|
||||||
case badServer
|
case badServer
|
||||||
@@ -177,6 +212,8 @@ public enum CalendarrLiveError: LocalizedError, Equatable {
|
|||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module)
|
case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module)
|
||||||
|
case .twoFactorRequired: String(localized: "calendarr.live.error.twoFactor", bundle: .module)
|
||||||
|
case .twoFactorWrong: String(localized: "calendarr.live.error.twoFactorWrong", bundle: .module)
|
||||||
case .badResponse: String(localized: "calendarr.live.error.response", bundle: .module)
|
case .badResponse: String(localized: "calendarr.live.error.response", bundle: .module)
|
||||||
case .status(let code): String(localized: "calendarr.live.error.status \(code)", bundle: .module)
|
case .status(let code): String(localized: "calendarr.live.error.status \(code)", bundle: .module)
|
||||||
case .badServer: String(localized: "calendarr.live.error.server", bundle: .module)
|
case .badServer: String(localized: "calendarr.live.error.server", bundle: .module)
|
||||||
@@ -284,6 +321,24 @@ public enum CalendarrLiveDecoder {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was eine abgelehnte Anmeldung bedeutet.
|
||||||
|
///
|
||||||
|
/// Der Server unterscheidet die Fälle nur im Begleittext, und der
|
||||||
|
/// Unterschied ist der zwischen „Passwort neu tippen" und „App aufmachen".
|
||||||
|
/// Ohne diese Unterscheidung stand bei einem Konto mit zweitem Faktor
|
||||||
|
/// „Benutzername oder Passwort stimmen nicht" — obwohl beides stimmte.
|
||||||
|
public static func loginFailure(status: Int, detail: String?) -> CalendarrLiveError? {
|
||||||
|
switch status {
|
||||||
|
case 200: return nil
|
||||||
|
case 401:
|
||||||
|
let detail = detail ?? ""
|
||||||
|
if detail == "2fa_required" { return .twoFactorRequired }
|
||||||
|
if detail.localizedCaseInsensitiveContains("2fa") { return .twoFactorWrong }
|
||||||
|
return .unauthorized
|
||||||
|
default: return .status(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wann das Token abläuft — steht in ihm selbst.
|
/// Wann das Token abläuft — steht in ihm selbst.
|
||||||
///
|
///
|
||||||
/// Ein JWT trägt seine Gültigkeit im Mittelteil, base64-kodiert. Die
|
/// Ein JWT trägt seine Gültigkeit im Mittelteil, base64-kodiert. Die
|
||||||
@@ -348,23 +403,35 @@ public struct CalendarrCredentials: @unchecked Sendable {
|
|||||||
return CalendarrAccount(server: server, username: username)
|
return CalendarrAccount(server: server, username: username)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func save(server: URL, username: String, password: String) {
|
/// Das Token gehört mit in den Schlüsselbund, nicht nur ins Gedächtnis des
|
||||||
|
/// laufenden Programms.
|
||||||
|
///
|
||||||
|
/// Bei einem Konto mit zweitem Faktor ist es das einzige, womit Onyx
|
||||||
|
/// überhaupt noch hineinkommt: neu anmelden ginge nur mit einem Code, und
|
||||||
|
/// den hat nur der Mensch. Ginge es beim Beenden verloren, müsste man sich
|
||||||
|
/// bei jedem Start neu anmelden.
|
||||||
|
public func save(server: URL, username: String, password: String, token: String?) {
|
||||||
defaults.set(server.absoluteString, forKey: Keys.server)
|
defaults.set(server.absoluteString, forKey: Keys.server)
|
||||||
defaults.set(username, forKey: Keys.username)
|
defaults.set(username, forKey: Keys.username)
|
||||||
Self.setPassword(password, for: username)
|
Self.setSecret(password, for: username, kind: .password)
|
||||||
|
Self.setSecret(token, for: username, kind: .token)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func clear() {
|
public func clear() {
|
||||||
if let username = defaults.string(forKey: Keys.username) {
|
if let username = defaults.string(forKey: Keys.username) {
|
||||||
Self.setPassword(nil, for: username)
|
Self.setSecret(nil, for: username, kind: .password)
|
||||||
|
Self.setSecret(nil, for: username, kind: .token)
|
||||||
}
|
}
|
||||||
defaults.removeObject(forKey: Keys.server)
|
defaults.removeObject(forKey: Keys.server)
|
||||||
defaults.removeObject(forKey: Keys.username)
|
defaults.removeObject(forKey: Keys.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func password() -> String? {
|
public func password() -> String? { secret(.password) }
|
||||||
|
public func token() -> String? { secret(.token) }
|
||||||
|
|
||||||
|
private func secret(_ kind: Secret) -> String? {
|
||||||
guard let username = defaults.string(forKey: Keys.username) else { return nil }
|
guard let username = defaults.string(forKey: Keys.username) else { return nil }
|
||||||
var query = Self.query(for: username)
|
var query = Self.query(for: username, kind: kind)
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
|
||||||
@@ -375,19 +442,24 @@ public struct CalendarrCredentials: @unchecked Sendable {
|
|||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func query(for username: String) -> [String: Any] {
|
private enum Secret: String {
|
||||||
|
case password
|
||||||
|
case token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query(for username: String, kind: Secret) -> [String: Any] {
|
||||||
[kSecClass as String: kSecClassGenericPassword,
|
[kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service + "." + kind.rawValue,
|
||||||
kSecAttrAccount as String: username]
|
kSecAttrAccount as String: username]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func setPassword(_ password: String?, for username: String) {
|
private static func setSecret(_ value: String?, for username: String, kind: Secret) {
|
||||||
let query = query(for: username)
|
let query = query(for: username, kind: kind)
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
guard let password, !password.isEmpty else { return }
|
guard let value, !value.isEmpty else { return }
|
||||||
|
|
||||||
var item = query
|
var item = query
|
||||||
item[kSecValueData as String] = Data(password.utf8)
|
item[kSecValueData as String] = Data(value.utf8)
|
||||||
// Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein
|
// Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein
|
||||||
// Kalenderwidget braucht kein Passwort, solange niemand angemeldet ist.
|
// Kalenderwidget braucht kein Passwort, solange niemand angemeldet ist.
|
||||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
|||||||
@@ -317,6 +317,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"calendarr.live.error.twoFactor": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dieses Konto verlangt einen Zwei-Faktor-Code."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This account requires a two-factor code."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendarr.live.error.twoFactorWrong": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Zwei-Faktor-Code stimmt nicht — er gilt nur eine halbe Minute."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wrong two-factor code — it’s only valid for half a minute."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"widget.calendar.allDay": {
|
"widget.calendar.allDay": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -70,6 +70,27 @@ struct CalendarrLiveTests {
|
|||||||
#expect(CalendarrLiveDecoder.expiry(ofToken: "a.b.c") == 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
|
// MARK: - Termine
|
||||||
|
|
||||||
private func json(_ object: Any) throws -> Data {
|
private func json(_ object: Any) throws -> Data {
|
||||||
|
|||||||
Reference in New Issue
Block a user