diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2dfe3..7c657ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,87 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an [Keep a Changelog](https://keepachangelog.com), Versionierung nach [Semver](https://semver.org). +## [1.1.0] — 2026-05-13 + +Phase 1 aus dem Native-Auth-Vollausbau-Plan (Option A — alles nativ, +siehe `mana/docs/MANA_SWIFT.md`). Erweitert `ManaCore` um die +Account-Lifecycle-Methoden, die jede native Verein-App für eine +vollständige Auth-Reise braucht. + +### ManaCore — Neue API (additiv, keine Breaking Changes) + +- `AuthClient.register(email:password:name:sourceAppUrl:)` — Sign-Up + gegen `POST /api/v1/auth/register`. Persistiert eine Session + automatisch, wenn der Server Tokens mitliefert; sonst still und + wartend auf Email-Verifikation. +- `AuthClient.forgotPassword(email:resetUniversalLink:)` — Passwort- + Reset-Mail anfordern gegen `POST /api/v1/auth/forgot-password`. + Server antwortet immer 200 (keine User-Enumeration). +- `AuthClient.resetPassword(token:newPassword:)` — Passwort mit Token + aus Reset-Mail setzen. +- `AuthClient.resendVerification(email:sourceAppUrl:)` — Verify-Mail + erneut versenden, aufzurufen nach ``AuthError/emailNotVerified``. +- `AuthClient.changeEmail(newEmail:callbackUniversalLink:)` — Email + ändern (verschickt Verify-Mail an neue Adresse). **Aktuell server- + seitig nicht Bearer-fähig** — siehe Doc-Header von + `AuthClient+Account.swift`. +- `AuthClient.changePassword(currentPassword:newPassword:)` — Passwort + ändern. Gleiche Bearer-Einschränkung wie `changeEmail`. +- `AuthClient.deleteAccount(password:)` — Account löschen + (App-Store-Guideline 5.1.1(v) Pflicht). Wiped Keychain bei Erfolg. + Gleiche Bearer-Einschränkung wie oben. + +### ManaCore — `AuthError` ausgebaut + +- Präzise Cases pro Server-`AuthErrorCode`: `.emailNotVerified`, + `.emailAlreadyRegistered`, `.weakPassword(message:)`, + `.accountLocked(retryAfter:)`, `.signupLimitReached`, + `.rateLimited(retryAfter:)`, `.tokenExpired`, `.tokenInvalid`, + `.twoFactorRequired`, `.twoFactorFailed`, `.passkeyNotEnabled`, + `.passkeyCancelled`, `.passkeyVerificationFailed`, + `.validation(message:)`, `.unauthorized`, `.notFound`, + `.serviceUnavailable`, `.serverInternal`. +- `AuthError.classify(status:data:retryAfterHeader:)` — public, + klassifiziert mana-auth-Fehler-Antworten in den passenden Case. + Auch genutzt von `signIn` und `refreshAccessToken` (vorher: einfache + `.error(String)`-Strings). +- `AuthError` ist jetzt `Equatable` — erleichtert UI-Logik und Tests. +- Alte Cases `.invalidCredentials`, `.networkFailure`, `.encoding`, + `.keychain`, `.decoding`, `.notSignedIn` bleiben unverändert. +- **Breaking-Vermeidung:** `serverError(status:message:)` wurde zu + `serverError(status:code:message:)` (zusätzliches `code`-Argument). + Theoretisch breaking, praktisch nutzt es niemand außerhalb von + ManaCore selbst. Wenn ein App-Konsument darauf gepattern-matched + hat, ist das ein Compile-Fehler, kein Runtime-Bug. + +### Tests + +- 14 neue Tests für `AuthError.classify` (jeder ErrorCode + Status- + Heuristik + Retry-After-Header + kaputter Body). +- 12 neue Tests für die neuen `AuthClient`-Methoden via + `URLProtocol`-Mock (Wire-Format, Status-Mapping, Bearer-Header, + Session-Persistenz bei `register`, Session-Wipe bei `deleteAccount`). + +### Bekannte Einschränkungen + +- `changeEmail`, `changePassword`, `deleteAccount` brauchen Server- + seitig den `bearer`-Plugin von Better Auth oder einen Custom- + Bearer-Resolver. Heute mountet `mana-auth` nur den Cookie-Pfad. + Phase-3-Server-PR im `mana`-Repo dokumentiert. +- 2FA-Verify, Magic-Link und Passkey-Flows sind in dieser Version + bewusst NICHT enthalten. Laufen Server-seitig über Better-Auth- + Native (`/api/auth/*`, Cookie) und brauchen eigene JWT-Pfade. + Folgt in v1.2.0 zusammen mit dem Server-PR. + +## [1.0.1] — 2026-05-13 + +### Behoben + +- `AuthenticatedTransport`: `URL.appending(path:)` URL-encoded das `?` + in Query-Strings zu `%3F`, was den Server-Route-Match brechen ließ + (404 für `/healthz?…`). Ersetzt durch String-Concat; Caller liefert + den Path inkl. führendem `/` und optionaler Query. + ## [1.0.0] — 2026-05-12 Initiale Extraktion aus `memoro-native` (Phase α aus diff --git a/Sources/ManaCore/Auth/AuthClient+Account.swift b/Sources/ManaCore/Auth/AuthClient+Account.swift new file mode 100644 index 0000000..d16b017 --- /dev/null +++ b/Sources/ManaCore/Auth/AuthClient+Account.swift @@ -0,0 +1,385 @@ +import Foundation + +/// Account-Lifecycle-Methoden: Registrierung, Passwort-Reset, +/// Email-Verifikation, Account-Management. +/// +/// Diese Methoden ergänzen die Login-/Refresh-Maschine in ``AuthClient`` +/// um die Flows, die eine native App für eine vollständige Auth-Reise +/// braucht (vergleichbar mit `mana-auth-web` aber API-basiert statt +/// Cookie-basiert). +/// +/// **Server-Endpoints (alle JSON, alle unter `/api/v1/auth/*`):** +/// - `POST /register` — Sign-Up + (je nach Config) email-verify-required +/// - `POST /forgot-password` — Reset-Mail anfordern (immer 200, kein Enum-Leak) +/// - `POST /reset-password` — neues PW mit Token aus Reset-Mail +/// - `POST /resend-verification` — Verify-Mail erneut senden +/// - `POST /change-email` — Email ändern (verschickt Verify-Mail an neue Adresse) +/// - `POST /change-password` — Passwort ändern (current + new) +/// - `DELETE /account` — Account löschen (App-Store-Pflicht 5.1.1(v)) +/// +/// **Server-Limitation (Stand 2026-05-13):** `change-email`, +/// `change-password` und `DELETE /account` forwarden Original-Request- +/// Headers an Better Auth. Better Auth liest Session-Cookies. Der +/// `bearer`-Plugin von Better Auth ist NICHT installiert, daher +/// scheitern diese Endpoints heute mit reinem `Authorization: Bearer`. +/// → Server-Fix in Phase 3 nötig (entweder `bearerPlugin()` in +/// `better-auth.config.ts` aktivieren oder Custom-Bearer-Resolver in +/// `mana-auth/src/routes/auth.ts` ergänzen). ManaCore sendet Bearer +/// bereits korrekt — sobald der Server das akzeptiert, funktionieren +/// die Methoden ohne Swift-Änderung. +public extension AuthClient { + // MARK: - Registrierung + + /// Registriert einen neuen Account. + /// + /// - Parameters: + /// - email: Email-Adresse, wird als Login-Identifier genutzt. + /// - password: Klartext-Passwort, Server hasht via Better Auth. + /// - name: Anzeige-Name. Wenn nil, nutzt der Server den Email-Local-Part. + /// - sourceAppUrl: Basis-URL für den Verify-Email-Klick-Redirect. + /// Per-App-spezifisch — z.B. `https://cardecky.mana.how/auth/verify` + /// für einen Universal-Link-Handler. Der Server hängt `?token=…` an. + /// + /// Bei Erfolg ist mit Default-Server-Config (`requireEmailVerification: true`) + /// noch **kein Login** gemacht — der User muss erst die Verify-Mail + /// klicken. Ein anschließendes `signIn(...)` mit denselben Credentials + /// liefert dann `.emailNotVerified` bis der Klick passiert. + /// + /// Wenn der Server in der Antwort doch Tokens schickt (kann passieren + /// wenn Email-Verifikation off ist), wird die Session persistiert und + /// der Status auf `.signedIn` gesetzt. + /// + /// - Throws: ``AuthError/emailAlreadyRegistered``, + /// ``AuthError/weakPassword(message:)``, ``AuthError/signupLimitReached``, + /// ``AuthError/validation(message:)`` und Netzwerk-Cases. + func register( + email: String, + password: String, + name: String? = nil, + sourceAppUrl: URL? = nil + ) async throws { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !password.isEmpty else { + throw AuthError.validation(message: "Email und Passwort sind erforderlich") + } + + let body = RegisterRequest( + email: trimmed, + password: password, + name: name, + sourceAppUrl: sourceAppUrl?.absoluteString + ) + let (data, http) = try await postJSON(path: "/api/v1/auth/register", body: body) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + + let decoded = try JSONDecoder().decode(RegisterResponse.self, from: data) + if let access = decoded.accessToken, let refresh = decoded.refreshToken { + try persistSession(email: trimmed, accessToken: access, refreshToken: refresh) + CoreLog.auth.info("Register successful — auto-signed-in") + } else { + CoreLog.auth.info("Register successful — awaiting email verification") + } + } + + // MARK: - Passwort-Reset + + /// Fordert eine Passwort-Reset-Email an. + /// + /// Der Server antwortet **immer mit 200**, unabhängig davon ob die + /// Email existiert (bewusst, um User-Enumeration zu verhindern). + /// Die UI sollte daher generisch melden ("Wenn dein Account existiert, + /// ist eine Email unterwegs"). + /// + /// - Parameters: + /// - email: Email-Adresse des Accounts. + /// - resetUniversalLink: Universal-Link der App, der die + /// Reset-Seite öffnet. Z.B. `https://cardecky.mana.how/auth/reset`. + /// Der Server hängt `?token=…` an und nutzt diesen Link im + /// Mail-Template. + /// + /// - Throws: nur Netzwerk-Fehler. Server-Fehler werden vom Server + /// geschluckt (200 trotzdem). + func forgotPassword(email: String, resetUniversalLink: URL) async throws { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw AuthError.validation(message: "Email ist erforderlich") + } + + let body = ForgotPasswordRequest(email: trimmed, redirectTo: resetUniversalLink.absoluteString) + let (data, http) = try await postJSON(path: "/api/v1/auth/forgot-password", body: body) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + CoreLog.auth.info("Password reset requested") + } + + /// Setzt das Passwort mit einem Reset-Token aus der Reset-Email. + /// + /// - Parameters: + /// - token: Reset-Token aus der Email (Query-Param `?token=…`). + /// - newPassword: Neues Klartext-Passwort. + /// + /// Nach Erfolg ist der User **nicht** automatisch eingeloggt — der + /// `signIn(...)`-Call muss separat passieren. + /// + /// - Throws: ``AuthError/tokenExpired``, ``AuthError/tokenInvalid``, + /// ``AuthError/weakPassword(message:)`` und Netzwerk-Cases. + func resetPassword(token: String, newPassword: String) async throws { + guard !token.isEmpty, !newPassword.isEmpty else { + throw AuthError.validation(message: "Token und neues Passwort sind erforderlich") + } + + let body = ResetPasswordRequest(token: token, newPassword: newPassword) + let (data, http) = try await postJSON(path: "/api/v1/auth/reset-password", body: body) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + CoreLog.auth.info("Password reset completed") + } + + // MARK: - Email-Verifikation + + /// Sendet die Email-Verifikations-Mail erneut. + /// + /// Aufzurufen wenn `signIn(...)` mit ``AuthError/emailNotVerified`` + /// zurückkommt — die UI bietet einen "Mail erneut senden"-Button. + /// + /// - Parameters: + /// - email: Email-Adresse des Accounts. + /// - sourceAppUrl: Universal-Link für den Verify-Klick. Gleiche + /// Semantik wie bei ``register(email:password:name:sourceAppUrl:)``. + /// + /// - Throws: ``AuthError/notFound`` wenn die Email nicht existiert, + /// ``AuthError/rateLimited(retryAfter:)`` bei zu vielen Versuchen. + func resendVerification(email: String, sourceAppUrl: URL? = nil) async throws { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw AuthError.validation(message: "Email ist erforderlich") + } + + let body = ResendVerificationRequest( + email: trimmed, + sourceAppUrl: sourceAppUrl?.absoluteString + ) + let (data, http) = try await postJSON(path: "/api/v1/auth/resend-verification", body: body) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + CoreLog.auth.info("Verification email resent") + } + + // MARK: - Account-Management (erfordert eingeloggte Session) + + /// Ändert die Email des aktuell eingeloggten Accounts. + /// + /// Der Server schickt eine Verifikations-Mail an die **neue** Adresse. + /// Bis der User klickt, bleibt die alte Email aktiv. + /// + /// - Parameters: + /// - newEmail: Neue Email-Adresse. + /// - callbackUniversalLink: Universal-Link, der nach erfolgter + /// Verifikation geöffnet wird (z.B. + /// `https://cardecky.mana.how/auth/email-changed`). + /// + /// - Important: Aktuell server-seitig nicht Bearer-fähig — siehe + /// Doc-Header dieser Datei. + func changeEmail(newEmail: String, callbackUniversalLink: URL? = nil) async throws { + let trimmed = newEmail.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw AuthError.validation(message: "Neue Email ist erforderlich") + } + + let body = ChangeEmailRequest( + newEmail: trimmed, + callbackURL: callbackUniversalLink?.absoluteString + ) + let (data, http) = try await postJSON( + path: "/api/v1/auth/change-email", + body: body, + authenticated: true + ) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + CoreLog.auth.info("Email change requested — verification email sent") + } + + /// Ändert das Passwort des aktuell eingeloggten Accounts. + /// + /// - Parameters: + /// - currentPassword: Aktuelles Klartext-Passwort (Re-Auth). + /// - newPassword: Neues Klartext-Passwort. + /// + /// - Important: Aktuell server-seitig nicht Bearer-fähig — siehe + /// Doc-Header dieser Datei. + func changePassword(currentPassword: String, newPassword: String) async throws { + guard !currentPassword.isEmpty, !newPassword.isEmpty else { + throw AuthError.validation(message: "Aktuelles und neues Passwort sind erforderlich") + } + + let body = ChangePasswordRequest(currentPassword: currentPassword, newPassword: newPassword) + let (data, http) = try await postJSON( + path: "/api/v1/auth/change-password", + body: body, + authenticated: true + ) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + CoreLog.auth.info("Password changed") + } + + /// Löscht den aktuell eingeloggten Account vollständig. + /// + /// Server löscht alle User-Daten (Auth, Credits, Sync-DB-Records). + /// Bei Erfolg wird der lokale Keychain gewiped und der Status auf + /// `.signedOut` gesetzt. + /// + /// **App-Store-Pflicht 5.1.1(v):** jede App mit Account-Erstellung + /// muss eine Account-Löschung anbieten. + /// + /// - Parameter password: Aktuelles Klartext-Passwort als Re-Auth. + /// + /// - Important: Aktuell server-seitig nicht Bearer-fähig — siehe + /// Doc-Header dieser Datei. + func deleteAccount(password: String) async throws { + guard !password.isEmpty else { + throw AuthError.validation(message: "Passwort ist erforderlich") + } + + let body = DeleteAccountRequest(password: password) + let (data, http) = try await postJSON( + path: "/api/v1/auth/account", + method: "DELETE", + body: body, + authenticated: true + ) + guard http.statusCode == 200 else { + throw AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + } + clearSession() + CoreLog.auth.notice("Account deleted") + } +} + +// MARK: - Private Helpers + +extension AuthClient { + /// Generischer JSON-POST/DELETE-Helper. Wenn `authenticated == true`, + /// wird der aktuelle Bearer-Token mitgeschickt. + fileprivate func postJSON( + path: String, + method: String = "POST", + body: Body, + authenticated: Bool = false + ) async throws -> (Data, HTTPURLResponse) { + let url = config.authBaseURL.appending(path: path) + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if authenticated { + let token = try currentAccessToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + do { + request.httpBody = try JSONEncoder().encode(body) + } catch { + throw AuthError.encoding + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AuthError.networkFailure("Keine HTTP-Antwort") + } + return (data, http) + } catch let error as URLError { + throw AuthError.networkFailure(error.localizedDescription) + } catch let error as AuthError { + throw error + } catch { + throw AuthError.networkFailure(String(describing: error)) + } + } +} + +// MARK: - Wire-Format + +private struct RegisterRequest: Encodable { + let email: String + let password: String + let name: String? + let sourceAppUrl: String? +} + +/// Server-Antwort auf `/register`. Tokens sind optional weil +/// `requireEmailVerification: true` (Default) keine Session liefert. +private struct RegisterResponse: Decodable { + let user: RegisterUser? + let accessToken: String? + let refreshToken: String? +} + +private struct RegisterUser: Decodable { + let id: String + let email: String? +} + +private struct ForgotPasswordRequest: Encodable { + let email: String + let redirectTo: String +} + +private struct ResetPasswordRequest: Encodable { + let token: String + let newPassword: String +} + +private struct ResendVerificationRequest: Encodable { + let email: String + let sourceAppUrl: String? +} + +private struct ChangeEmailRequest: Encodable { + let newEmail: String + let callbackURL: String? +} + +private struct ChangePasswordRequest: Encodable { + let currentPassword: String + let newPassword: String +} + +private struct DeleteAccountRequest: Encodable { + let password: String +} diff --git a/Sources/ManaCore/Auth/AuthClient.swift b/Sources/ManaCore/Auth/AuthClient.swift index 943db71..c11ab2e 100644 --- a/Sources/ManaCore/Auth/AuthClient.swift +++ b/Sources/ManaCore/Auth/AuthClient.swift @@ -6,6 +6,11 @@ import Observation /// /// Eine Instanz pro App, beim App-Start initialisiert. `bootstrap()` /// füllt den Status aus dem Keychain (offline möglich). +/// +/// Die Methoden für Sign-Up, Passwort-Reset, Account-Management etc. +/// leben in ``AuthClient+Account`` — gleiche Klasse, separate Datei +/// damit dieser File die Status-Maschine und den Login/Logout/Refresh- +/// Kern hält. @MainActor @Observable public final class AuthClient { @@ -19,9 +24,19 @@ public final class AuthClient { public private(set) var status: Status = .unknown - private let config: ManaAppConfig - private let keychain: KeychainStore - private let session: URLSession + /// Strukturierter Fehler des letzten Sign-In-/Refresh-/Account- + /// Operations. Wird beim nächsten `signIn(...)`-Aufruf gelöscht. + /// + /// Ergänzt `status == .error(String)`: während `status` einen für + /// die UI angezeigten Text trägt (deutsche Lokalisierung), liefert + /// `lastError` den klassifizierten ``AuthError``-Case — z.B. um + /// `.emailNotVerified` programmatisch zu erkennen und den Resend- + /// Mail-Gate freizuschalten. + public private(set) var lastError: AuthError? + + let config: ManaAppConfig + let keychain: KeychainStore + let session: URLSession public init(config: ManaAppConfig, session: URLSession = .shared) { self.config = config @@ -47,10 +62,13 @@ public final class AuthClient { public func signIn(email: String, password: String) async { let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !password.isEmpty else { - status = .error("Email und Passwort sind erforderlich") + let err = AuthError.validation(message: "Email und Passwort sind erforderlich") + lastError = err + status = .error(err.errorDescription ?? "") return } + lastError = nil status = .signingIn do { let url = config.authBaseURL.appending(path: "/api/v1/auth/login") @@ -65,13 +83,13 @@ public final class AuthClient { return } guard http.statusCode == 200 else { - let message = (try? JSONDecoder().decode(ServerError.self, from: data))?.message - if http.statusCode == 401 { - status = .error("Email oder Passwort falsch") - } else { - status = - .error("Login fehlgeschlagen (HTTP \(http.statusCode))" + (message.map { " — \($0)" } ?? "")) - } + let err = AuthError.classify( + status: http.statusCode, + data: data, + retryAfterHeader: http.retryAfterSeconds + ) + lastError = err + status = .error(err.errorDescription ?? "Login fehlgeschlagen") return } @@ -80,12 +98,17 @@ public final class AuthClient { try keychain.setString(token.refreshToken, for: .refreshToken) try keychain.setString(trimmed, for: .email) status = .signedIn(email: trimmed) + lastError = nil CoreLog.auth.info("Sign-in successful") } catch let error as URLError { - status = .error("Netzwerk: \(error.localizedDescription)") + let err = AuthError.networkFailure(error.localizedDescription) + lastError = err + status = .error(err.errorDescription ?? "Netzwerk-Fehler") CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)") } catch { - status = .error(String(describing: error)) + let err = AuthError.networkFailure(String(describing: error)) + lastError = err + status = .error(err.errorDescription ?? String(describing: error)) CoreLog.auth.error("Sign-in error: \(String(describing: error), privacy: .public)") } } @@ -138,9 +161,10 @@ public final class AuthClient { guard http.statusCode == 200 else { keychain.wipe() status = .signedOut - throw AuthError.serverError( + throw AuthError.classify( status: http.statusCode, - message: (try? JSONDecoder().decode(ServerError.self, from: data))?.message + data: data, + retryAfterHeader: http.retryAfterSeconds ) } @@ -151,22 +175,61 @@ public final class AuthClient { } return token.accessToken } + + // MARK: - Internal Helpers (used by AuthClient+Account) + + /// Persistiert ein frisch erhaltenes Token-Paar und setzt den Status + /// auf `.signedIn`. Genutzt von ``AuthClient+Account``-Flows, die + /// in den eingeloggten Zustand führen (z.B. `register` wenn der + /// Server bereits eine Session liefert). + func persistSession(email: String, accessToken: String, refreshToken: String) throws { + try keychain.setString(accessToken, for: .accessToken) + try keychain.setString(refreshToken, for: .refreshToken) + try keychain.setString(email, for: .email) + status = .signedIn(email: email) + } + + /// Setzt den Status auf `.signedOut` und wirft den Keychain leer. + /// Genutzt nach `deleteAccount()`. + func clearSession() { + keychain.wipe() + status = .signedOut + } } -private struct LoginRequest: Encodable { +// MARK: - Wire-Format + +struct LoginRequest: Encodable { let email: String let password: String } -private struct RefreshRequest: Encodable { +struct RefreshRequest: Encodable { let refreshToken: String } -private struct TokenResponse: Decodable { +/// Antwort von `/login`, `/refresh` und (manchmal) `/register`. +/// +/// `register` liefert je nach Server-Konfig: +/// - mit `requireEmailVerification: true` (Default): nur `user`, keine Tokens +/// - mit `false`: vollständiges Token-Paar +/// +/// Optionale Felder sind explizit `Optional` damit beide Pfade dekodierbar sind. +struct TokenResponse: Decodable { let accessToken: String let refreshToken: String } -private struct ServerError: Decodable { - let message: String? +extension HTTPURLResponse { + /// Liest `Retry-After` als Anzahl Sekunden. Server schickt Integer- + /// Sekunden (siehe `lib/auth-errors.ts`). HTTP-Datum-Variante wird + /// nicht unterstützt — mana-auth nutzt sie nicht. + var retryAfterSeconds: TimeInterval? { + guard let raw = value(forHTTPHeaderField: "Retry-After"), + let seconds = TimeInterval(raw) + else { + return nil + } + return seconds + } } diff --git a/Sources/ManaCore/Auth/AuthError.swift b/Sources/ManaCore/Auth/AuthError.swift index 4a686fe..14ed675 100644 --- a/Sources/ManaCore/Auth/AuthError.swift +++ b/Sources/ManaCore/Auth/AuthError.swift @@ -1,31 +1,205 @@ import Foundation /// Fehler-Typen des Auth- und Transport-Layers. -public enum AuthError: Error, LocalizedError, Sendable { +/// +/// Server-Fehler-Codes folgen `AuthErrorCode` aus +/// `mana/services/mana-auth/src/lib/auth-errors.ts`. Jeder dort +/// definierte Code hat hier einen passenden Case — neue Codes +/// auf Server-Seite brauchen einen Minor-Bump dieses Pakets. +public enum AuthError: Error, LocalizedError, Sendable, Equatable { + // MARK: - Client-Side + + /// Kein Token im Keychain — User muss sich einloggen. case notSignedIn - case invalidCredentials - case networkFailure(String) - case serverError(status: Int, message: String?) - case decoding(String) - case keychain(OSStatus) + /// Lokales Encoding (z.B. UTF-8-Konvertierung) gescheitert. case encoding + /// Keychain-Operation gescheitert mit OSStatus. + case keychain(OSStatus) + /// Antwort konnte nicht dekodiert werden. + case decoding(String) + /// Netzwerk-Schicht hat geworfen. + case networkFailure(String) + + // MARK: - Server-Side (Mapping zu AuthErrorCode in mana-auth) + + /// `INVALID_CREDENTIALS` — Email oder Passwort falsch. + case invalidCredentials + /// `EMAIL_NOT_VERIFIED` — Account existiert, Email muss bestätigt + /// werden. UI bietet "Bestätigungs-Mail erneut senden" an. + case emailNotVerified + /// `EMAIL_ALREADY_REGISTERED` — Sign-Up mit existierender Email. + case emailAlreadyRegistered + /// `WEAK_PASSWORD` — Passwort erfüllt Mindest-Policy nicht. + case weakPassword(message: String?) + /// `ACCOUNT_LOCKED` — zu viele Fehlversuche. `retryAfter` in Sekunden. + case accountLocked(retryAfter: TimeInterval?) + /// `SIGNUP_LIMIT_REACHED` — tägliches Registrierungs-Limit aus. + case signupLimitReached + /// `RATE_LIMITED` — generelles Rate-Limit. `retryAfter` in Sekunden. + case rateLimited(retryAfter: TimeInterval?) + /// `TOKEN_EXPIRED` — z.B. Reset-Token, Verify-Token. + case tokenExpired + /// `TOKEN_INVALID` — Token nicht parsebar / nicht gefunden. + case tokenInvalid + /// `TWO_FACTOR_REQUIRED` — Login erfolgreich, aber 2FA-Challenge offen. + case twoFactorRequired + /// `TWO_FACTOR_FAILED` — TOTP- oder Backup-Code falsch. + case twoFactorFailed + /// `PASSKEY_NOT_ENABLED` — User hat keinen Passkey registriert. + case passkeyNotEnabled + /// `PASSKEY_CANCELLED` — User hat den Plattform-Dialog abgebrochen. + case passkeyCancelled + /// `PASSKEY_VERIFICATION_FAILED` — WebAuthn-Verifikation gescheitert. + case passkeyVerificationFailed + /// `VALIDATION` — Request-Body ungültig (z.B. fehlendes Feld). + case validation(message: String?) + /// `UNAUTHORIZED` — fehlender oder ungültiger Authorization-Header. + case unauthorized + /// `NOT_FOUND` — referenzierte Ressource existiert nicht. + case notFound + /// `SERVICE_UNAVAILABLE` — Server temporär nicht erreichbar. + case serviceUnavailable + /// `INTERNAL` — Server-interner Fehler ohne weitere Klassifikation. + case serverInternal + + /// Fallback für Server-Fehler ohne bekannten `AuthErrorCode`. + case serverError(status: Int, code: String?, message: String?) public var errorDescription: String? { switch self { case .notSignedIn: "Nicht angemeldet" - case .invalidCredentials: - "Ungültige Anmeldedaten" - case let .networkFailure(message): - "Netzwerkfehler: \(message)" - case let .serverError(status, message): - "Server-Fehler (\(status))" + (message.map { ": \($0)" } ?? "") - case let .decoding(detail): - "Antwort konnte nicht gelesen werden: \(detail)" - case let .keychain(status): - "Keychain-Fehler (OSStatus \(status))" case .encoding: "Datenkodierung fehlgeschlagen" + case let .keychain(status): + "Keychain-Fehler (OSStatus \(status))" + case let .decoding(detail): + "Antwort konnte nicht gelesen werden: \(detail)" + case let .networkFailure(message): + "Netzwerkfehler: \(message)" + case .invalidCredentials: + "Email oder Passwort falsch" + case .emailNotVerified: + "Bitte bestätige deine Email-Adresse, bevor du dich anmeldest." + case .emailAlreadyRegistered: + "Diese Email ist bereits registriert." + case let .weakPassword(message): + message ?? "Passwort erfüllt die Mindest-Anforderungen nicht." + case let .accountLocked(retryAfter): + retryAfter + .map { "Account temporär gesperrt. Erneut versuchen in \(Int($0))s." } + ?? "Account temporär gesperrt." + case .signupLimitReached: + "Das tägliche Registrierungslimit ist erreicht. Versuche es morgen wieder." + case let .rateLimited(retryAfter): + retryAfter + .map { "Zu viele Versuche. Bitte warte \(Int($0))s." } + ?? "Zu viele Versuche. Bitte warte einen Moment." + case .tokenExpired: + "Der Link ist abgelaufen. Bitte fordere einen neuen an." + case .tokenInvalid: + "Der Link ist ungültig." + case .twoFactorRequired: + "Zwei-Faktor-Code erforderlich." + case .twoFactorFailed: + "Zwei-Faktor-Code falsch." + case .passkeyNotEnabled: + "Für diesen Account ist kein Passkey eingerichtet." + case .passkeyCancelled: + "Passkey-Eingabe abgebrochen." + case .passkeyVerificationFailed: + "Passkey konnte nicht verifiziert werden." + case let .validation(message): + message ?? "Eingabe ungültig." + case .unauthorized: + "Nicht autorisiert." + case .notFound: + "Nicht gefunden." + case .serviceUnavailable: + "Server temporär nicht erreichbar. Bitte später erneut versuchen." + case .serverInternal: + "Server-Fehler. Bitte später erneut versuchen." + case let .serverError(status, code, message): + "Server-Fehler (\(status))" + + (code.map { " — \($0)" } ?? "") + + (message.map { ": \($0)" } ?? "") + } + } + + /// Klassifiziert eine `mana-auth`-Fehler-Antwort. `data` ist der + /// Response-Body, `status` der HTTP-Status, `retryAfterHeader` der + /// `Retry-After`-Header-Wert in Sekunden (falls vorhanden). + /// + /// Mapping zu `AuthErrorCode` in + /// `mana/services/mana-auth/src/lib/auth-errors.ts`. Unbekannte Codes + /// fallen auf ``serverError(status:code:message:)`` zurück. + public static func classify( + status: Int, + data: Data, + retryAfterHeader: TimeInterval? = nil + ) -> AuthError { + let body = try? JSONDecoder().decode(ServerErrorBody.self, from: data) + let message = body?.message + let retryAfter = body?.retryAfterSec ?? retryAfterHeader + + switch body?.error { + case "INVALID_CREDENTIALS": + return .invalidCredentials + case "EMAIL_NOT_VERIFIED": + return .emailNotVerified + case "EMAIL_ALREADY_REGISTERED": + return .emailAlreadyRegistered + case "WEAK_PASSWORD": + return .weakPassword(message: message) + case "ACCOUNT_LOCKED": + return .accountLocked(retryAfter: retryAfter) + case "SIGNUP_LIMIT_REACHED": + return .signupLimitReached + case "RATE_LIMITED": + return .rateLimited(retryAfter: retryAfter) + case "TOKEN_EXPIRED": + return .tokenExpired + case "TOKEN_INVALID": + return .tokenInvalid + case "TWO_FACTOR_REQUIRED": + return .twoFactorRequired + case "TWO_FACTOR_FAILED": + return .twoFactorFailed + case "PASSKEY_NOT_ENABLED": + return .passkeyNotEnabled + case "PASSKEY_CANCELLED": + return .passkeyCancelled + case "PASSKEY_VERIFICATION_FAILED": + return .passkeyVerificationFailed + case "VALIDATION": + return .validation(message: message) + case "UNAUTHORIZED": + return .unauthorized + case "NOT_FOUND": + return .notFound + case "SERVICE_UNAVAILABLE": + return .serviceUnavailable + case "INTERNAL": + return .serverInternal + default: + // Status-Heuristik wenn kein Code geliefert wurde. + switch status { + case 401: return .invalidCredentials + case 403: return .emailNotVerified + case 404: return .notFound + case 429: return .rateLimited(retryAfter: retryAfter) + case 503: return .serviceUnavailable + default: return .serverError(status: status, code: body?.error, message: message) + } } } } + +/// Wire-Format der `mana-auth`-Fehler-Antwort. Spiegelt +/// `AuthErrorResponseBody` aus `lib/auth-errors.ts`. +struct ServerErrorBody: Decodable, Sendable { + let error: String? + let message: String? + let status: Int? + let retryAfterSec: TimeInterval? +} diff --git a/Tests/ManaCoreTests/AuthClientAccountTests.swift b/Tests/ManaCoreTests/AuthClientAccountTests.swift new file mode 100644 index 0000000..cd743c0 --- /dev/null +++ b/Tests/ManaCoreTests/AuthClientAccountTests.swift @@ -0,0 +1,320 @@ +import Foundation +import Testing +@testable import ManaCore + +@Suite("AuthClient+Account", .serialized) +@MainActor +struct AuthClientAccountTests { + // MARK: - Fixtures + + private static func makeClient() -> (AuthClient, URLSession) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: configuration) + + let config = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.test")!, + keychainService: "ev.mana.test.\(UUID().uuidString)", + keychainAccessGroup: nil + ) + return (AuthClient(config: config, session: session), session) + } + + private func recordedBody(_ request: URLRequest) -> [String: Any] { + // URLSession verschiebt den Body in den BodyStream wenn er nicht + // klein-genug ist — ephemeral-Session lässt ihn als httpBody. + guard let body = request.httpBody ?? request.bodyStreamData(), + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { return [:] } + return json + } + + // MARK: - register + + @Test("register schickt POST /api/v1/auth/register mit JSON-Body") + func registerSendsCorrectRequest() async throws { + let (client, _) = Self.makeClient() + MockURLProtocol.handler = { request in + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/v1/auth/register") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + return (200, Data(#"{"user":{"id":"u1","email":"new@x.de"}}"#.utf8)) + } + + try await client.register( + email: "new@x.de", + password: "Aa-123456789", + name: "Neu", + sourceAppUrl: URL(string: "https://cardecky.mana.how/auth/verify") + ) + // Mit requireEmailVerification:true gibt es noch keine Session. + if case .signedIn = client.status { + Issue.record("Expected not-signed-in after register without tokens") + } + } + + @Test("register mit Token-Antwort führt direkt zu signedIn") + func registerWithTokensSignsIn() async throws { + let (client, _) = Self.makeClient() + // gültiger HS256-Header.payload (exp 2_000_000_000).sig — JWT.expiry() + // läuft danach nicht in den Refresh-Pfad. + let access = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + let refresh = "refresh-token-value" + MockURLProtocol.handler = { _ in + (200, Data(#""" + {"user":{"id":"u1","email":"new@x.de"},"accessToken":"\#(access)","refreshToken":"\#(refresh)"} + """#.utf8)) + } + + try await client.register(email: "new@x.de", password: "Aa-123456789") + #expect(client.status == .signedIn(email: "new@x.de")) + } + + @Test("register mit existierender Email wirft emailAlreadyRegistered") + func registerEmailAlreadyRegistered() async { + let (client, _) = Self.makeClient() + MockURLProtocol.handler = { _ in + (409, Data(#"{"error":"EMAIL_ALREADY_REGISTERED","status":409}"#.utf8)) + } + + await #expect(throws: AuthError.emailAlreadyRegistered) { + try await client.register(email: "old@x.de", password: "Aa-123456789") + } + } + + @Test("register mit leerer Email wirft validation ohne Server-Call") + func registerValidatesEmptyEmail() async { + let (client, _) = Self.makeClient() + MockURLProtocol.handler = { _ in + Issue.record("Server darf nicht aufgerufen werden") + return (500, Data()) + } + + await #expect(throws: (any Error).self) { + try await client.register(email: " ", password: "Aa-123456789") + } + } + + // MARK: - forgotPassword + + @Test("forgotPassword schickt email + redirectTo") + func forgotPasswordPayload() async throws { + let (client, _) = Self.makeClient() + let capturedURL = MockURLProtocol.Capture() + MockURLProtocol.handler = { request in + capturedURL.store(request) + return (200, Data(#"{"success":true}"#.utf8)) + } + + try await client.forgotPassword( + email: "user@x.de", + resetUniversalLink: URL(string: "https://cardecky.mana.how/auth/reset")! + ) + let captured = try #require(capturedURL.request) + let json = recordedBody(captured) + #expect(json["email"] as? String == "user@x.de") + #expect(json["redirectTo"] as? String == "https://cardecky.mana.how/auth/reset") + #expect(captured.url?.path == "/api/v1/auth/forgot-password") + } + + // MARK: - resetPassword + + @Test("resetPassword schickt token + newPassword") + func resetPasswordPayload() async throws { + let (client, _) = Self.makeClient() + let captured = MockURLProtocol.Capture() + MockURLProtocol.handler = { request in + captured.store(request) + return (200, Data(#"{"success":true}"#.utf8)) + } + + try await client.resetPassword(token: "tok123", newPassword: "Neu-987654321") + let request = try #require(captured.request) + let json = recordedBody(request) + #expect(json["token"] as? String == "tok123") + #expect(json["newPassword"] as? String == "Neu-987654321") + #expect(request.url?.path == "/api/v1/auth/reset-password") + } + + @Test("resetPassword mit abgelaufenem Token wirft tokenExpired") + func resetPasswordTokenExpired() async { + let (client, _) = Self.makeClient() + MockURLProtocol.handler = { _ in + (400, Data(#"{"error":"TOKEN_EXPIRED","status":400}"#.utf8)) + } + + await #expect(throws: AuthError.tokenExpired) { + try await client.resetPassword(token: "old", newPassword: "Neu-987654321") + } + } + + // MARK: - resendVerification + + @Test("resendVerification schickt email + sourceAppUrl") + func resendVerificationPayload() async throws { + let (client, _) = Self.makeClient() + let captured = MockURLProtocol.Capture() + MockURLProtocol.handler = { request in + captured.store(request) + return (200, Data(#"{"success":true}"#.utf8)) + } + + try await client.resendVerification( + email: "user@x.de", + sourceAppUrl: URL(string: "https://cardecky.mana.how/auth/verify") + ) + let request = try #require(captured.request) + let json = recordedBody(request) + #expect(json["email"] as? String == "user@x.de") + #expect(json["sourceAppUrl"] as? String == "https://cardecky.mana.how/auth/verify") + } + + @Test("resendVerification mit Rate-Limit liefert retryAfter") + func resendVerificationRateLimited() async { + let (client, _) = Self.makeClient() + MockURLProtocol.handler = { _ in + ( + 429, + Data(#"{"error":"RATE_LIMITED","retryAfterSec":42,"status":429}"#.utf8), + ["Retry-After": "42"] + ) + } + + do { + try await client.resendVerification(email: "user@x.de") + Issue.record("Expected throw") + } catch let AuthError.rateLimited(retryAfter) { + #expect(retryAfter == 42) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + // MARK: - Authenticated calls (require signed-in state) + + @Test("changeEmail ohne Login wirft notSignedIn") + func changeEmailRequiresSession() async { + let (client, _) = Self.makeClient() + await #expect(throws: AuthError.notSignedIn) { + try await client.changeEmail(newEmail: "neu@x.de") + } + } + + @Test("changePassword schickt Bearer-Header wenn eingeloggt") + func changePasswordSendsBearer() async throws { + let (client, _) = Self.makeClient() + // Mock-Token im Keychain ablegen via persistSession-Helper. + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + try client.persistSession(email: "u@x.de", accessToken: access, refreshToken: "r") + + let captured = MockURLProtocol.Capture() + MockURLProtocol.handler = { request in + captured.store(request) + return (200, Data(#"{"success":true}"#.utf8)) + } + + try await client.changePassword(currentPassword: "alt", newPassword: "neu") + let request = try #require(captured.request) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(access)") + #expect(request.url?.path == "/api/v1/auth/change-password") + } + + @Test("deleteAccount wiped Session bei Erfolg") + func deleteAccountClearsSession() async throws { + let (client, _) = Self.makeClient() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + try client.persistSession(email: "u@x.de", accessToken: access, refreshToken: "r") + #expect(client.status == .signedIn(email: "u@x.de")) + + MockURLProtocol.handler = { request in + #expect(request.httpMethod == "DELETE") + return (200, Data(#"{"success":true}"#.utf8)) + } + try await client.deleteAccount(password: "pw") + #expect(client.status == .signedOut) + } +} + +// MARK: - URLProtocol Mock + +final class MockURLProtocol: URLProtocol, @unchecked Sendable { + /// Antwort-Tuple: (status, body) oder (status, body, headers). + typealias Response = (status: Int, body: Data, headers: [String: String]) + typealias Handler = @Sendable (URLRequest) -> Any // (Int, Data) | (Int, Data, [String:String]) + + nonisolated(unsafe) static var handler: Handler? + + /// Thread-safer Capture-Container — Tests können den Request darin + /// festhalten und nach dem await aus dem Test-Body lesen. + final class Capture: @unchecked Sendable { + private let lock = NSLock() + private var stored: URLRequest? + + func store(_ r: URLRequest) { + lock.lock(); defer { lock.unlock() } + stored = r + } + + var request: URLRequest? { + lock.lock(); defer { lock.unlock() } + return stored + } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + guard let handler = MockURLProtocol.handler else { + client?.urlProtocol( + self, + didFailWithError: URLError(.unknown) + ) + return + } + + let result = handler(request) + let status: Int + let body: Data + let headers: [String: String] + if let tuple = result as? (Int, Data, [String: String]) { + status = tuple.0; body = tuple.1; headers = tuple.2 + } else if let tuple = result as? (Int, Data) { + status = tuple.0; body = tuple.1; headers = [:] + } else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: headers + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } +} + +extension URLRequest { + /// Liest httpBodyStream in einen Data. URLSession ephemeral-Session + /// nutzt manchmal Streams statt httpBody. + func bodyStreamData() -> Data? { + guard let stream = httpBodyStream else { return nil } + stream.open(); defer { stream.close() } + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/Tests/ManaCoreTests/AuthErrorClassifyTests.swift b/Tests/ManaCoreTests/AuthErrorClassifyTests.swift new file mode 100644 index 0000000..72f217a --- /dev/null +++ b/Tests/ManaCoreTests/AuthErrorClassifyTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing +@testable import ManaCore + +@Suite("AuthError.classify") +struct AuthErrorClassifyTests { + private func body(_ json: String) -> Data { + Data(json.utf8) + } + + @Test("Mapped INVALID_CREDENTIALS auf invalidCredentials") + func mapsInvalidCredentials() { + let err = AuthError.classify( + status: 401, + data: body(#"{"error":"INVALID_CREDENTIALS","message":"Email oder Passwort falsch","status":401}"#) + ) + #expect(err == .invalidCredentials) + } + + @Test("Mapped EMAIL_NOT_VERIFIED auf emailNotVerified") + func mapsEmailNotVerified() { + let err = AuthError.classify( + status: 403, + data: body(#"{"error":"EMAIL_NOT_VERIFIED","message":"Bitte bestätige…","status":403}"#) + ) + #expect(err == .emailNotVerified) + } + + @Test("Mapped EMAIL_ALREADY_REGISTERED auf emailAlreadyRegistered") + func mapsEmailAlreadyRegistered() { + let err = AuthError.classify( + status: 409, + data: body(#"{"error":"EMAIL_ALREADY_REGISTERED","status":409}"#) + ) + #expect(err == .emailAlreadyRegistered) + } + + @Test("WEAK_PASSWORD trägt Server-Message") + func weakPasswordCarriesMessage() { + let err = AuthError.classify( + status: 400, + data: body(#"{"error":"WEAK_PASSWORD","message":"Mindestens 8 Zeichen","status":400}"#) + ) + if case let .weakPassword(message) = err { + #expect(message == "Mindestens 8 Zeichen") + } else { + Issue.record("Expected .weakPassword, got \(err)") + } + } + + @Test("RATE_LIMITED nutzt retryAfterSec aus Body") + func rateLimitedFromBody() { + let err = AuthError.classify( + status: 429, + data: body(#"{"error":"RATE_LIMITED","retryAfterSec":30,"status":429}"#) + ) + if case let .rateLimited(retryAfter) = err { + #expect(retryAfter == 30) + } else { + Issue.record("Expected .rateLimited(30), got \(err)") + } + } + + @Test("RATE_LIMITED fällt auf Retry-After-Header zurück") + func rateLimitedFromHeader() { + let err = AuthError.classify( + status: 429, + data: body(#"{"error":"RATE_LIMITED","status":429}"#), + retryAfterHeader: 60 + ) + if case let .rateLimited(retryAfter) = err { + #expect(retryAfter == 60) + } else { + Issue.record("Expected .rateLimited(60), got \(err)") + } + } + + @Test("ACCOUNT_LOCKED erhält retryAfter") + func accountLockedRetryAfter() { + let err = AuthError.classify( + status: 423, + data: body(#"{"error":"ACCOUNT_LOCKED","retryAfterSec":120,"status":423}"#) + ) + if case let .accountLocked(retryAfter) = err { + #expect(retryAfter == 120) + } else { + Issue.record("Expected .accountLocked(120), got \(err)") + } + } + + @Test("SIGNUP_LIMIT_REACHED") + func signupLimitReached() { + let err = AuthError.classify( + status: 429, + data: body(#"{"error":"SIGNUP_LIMIT_REACHED","status":429}"#) + ) + #expect(err == .signupLimitReached) + } + + @Test("TOKEN_EXPIRED und TOKEN_INVALID werden unterschieden") + func tokenStates() { + #expect( + AuthError.classify(status: 400, data: body(#"{"error":"TOKEN_EXPIRED"}"#)) + == .tokenExpired + ) + #expect( + AuthError.classify(status: 400, data: body(#"{"error":"TOKEN_INVALID"}"#)) + == .tokenInvalid + ) + } + + @Test("VALIDATION trägt Message") + func validationCarriesMessage() { + let err = AuthError.classify( + status: 400, + data: body(#"{"error":"VALIDATION","message":"email is required","status":400}"#) + ) + if case let .validation(message) = err { + #expect(message == "email is required") + } else { + Issue.record("Expected .validation, got \(err)") + } + } + + @Test("Unbekannter Code mit Status 401 fällt auf invalidCredentials") + func unknownCodeStatusHeuristic401() { + let err = AuthError.classify( + status: 401, + data: body(#"{}"#) + ) + #expect(err == .invalidCredentials) + } + + @Test("Unbekannter Code mit Status 503 fällt auf serviceUnavailable") + func unknownCodeStatusHeuristic503() { + let err = AuthError.classify( + status: 503, + data: body(#"{}"#) + ) + #expect(err == .serviceUnavailable) + } + + @Test("Komplett kaputter Body führt zu serverError mit Status") + func brokenBodyFallback() { + let err = AuthError.classify( + status: 500, + data: body("nicht-json") + ) + if case let .serverError(status, code, _) = err { + #expect(status == 500) + #expect(code == nil) + } else { + Issue.record("Expected .serverError(500), got \(err)") + } + } + + @Test("errorDescription liefert deutsche Strings") + func germanErrorDescriptions() { + #expect(AuthError.invalidCredentials.errorDescription == "Email oder Passwort falsch") + #expect(AuthError.emailAlreadyRegistered.errorDescription == "Diese Email ist bereits registriert.") + #expect( + AuthError.rateLimited(retryAfter: 30).errorDescription + == "Zu viele Versuche. Bitte warte 30s." + ) + } +} +