diff --git a/CHANGELOG.md b/CHANGELOG.md index a087aa9..7ebbbce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,77 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an [Keep a Changelog](https://keepachangelog.com), Versionierung nach [Semver](https://semver.org). +## [1.2.0] — 2026-05-13 + +Minor — Guest-Mode + Auth-Resilience. Native-Apps werden gegen mana-auth- +Downtime gehärtet und können jetzt einen anonymen Local-First-Modus +anbieten. Komplett additiv — keine Breaking Changes für bestehende +Konsumenten (Memoro, Cards, Manaspur, Nutriphi). + +### ManaCore — Guest-Identität + +- `AuthClient.Status` um Case `.guest(id: String)` erweitert. Persistente + lokale UUID ohne Server-Account; gleichberechtigt mit `.signedIn` als + „App ist nutzbar"-Zustand. Apps können in diesem Modus alles Lokale + und alle unauthenticated-Server-Endpoints anbieten, schreibende + Endpoints poppen Auth-Sheet. +- `AuthClient.enterGuestMode() throws -> String` — idempotent, erzeugt + oder reuse die Guest-UUID aus Keychain. Wechselt den Status nur, + wenn aktuell `.signedOut`/`.unknown` (eine aktive Session bleibt + unangetastet, App kann die Guest-ID parallel lesen). +- `AuthClient.currentGuestId() -> String?` — Lookup unabhängig vom Status. + Genutzt z.B. um lokale Guest-Daten beim Sign-In dem neuen Server- + Account zuzuordnen. +- `AuthClient.clearGuestId()` — entfernt die Guest-ID, etwa nach + erfolgreicher Migration der lokalen Daten auf einen Server-Account. +- `AuthClient.signOut(keepGuestMode: Bool = false)` — Default-Verhalten + unverändert (`false` löscht alles, Status `.signedOut`). Mit `true` + bleibt die App im anonymen Modus weiter nutzbar. +- `KeychainStore.Key.guestId` als neuer Key. `wipe()` löscht jetzt + *nur* Session-Felder (accessToken/refreshToken/email) — die Guest-ID + überlebt. Für komplettes Vergessen: neue `wipeAll()`. + +### ManaCore — Refresh-Resilience + +- `refreshAccessToken()` wipt nicht mehr blind den Keychain bei jedem + Nicht-200. Stattdessen Heuristik via `AuthError.invalidatesSession`: + - **Wipe** bei `.invalidCredentials`, `.unauthorized`, `.tokenExpired`, + `.tokenInvalid`, `.emailNotVerified` — Session ist tatsächlich tot. + - **Behalten** bei `.serviceUnavailable` (503), `.serverInternal` + (500), `.networkFailure`, `.rateLimited`, weiteren transienten + Fehlern. Apps werden bei mana-auth-Downtime nicht mehr in den + Login-Screen geworfen. +- Beim Wipe-Pfad fällt der Status auf `.guest(id)` zurück, falls eine + Guest-Identität existiert; sonst auf `.signedOut`. +- `AuthError.invalidatesSession: Bool` — public computed Property, + auch von Apps direkt nutzbar (z.B. um auf Transport-Fehler zu + reagieren). + +### Tests + +- 15 neue Tests: Guest-Mode (Idempotenz, Bootstrap-Priorität, Status- + Übergänge), signOut(keepGuestMode:) in beiden Modi, Refresh-Verhalten + bei 401/429/500/503/Network, invalidatesSession-Partitionierung. + +### Migration für Apps + +Bestehende Apps brauchen **keine** Änderung — Default-Verhalten ist +identisch. Wer den anonymen Modus nutzen will: + +```swift +// Beim App-Start nach bootstrap(): +auth.bootstrap() +if case .signedOut = auth.status { + try? auth.enterGuestMode() // Statt sofort Login-Screen +} + +// In Aktionen, die einen Account brauchen: +guard case .signedIn = auth.status else { + presentLoginSheet() + return +} +``` + ## [1.1.1] — 2026-05-13 Patch — Wire-Konvention für authenticated Account-Calls geklärt. diff --git a/Sources/ManaCore/Auth/AuthClient.swift b/Sources/ManaCore/Auth/AuthClient.swift index 4b2de5c..34ddc4e 100644 --- a/Sources/ManaCore/Auth/AuthClient.swift +++ b/Sources/ManaCore/Auth/AuthClient.swift @@ -15,8 +15,18 @@ import Observation @Observable public final class AuthClient { public enum Status: Equatable, Sendable { + /// Vor `bootstrap()` — Keychain noch nicht ausgewertet. case unknown + /// Kein Account, keine Guest-Identität. UI sollte Login/Sign-Up + /// anbieten oder via `enterGuestMode()` einen anonymen Modus + /// starten. case signedOut + /// Anonyme lokale Identität ohne Server-Account. Apps können + /// lokale Daten unter dieser UUID persistieren und sie später + /// beim Sign-In einem Server-Account zuordnen. Read-only- + /// Server-Endpoints, die keinen User brauchen, sind in diesem + /// Status erreichbar; schreibende Endpoints nicht. + case guest(id: String) case signingIn case signedIn(email: String) case error(String) @@ -49,16 +59,73 @@ public final class AuthClient { return nil } + /// Liest den persistierten Auth-Zustand aus dem Keychain. Reihenfolge: + /// vollwertige Session > Guest-Identität > komplett ausgeloggt. + /// + /// Keine Server-Roundtrips — Apps starten offline-fähig und mana-auth- + /// Downtime kann den letzten gültigen Zustand nicht überschreiben. + /// Token-Gültigkeit wird erst beim nächsten authentifizierten Call + /// geprüft (siehe ``refreshAccessToken()``). public func bootstrap() { let email = keychain.getString(for: .email) let hasToken = keychain.getString(for: .accessToken) != nil if let email, hasToken { status = .signedIn(email: email) + } else if let guestId = keychain.getString(for: .guestId) { + status = .guest(id: guestId) } else { status = .signedOut } } + /// Aktive Guest-Identität, falls vorhanden. Persistiert über + /// App-Starts hinweg. Existiert sowohl im ``Status/guest(id:)`` als + /// auch parallel zu einer eingeloggten Session — letzteres erlaubt + /// es einer App, beim Sign-In die zuvor lokal gesammelten anonymen + /// Daten dem neuen Server-Account zuzuordnen. + public func currentGuestId() -> String? { + keychain.getString(for: .guestId) + } + + /// Setzt die App in den anonymen Modus. Idempotent: liefert eine + /// bestehende Guest-ID zurück, wenn schon eine im Keychain liegt; + /// sonst wird eine neue UUID erzeugt und persistiert. + /// + /// Ändert den Status nur, wenn aktuell `.signedOut` oder `.unknown` + /// vorliegt. Bei aktiver Session (`.signedIn`/`.signingIn`) bleibt + /// der Status unverändert — die App kann trotzdem `currentGuestId()` + /// nutzen, um die anonyme ID zu lesen, ohne die Session zu stören. + /// + /// - Throws: ``AuthError/keychain(_:)`` wenn der Keychain-Write + /// scheitert. + @discardableResult + public func enterGuestMode() throws -> String { + if let existing = keychain.getString(for: .guestId) { + if case .signedIn = status {} else if case .signingIn = status {} else { + status = .guest(id: existing) + } + return existing + } + let newId = UUID().uuidString + try keychain.setString(newId, for: .guestId) + if case .signedIn = status {} else if case .signingIn = status {} else { + status = .guest(id: newId) + } + CoreLog.auth.info("Entered guest mode") + return newId + } + + /// Löscht die Guest-Identität aus dem Keychain. Genutzt nach einer + /// erfolgreichen Migration der lokalen Guest-Daten auf einen + /// Server-Account — die anonyme ID wird dann nicht mehr gebraucht. + /// Wenn der Status aktuell `.guest` ist, wechselt er auf `.signedOut`. + public func clearGuestId() { + keychain.remove(for: .guestId) + if case .guest = status { + status = .signedOut + } + } + public func signIn(email: String, password: String) async { let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !password.isEmpty else { @@ -113,7 +180,15 @@ public final class AuthClient { } } - public func signOut() async { + /// Logged den User aus. Versucht den Server zu informieren (Logout- + /// Endpoint), wischt den lokalen Keychain und setzt den Status. + /// + /// - Parameter keepGuestMode: Wenn `true`, bleibt die App in einem + /// anonymen Local-First-Modus aktiv. Eine bestehende Guest-ID + /// wird beibehalten oder eine neue erzeugt; der Status wechselt + /// auf ``Status/guest(id:)``. Default `false` für strict-Logout + /// (Status → `.signedOut`, auch Guest-ID gelöscht). + public func signOut(keepGuestMode: Bool = false) async { if let token = keychain.getString(for: .accessToken) { let url = config.authBaseURL.appending(path: "/api/v1/auth/logout") var request = URLRequest(url: url) @@ -122,8 +197,20 @@ public final class AuthClient { _ = try? await session.data(for: request) } keychain.wipe() - status = .signedOut - CoreLog.auth.info("Signed out") + if keepGuestMode { + if let existing = keychain.getString(for: .guestId) { + status = .guest(id: existing) + } else { + let newId = UUID().uuidString + try? keychain.setString(newId, for: .guestId) + status = .guest(id: newId) + } + CoreLog.auth.info("Signed out — kept guest mode") + } else { + keychain.remove(for: .guestId) + status = .signedOut + CoreLog.auth.info("Signed out") + } } public func currentAccessToken() throws -> String { @@ -172,13 +259,30 @@ public final class AuthClient { throw AuthError.networkFailure("Keine HTTP-Antwort") } guard http.statusCode == 200 else { - keychain.wipe() - status = .signedOut - throw AuthError.classify( + let err = AuthError.classify( status: http.statusCode, data: data, retryAfterHeader: http.retryAfterSeconds ) + // Wipe nur bei tatsächlich invalidierter Session. Transiente + // Fehler (5xx, Rate-Limit) lassen den Token erhalten — sonst + // wirft jeder mana-auth-Downtime-Moment alle Apps in den + // Login-Screen. Falls eine Guest-Identität existiert, wird + // beim Wipe in den Guest-Status zurückgefallen statt in + // strict `.signedOut`. + if err.invalidatesSession { + keychain.wipe() + if let guestId = keychain.getString(for: .guestId) { + status = .guest(id: guestId) + } else { + status = .signedOut + } + } else { + CoreLog.auth.notice( + "Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session" + ) + } + throw err } let token = try JSONDecoder().decode(TokenResponse.self, from: data) @@ -202,10 +306,12 @@ public final class AuthClient { status = .signedIn(email: email) } - /// Setzt den Status auf `.signedOut` und wirft den Keychain leer. - /// Genutzt nach `deleteAccount()`. + /// Setzt den Status auf `.signedOut` und wirft den Keychain + /// vollständig leer — inklusive Guest-ID. Genutzt nach + /// `deleteAccount()`: Account ist gelöscht, also auch die + /// (potenziell verknüpfte) anonyme Identität. func clearSession() { - keychain.wipe() + keychain.wipeAll() status = .signedOut } } diff --git a/Sources/ManaCore/Auth/AuthError.swift b/Sources/ManaCore/Auth/AuthError.swift index 14ed675..073f578 100644 --- a/Sources/ManaCore/Auth/AuthError.swift +++ b/Sources/ManaCore/Auth/AuthError.swift @@ -126,6 +126,49 @@ public enum AuthError: Error, LocalizedError, Sendable, Equatable { } } + /// `true` wenn dieser Fehler bedeutet, dass die gespeicherten + /// Tokens nicht mehr gültig sind und die App den Nutzer ausloggen + /// muss. `false` für *transiente* Fehler (Server-Downtime, Netzwerk- + /// Fehler, Rate-Limiting), bei denen die Session erhalten bleiben + /// soll — die App sollte den Versuch nur wiederholen. + /// + /// Genutzt von ``AuthClient/refreshAccessToken()`` um zu entscheiden, + /// ob der Keychain gewiped werden muss. Apps können denselben + /// Test auf Fehler aus ``AuthenticatedTransport`` anwenden, um + /// zwischen "neuer Login nötig" und "später nochmal probieren" zu + /// unterscheiden. + public var invalidatesSession: Bool { + switch self { + case .invalidCredentials, + .unauthorized, + .tokenExpired, + .tokenInvalid, + .emailNotVerified: + true + case .notSignedIn, + .encoding, + .keychain, + .decoding, + .networkFailure, + .emailAlreadyRegistered, + .weakPassword, + .accountLocked, + .signupLimitReached, + .rateLimited, + .twoFactorRequired, + .twoFactorFailed, + .passkeyNotEnabled, + .passkeyCancelled, + .passkeyVerificationFailed, + .validation, + .notFound, + .serviceUnavailable, + .serverInternal, + .serverError: + false + } + } + /// 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). diff --git a/Sources/ManaCore/Auth/KeychainStore.swift b/Sources/ManaCore/Auth/KeychainStore.swift index b6847c8..b9e953e 100644 --- a/Sources/ManaCore/Auth/KeychainStore.swift +++ b/Sources/ManaCore/Auth/KeychainStore.swift @@ -5,10 +5,18 @@ import Security /// pro `ManaAppConfig`. public final class KeychainStore: Sendable { /// Bekannte Keys für mana-auth-Tokens. + /// + /// `.guestId` ist eine lokal generierte anonyme UUID für Apps mit + /// Local-First-/Guest-Modus. Sie wird unabhängig von den Session- + /// Tokens persistiert und vom Default-``wipe()`` *nicht* gelöscht — + /// damit eine App nach einem Logout im Guest-Modus weiterlaufen + /// kann, ohne dass die lokalen anonymen Daten ihre Besitzer-Spur + /// verlieren. Vollständiger Reset über ``wipeAll()``. public enum Key: String, Sendable { case accessToken = "access_token" case refreshToken = "refresh_token" case email + case guestId = "guest_id" } private let service: String @@ -64,12 +72,22 @@ public final class KeychainStore: Sendable { SecItemDelete(query as CFDictionary) } + /// Löscht Session-Daten (accessToken, refreshToken, email). Behält + /// die ``Key/guestId``, damit lokale Guest-Daten nach einem Logout + /// erhalten bleiben können. public func wipe() { remove(for: .accessToken) remove(for: .refreshToken) remove(for: .email) } + /// Löscht *alles* inklusive der Guest-Identität. Genutzt nach + /// `deleteAccount()` oder bei explizitem "anonyme Daten vergessen". + public func wipeAll() { + wipe() + remove(for: .guestId) + } + private func baseQuery(for key: Key) -> [CFString: Any] { var query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, diff --git a/Tests/ManaCoreTests/AuthClientAccountTests.swift b/Tests/ManaCoreTests/AuthClientAccountTests.swift index 8c63005..1118c17 100644 --- a/Tests/ManaCoreTests/AuthClientAccountTests.swift +++ b/Tests/ManaCoreTests/AuthClientAccountTests.swift @@ -2,27 +2,10 @@ import Foundation import Testing @testable import ManaCore -@Suite("AuthClient+Account", .serialized) +@Suite("AuthClient+Account") @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 [:] } @@ -33,66 +16,62 @@ struct AuthClientAccountTests { @Test("register schickt POST /api/v1/auth/register mit JSON-Body") func registerSendsCorrectRequest() async throws { - let (client, _) = Self.makeClient() - MockURLProtocol.handler = { request in + let mocked = makeMockedAuth() + mocked.setHandler { 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( + try await mocked.auth.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 { + if case .signedIn = mocked.auth.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 mocked = makeMockedAuth() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" let refresh = "refresh-token-value" - MockURLProtocol.handler = { _ in + mocked.setHandler { _ 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")) + try await mocked.auth.register(email: "new@x.de", password: "Aa-123456789") + #expect(mocked.auth.status == .signedIn(email: "new@x.de")) } @Test("register mit existierender Email wirft emailAlreadyRegistered") func registerEmailAlreadyRegistered() async { - let (client, _) = Self.makeClient() - MockURLProtocol.handler = { _ in + let mocked = makeMockedAuth() + mocked.setHandler { _ 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") + try await mocked.auth.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 + let mocked = makeMockedAuth() + mocked.setHandler { _ 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") + try await mocked.auth.register(email: " ", password: "Aa-123456789") } } @@ -100,14 +79,14 @@ struct AuthClientAccountTests { @Test("forgotPassword schickt email + redirectTo") func forgotPasswordPayload() async throws { - let (client, _) = Self.makeClient() + let mocked = makeMockedAuth() let capturedURL = MockURLProtocol.Capture() - MockURLProtocol.handler = { request in + mocked.setHandler { request in capturedURL.store(request) return (200, Data(#"{"success":true}"#.utf8)) } - try await client.forgotPassword( + try await mocked.auth.forgotPassword( email: "user@x.de", resetUniversalLink: URL(string: "https://cardecky.mana.how/auth/reset")! ) @@ -122,14 +101,14 @@ struct AuthClientAccountTests { @Test("resetPassword schickt token + newPassword") func resetPasswordPayload() async throws { - let (client, _) = Self.makeClient() + let mocked = makeMockedAuth() let captured = MockURLProtocol.Capture() - MockURLProtocol.handler = { request in + mocked.setHandler { request in captured.store(request) return (200, Data(#"{"success":true}"#.utf8)) } - try await client.resetPassword(token: "tok123", newPassword: "Neu-987654321") + try await mocked.auth.resetPassword(token: "tok123", newPassword: "Neu-987654321") let request = try #require(captured.request) let json = recordedBody(request) #expect(json["token"] as? String == "tok123") @@ -139,13 +118,13 @@ struct AuthClientAccountTests { @Test("resetPassword mit abgelaufenem Token wirft tokenExpired") func resetPasswordTokenExpired() async { - let (client, _) = Self.makeClient() - MockURLProtocol.handler = { _ in + let mocked = makeMockedAuth() + mocked.setHandler { _ in (400, Data(#"{"error":"TOKEN_EXPIRED","status":400}"#.utf8)) } await #expect(throws: AuthError.tokenExpired) { - try await client.resetPassword(token: "old", newPassword: "Neu-987654321") + try await mocked.auth.resetPassword(token: "old", newPassword: "Neu-987654321") } } @@ -153,14 +132,14 @@ struct AuthClientAccountTests { @Test("resendVerification schickt email + sourceAppUrl") func resendVerificationPayload() async throws { - let (client, _) = Self.makeClient() + let mocked = makeMockedAuth() let captured = MockURLProtocol.Capture() - MockURLProtocol.handler = { request in + mocked.setHandler { request in captured.store(request) return (200, Data(#"{"success":true}"#.utf8)) } - try await client.resendVerification( + try await mocked.auth.resendVerification( email: "user@x.de", sourceAppUrl: URL(string: "https://cardecky.mana.how/auth/verify") ) @@ -172,8 +151,8 @@ struct AuthClientAccountTests { @Test("resendVerification mit Rate-Limit liefert retryAfter") func resendVerificationRateLimited() async { - let (client, _) = Self.makeClient() - MockURLProtocol.handler = { _ in + let mocked = makeMockedAuth() + mocked.setHandler { _ in ( 429, Data(#"{"error":"RATE_LIMITED","retryAfterSec":42,"status":429}"#.utf8), @@ -182,7 +161,7 @@ struct AuthClientAccountTests { } do { - try await client.resendVerification(email: "user@x.de") + try await mocked.auth.resendVerification(email: "user@x.de") Issue.record("Expected throw") } catch let AuthError.rateLimited(retryAfter) { #expect(retryAfter == 42) @@ -195,30 +174,26 @@ struct AuthClientAccountTests { @Test("changeEmail ohne Login wirft notSignedIn") func changeEmailRequiresSession() async { - let (client, _) = Self.makeClient() + let mocked = makeMockedAuth() await #expect(throws: AuthError.notSignedIn) { - try await client.changeEmail(newEmail: "neu@x.de") + try await mocked.auth.changeEmail(newEmail: "neu@x.de") } } @Test("changePassword schickt Session-Token als Bearer (nicht JWT)") func changePasswordSendsBearer() async throws { - let (client, _) = Self.makeClient() - // Authenticated Account-Calls senden den Session-Token (refreshToken) - // statt des JWT, weil server-seitig Better Auths bearer-Plugin - // den Session-Token zu einem Session-Cookie konvertiert. Siehe - // AuthClient+Account.swift Doc-Header. + let mocked = makeMockedAuth() let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" let session = "session-token-value" - try client.persistSession(email: "u@x.de", accessToken: access, refreshToken: session) + try mocked.auth.persistSession(email: "u@x.de", accessToken: access, refreshToken: session) let captured = MockURLProtocol.Capture() - MockURLProtocol.handler = { request in + mocked.setHandler { request in captured.store(request) return (200, Data(#"{"success":true}"#.utf8)) } - try await client.changePassword(currentPassword: "alt", newPassword: "neu") + try await mocked.auth.changePassword(currentPassword: "alt", newPassword: "neu") let request = try #require(captured.request) #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(session)") #expect(request.url?.path == "/api/v1/auth/change-password") @@ -226,99 +201,16 @@ struct AuthClientAccountTests { @Test("deleteAccount wiped Session bei Erfolg") func deleteAccountClearsSession() async throws { - let (client, _) = Self.makeClient() + let mocked = makeMockedAuth() let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" - try client.persistSession(email: "u@x.de", accessToken: access, refreshToken: "r") - #expect(client.status == .signedIn(email: "u@x.de")) + try mocked.auth.persistSession(email: "u@x.de", accessToken: access, refreshToken: "r") + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) - MockURLProtocol.handler = { request in + mocked.setHandler { 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 + try await mocked.auth.deleteAccount(password: "pw") + #expect(mocked.auth.status == .signedOut) } } diff --git a/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift b/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift new file mode 100644 index 0000000..c322242 --- /dev/null +++ b/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing +@testable import ManaCore + +@Suite("AuthClient Guest-Mode + Resilience") +@MainActor +struct AuthClientGuestAndResilienceTests { + // MARK: - enterGuestMode / currentGuestId + + @Test("enterGuestMode aus signedOut erzeugt UUID und setzt .guest") + func enterGuestModeFromSignedOut() throws { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + #expect(mocked.auth.status == .signedOut) + + let id = try mocked.auth.enterGuestMode() + #expect(!id.isEmpty) + #expect(mocked.auth.status == .guest(id: id)) + #expect(mocked.auth.currentGuestId() == id) + } + + @Test("enterGuestMode ist idempotent") + func enterGuestModeIdempotent() throws { + let mocked = makeMockedAuth() + let first = try mocked.auth.enterGuestMode() + let second = try mocked.auth.enterGuestMode() + #expect(first == second) + #expect(mocked.auth.status == .guest(id: first)) + } + + @Test("enterGuestMode stört aktive Session nicht") + func enterGuestModeKeepsSignedIn() throws { + let mocked = makeMockedAuth() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + + let id = try mocked.auth.enterGuestMode() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + #expect(mocked.auth.currentGuestId() == id) + } + + @Test("clearGuestId aus .guest fällt auf .signedOut") + func clearGuestIdFromGuest() throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + mocked.auth.clearGuestId() + #expect(mocked.auth.status == .signedOut) + #expect(mocked.auth.currentGuestId() == nil) + } + + @Test("clearGuestId aus .signedIn behält Status") + func clearGuestIdKeepsSignedIn() throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.auth.clearGuestId() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + #expect(mocked.auth.currentGuestId() == nil) + } + + // MARK: - bootstrap + + @Test("bootstrap erkennt nur-Guest-Keychain als .guest") + func bootstrapDetectsGuest() throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + mocked.auth.bootstrap() + if case let .guest(id) = mocked.auth.status { + #expect(!id.isEmpty) + } else { + Issue.record("Expected .guest after bootstrap, got \(mocked.auth.status)") + } + } + + @Test("bootstrap priorisiert Session über Guest") + func bootstrapPrioritisesSession() throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.auth.bootstrap() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + } + + // MARK: - signOut(keepGuestMode:) + + @Test("signOut Default löscht alles inkl. Guest-ID") + func signOutDefaultClearsGuest() async throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in (200, Data()) } + + await mocked.auth.signOut() + #expect(mocked.auth.status == .signedOut) + #expect(mocked.auth.currentGuestId() == nil) + } + + @Test("signOut(keepGuestMode: true) behält existierende Guest-ID") + func signOutKeepsExistingGuest() async throws { + let mocked = makeMockedAuth() + let id = try mocked.auth.enterGuestMode() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in (200, Data()) } + + await mocked.auth.signOut(keepGuestMode: true) + #expect(mocked.auth.status == .guest(id: id)) + #expect(mocked.auth.currentGuestId() == id) + } + + @Test("signOut(keepGuestMode: true) erzeugt neue Guest-ID wenn keine existiert") + func signOutCreatesGuestWhenMissing() async throws { + let mocked = makeMockedAuth() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in (200, Data()) } + + await mocked.auth.signOut(keepGuestMode: true) + if case let .guest(id) = mocked.auth.status { + #expect(!id.isEmpty) + #expect(mocked.auth.currentGuestId() == id) + } else { + Issue.record("Expected .guest after signOut(keepGuestMode:), got \(mocked.auth.status)") + } + } + + // MARK: - refreshAccessToken Resilience + + @Test("refresh 503 wirft serviceUnavailable, behält Session") + func refreshKeepsSessionOn503() async throws { + let mocked = makeMockedAuth() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (503, Data(#"{"error":"SERVICE_UNAVAILABLE","status":503}"#.utf8)) + } + + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw on 503") + } catch let err as AuthError { + #expect(err == .serviceUnavailable) + } + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + } + + @Test("refresh 500 wirft serverInternal, behält Session") + func refreshKeepsSessionOn500() async throws { + let mocked = makeMockedAuth() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (500, Data(#"{"error":"INTERNAL","status":500}"#.utf8)) + } + + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw on 500") + } catch let err as AuthError { + #expect(err == .serverInternal) + } + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + } + + @Test("refresh 429 wirft rateLimited, behält Session") + func refreshKeepsSessionOnRateLimit() async throws { + let mocked = makeMockedAuth() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (429, Data(#"{"error":"RATE_LIMITED","retryAfterSec":30,"status":429}"#.utf8)) + } + + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw on 429") + } catch let err as AuthError { + if case let .rateLimited(retryAfter) = err { + #expect(retryAfter == 30) + } else { + Issue.record("Expected .rateLimited, got \(err)") + } + } + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + } + + @Test("refresh 401 invalidiert Session ohne Guest → .signedOut") + func refreshInvalidates401NoGuest() async throws { + let mocked = makeMockedAuth() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) + } + + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw on 401") + } catch let err as AuthError { + #expect(err.invalidatesSession) + } + #expect(mocked.auth.status == .signedOut) + } + + @Test("refresh 401 mit Guest-ID fällt auf .guest zurück") + func refreshInvalidates401WithGuest() async throws { + let mocked = makeMockedAuth() + let id = try mocked.auth.enterGuestMode() + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) + } + + _ = try? await mocked.auth.refreshAccessToken() + #expect(mocked.auth.status == .guest(id: id)) + #expect(mocked.auth.currentGuestId() == id) + } + + // MARK: - AuthError.invalidatesSession + + @Test("invalidatesSession unterscheidet Session-Tot vs. transient") + func invalidatesSessionPartitioning() { + // Tot — Session muss weg + #expect(AuthError.invalidCredentials.invalidatesSession) + #expect(AuthError.unauthorized.invalidatesSession) + #expect(AuthError.tokenExpired.invalidatesSession) + #expect(AuthError.tokenInvalid.invalidatesSession) + + // Transient — Session bleibt + #expect(!AuthError.serviceUnavailable.invalidatesSession) + #expect(!AuthError.serverInternal.invalidatesSession) + #expect(!AuthError.networkFailure("offline").invalidatesSession) + #expect(!AuthError.rateLimited(retryAfter: 30).invalidatesSession) + #expect(!AuthError.accountLocked(retryAfter: nil).invalidatesSession) + } +} diff --git a/Tests/ManaCoreTests/MockURLProtocol.swift b/Tests/ManaCoreTests/MockURLProtocol.swift new file mode 100644 index 0000000..289332b --- /dev/null +++ b/Tests/ManaCoreTests/MockURLProtocol.swift @@ -0,0 +1,127 @@ +import Foundation +@testable import ManaCore + +/// URLProtocol-Mock mit Pro-Test-Routing: jede Test-AuthClient- +/// Instanz kriegt eine eigene Test-ID als HTTP-Header, der Mock +/// routet nach ID zum richtigen Handler. Das löst die Cross-Suite- +/// Pollution (mehrere `.serialized`-Suites laufen untereinander +/// parallel, der globale Handler-Slot wäre sonst ein Race). +/// +/// Identisches Pattern wie in mana-swift-ui — wenn weitere Test- +/// Helper hier hinzukommen, beide Pakete parallel halten. +final class MockURLProtocol: URLProtocol, @unchecked Sendable { + typealias Handler = @Sendable (URLRequest) -> Any + + nonisolated(unsafe) static var handlersStorage: [String: Handler] = [:] + static let handlersLock = NSLock() + + static func register(testID: String, handler: @escaping Handler) { + handlersLock.lock(); defer { handlersLock.unlock() } + handlersStorage[testID] = handler + } + + static func unregister(testID: String) { + handlersLock.lock(); defer { handlersLock.unlock() } + handlersStorage.removeValue(forKey: testID) + } + + private static func lookup(testID: String) -> Handler? { + handlersLock.lock(); defer { handlersLock.unlock() } + return handlersStorage[testID] + } + + 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() { + let testID = request.value(forHTTPHeaderField: "X-Test-ID") ?? "" + guard let handler = MockURLProtocol.lookup(testID: testID) 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) + } +} + +/// Bündelt einen `AuthClient` mit seiner Test-ID. +@MainActor +struct MockedAuth { + let auth: AuthClient + let testID: String + + func setHandler(_ handler: @escaping MockURLProtocol.Handler) { + MockURLProtocol.register(testID: testID, handler: handler) + } +} + +@MainActor +func makeMockedAuth() -> MockedAuth { + let testID = UUID().uuidString + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + configuration.httpAdditionalHeaders = ["X-Test-ID": testID] + let session = URLSession(configuration: configuration) + let config = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.test")!, + keychainService: "ev.mana.test.\(testID)", + keychainAccessGroup: nil + ) + return MockedAuth(auth: AuthClient(config: config, session: session), testID: testID) +} + +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 + } +}