Apps mit aktivem 2FA bekommen jetzt eine native Challenge-View nach Email/Password-Login. ManaLoginView schaltet automatisch um wenn AuthClient.status auf .twoFactorRequired wechselt. Components: - ManaTwoFactorChallengeView — Scaffold-View mit 6-stelligem Code- Input, Backup-Code-Toggle, Cancel zurück zum Login - TwoFactorChallengeViewModel — @Observable State-Maschine, wraps AuthClient.verifyTotp/verifyBackupCode - LoginViewModel.Status.twoFactorRequired(email:) als neuer Case; submit() routet automatisch dorthin wenn der AuthClient den Challenge-Status zurückgibt 6 neue Tests, 39/39 grün. Setzt mana-swift-core ≥ 1.3.0 voraus. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.2 KiB
Swift
118 lines
4.2 KiB
Swift
import Foundation
|
|
import ManaCore
|
|
import Testing
|
|
@testable import ManaAuthUI
|
|
|
|
@Suite("TwoFactorChallengeViewModel")
|
|
@MainActor
|
|
struct TwoFactorChallengeViewModelTests {
|
|
/// Bringt den AuthClient in den `.twoFactorRequired`-Status.
|
|
private func challengedAuth() async -> MockedAuth {
|
|
let mocked = makeMockedAuth()
|
|
mocked.setHandler { _ in
|
|
(200, Data(#"""
|
|
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-x"}
|
|
"""#.utf8))
|
|
}
|
|
await mocked.auth.signIn(email: "u@x.de", password: "pw")
|
|
return mocked
|
|
}
|
|
|
|
@Test("canSubmit fordert 6-stellige Ziffern im TOTP-Modus")
|
|
func canSubmitTotpDigits() {
|
|
let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth)
|
|
model.code = ""
|
|
#expect(model.canSubmit == false)
|
|
model.code = "12345"
|
|
#expect(model.canSubmit == false) // 5 Ziffern
|
|
model.code = "123456"
|
|
#expect(model.canSubmit == true)
|
|
model.code = "123 456" // erlaubt Whitespace → 6 Ziffern
|
|
#expect(model.canSubmit == true)
|
|
model.code = "abcdef"
|
|
#expect(model.canSubmit == false)
|
|
}
|
|
|
|
@Test("canSubmit im Backup-Modus akzeptiert nicht-leere Strings")
|
|
func canSubmitBackupCode() {
|
|
let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth)
|
|
model.toggleMode()
|
|
#expect(model.mode == .backupCode)
|
|
model.code = ""
|
|
#expect(model.canSubmit == false)
|
|
model.code = "abc-def-ghi"
|
|
#expect(model.canSubmit == true)
|
|
}
|
|
|
|
@Test("toggleMode wechselt mode + cleared code")
|
|
func toggleModeClearsCode() {
|
|
let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth)
|
|
model.code = "123456"
|
|
model.toggleMode()
|
|
#expect(model.mode == .backupCode)
|
|
#expect(model.code == "")
|
|
}
|
|
|
|
@Test("submit mit erfolgreichem TOTP setzt AuthClient auf signedIn")
|
|
func submitTotpSuccess() async {
|
|
let mocked = await challengedAuth()
|
|
let model = TwoFactorChallengeViewModel(auth: mocked.auth)
|
|
model.code = "123456"
|
|
|
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
|
mocked.setHandler { request in
|
|
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-totp")
|
|
return (200, Data(#"""
|
|
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
|
|
"""#.utf8))
|
|
}
|
|
|
|
await model.submit()
|
|
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
|
#expect(model.code == "")
|
|
#expect(model.status == .idle)
|
|
}
|
|
|
|
@Test("submit mit falschem TOTP → .error, AuthClient bleibt twoFactorRequired")
|
|
func submitTotpWrongCode() async {
|
|
let mocked = await challengedAuth()
|
|
let model = TwoFactorChallengeViewModel(auth: mocked.auth)
|
|
model.code = "000000"
|
|
|
|
mocked.setHandler { _ in
|
|
(401, Data(#"{"error":"TWO_FACTOR_FAILED","status":401}"#.utf8))
|
|
}
|
|
|
|
await model.submit()
|
|
if case let .error(message) = model.status {
|
|
#expect(message == "Zwei-Faktor-Code falsch.")
|
|
} else {
|
|
Issue.record("Expected .error, got \(model.status)")
|
|
}
|
|
// AuthClient bleibt im challenge-Status, User kann retry
|
|
if case .twoFactorRequired = mocked.auth.status {
|
|
#expect(Bool(true))
|
|
} else {
|
|
Issue.record("Expected .twoFactorRequired, got \(mocked.auth.status)")
|
|
}
|
|
}
|
|
|
|
@Test("submit im Backup-Modus ruft verify-backup-code-Endpoint")
|
|
func submitBackupCodeRoutesCorrectly() async {
|
|
let mocked = await challengedAuth()
|
|
let model = TwoFactorChallengeViewModel(auth: mocked.auth)
|
|
model.toggleMode()
|
|
model.code = "abc-def-ghi"
|
|
|
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
|
mocked.setHandler { request in
|
|
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-backup-code")
|
|
return (200, Data(#"""
|
|
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
|
|
"""#.utf8))
|
|
}
|
|
|
|
await model.submit()
|
|
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
|
}
|
|
}
|