Native-Apps werden gegen mana-auth-Downtime gehärtet und können einen anonymen Local-First-Modus anbieten. Komplett additiv. AuthClient.Status um `.guest(id: String)` erweitert — persistente lokale UUID ohne Server-Account, gleichberechtigt mit `.signedIn` als "App ist nutzbar"-Zustand. Neue Methoden: - enterGuestMode() throws -> String — idempotent - currentGuestId() -> String? - clearGuestId() - signOut(keepGuestMode: Bool = false) — Default-Verhalten unverändert KeychainStore.Key.guestId neu. wipe() löscht nur Session-Felder (accessToken/refreshToken/email); Guest-ID überlebt. Für komplettes Vergessen: neue wipeAll(). refreshAccessToken() wipt nicht mehr blind bei jedem Nicht-200. Heuristik via AuthError.invalidatesSession: - Wipe bei invalidCredentials/unauthorized/tokenExpired/tokenInvalid/ emailNotVerified — Session ist tatsächlich tot. - Behalten bei serviceUnavailable/serverInternal/networkFailure/ rateLimited — Apps werden bei mana-auth-Downtime nicht mehr in Login geworfen. Beim Wipe fällt der Status auf .guest(id) zurück, falls eine Guest-Identität existiert; sonst auf .signedOut. Tests: - Mock-Setup auf per-test-ID-Routing migriert (analog mana-swift-ui), löst Cross-Suite-Pollution zwischen AuthClient+Account und AuthClient Guest-Mode + Resilience. - 15 neue Tests für Guest-Mode + Refresh-Resilience. - 54/54 Tests grün. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
8.1 KiB
Swift
216 lines
8.1 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import ManaCore
|
|
|
|
@Suite("AuthClient+Account")
|
|
@MainActor
|
|
struct AuthClientAccountTests {
|
|
private func recordedBody(_ request: URLRequest) -> [String: Any] {
|
|
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 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 mocked.auth.register(
|
|
email: "new@x.de",
|
|
password: "Aa-123456789",
|
|
name: "Neu",
|
|
sourceAppUrl: URL(string: "https://cardecky.mana.how/auth/verify")
|
|
)
|
|
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 mocked = makeMockedAuth()
|
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
|
let refresh = "refresh-token-value"
|
|
mocked.setHandler { _ in
|
|
(200, Data(#"""
|
|
{"user":{"id":"u1","email":"new@x.de"},"accessToken":"\#(access)","refreshToken":"\#(refresh)"}
|
|
"""#.utf8))
|
|
}
|
|
|
|
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 mocked = makeMockedAuth()
|
|
mocked.setHandler { _ in
|
|
(409, Data(#"{"error":"EMAIL_ALREADY_REGISTERED","status":409}"#.utf8))
|
|
}
|
|
|
|
await #expect(throws: AuthError.emailAlreadyRegistered) {
|
|
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 mocked = makeMockedAuth()
|
|
mocked.setHandler { _ in
|
|
Issue.record("Server darf nicht aufgerufen werden")
|
|
return (500, Data())
|
|
}
|
|
|
|
await #expect(throws: (any Error).self) {
|
|
try await mocked.auth.register(email: " ", password: "Aa-123456789")
|
|
}
|
|
}
|
|
|
|
// MARK: - forgotPassword
|
|
|
|
@Test("forgotPassword schickt email + redirectTo")
|
|
func forgotPasswordPayload() async throws {
|
|
let mocked = makeMockedAuth()
|
|
let capturedURL = MockURLProtocol.Capture()
|
|
mocked.setHandler { request in
|
|
capturedURL.store(request)
|
|
return (200, Data(#"{"success":true}"#.utf8))
|
|
}
|
|
|
|
try await mocked.auth.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 mocked = makeMockedAuth()
|
|
let captured = MockURLProtocol.Capture()
|
|
mocked.setHandler { request in
|
|
captured.store(request)
|
|
return (200, Data(#"{"success":true}"#.utf8))
|
|
}
|
|
|
|
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")
|
|
#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 mocked = makeMockedAuth()
|
|
mocked.setHandler { _ in
|
|
(400, Data(#"{"error":"TOKEN_EXPIRED","status":400}"#.utf8))
|
|
}
|
|
|
|
await #expect(throws: AuthError.tokenExpired) {
|
|
try await mocked.auth.resetPassword(token: "old", newPassword: "Neu-987654321")
|
|
}
|
|
}
|
|
|
|
// MARK: - resendVerification
|
|
|
|
@Test("resendVerification schickt email + sourceAppUrl")
|
|
func resendVerificationPayload() async throws {
|
|
let mocked = makeMockedAuth()
|
|
let captured = MockURLProtocol.Capture()
|
|
mocked.setHandler { request in
|
|
captured.store(request)
|
|
return (200, Data(#"{"success":true}"#.utf8))
|
|
}
|
|
|
|
try await mocked.auth.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 mocked = makeMockedAuth()
|
|
mocked.setHandler { _ in
|
|
(
|
|
429,
|
|
Data(#"{"error":"RATE_LIMITED","retryAfterSec":42,"status":429}"#.utf8),
|
|
["Retry-After": "42"]
|
|
)
|
|
}
|
|
|
|
do {
|
|
try await mocked.auth.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 mocked = makeMockedAuth()
|
|
await #expect(throws: AuthError.notSignedIn) {
|
|
try await mocked.auth.changeEmail(newEmail: "neu@x.de")
|
|
}
|
|
}
|
|
|
|
@Test("changePassword schickt Session-Token als Bearer (nicht JWT)")
|
|
func changePasswordSendsBearer() async throws {
|
|
let mocked = makeMockedAuth()
|
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
|
let session = "session-token-value"
|
|
try mocked.auth.persistSession(email: "u@x.de", accessToken: access, refreshToken: session)
|
|
|
|
let captured = MockURLProtocol.Capture()
|
|
mocked.setHandler { request in
|
|
captured.store(request)
|
|
return (200, Data(#"{"success":true}"#.utf8))
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
@Test("deleteAccount wiped Session bei Erfolg")
|
|
func deleteAccountClearsSession() async throws {
|
|
let mocked = makeMockedAuth()
|
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
|
try mocked.auth.persistSession(email: "u@x.de", accessToken: access, refreshToken: "r")
|
|
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
|
|
|
mocked.setHandler { request in
|
|
#expect(request.httpMethod == "DELETE")
|
|
return (200, Data(#"{"success":true}"#.utf8))
|
|
}
|
|
try await mocked.auth.deleteAccount(password: "pw")
|
|
#expect(mocked.auth.status == .signedOut)
|
|
}
|
|
}
|