v0.4.0 — ManaTwoFactorEnrollView + ManaTwoFactorDisableView

3-Phasen-Wizard für 2FA-Enrollment + Single-Step-Sheet für Disable.
Setzt mana-swift-core ≥ 1.4.0 voraus.

ManaTwoFactorEnrollView:
1. Passwort-Re-Auth → server liefert otpauth-URI + Backup-Codes
2. QR-Code-Display (CoreImage.CIFilter.qrCodeGenerator) + 6-stellige
   Test-Code-Eingabe
3. Backup-Codes-Liste mit Copy-to-Clipboard

ManaTwoFactorDisableView:
- Re-Auth via Passwort, destructive-Button, .done-Konfirmation

5 neue Tests, 44/44 grün.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-14 00:39:03 +02:00
parent c1555565b6
commit dc8e5a4e9b
4 changed files with 595 additions and 0 deletions

View file

@ -0,0 +1,130 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("TwoFactorEnrollmentViewModel")
@MainActor
struct TwoFactorEnrollmentViewModelTests {
private func signedInAuth() async -> MockedAuth {
let mocked = makeMockedAuth()
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
return mocked
}
@Test("enrollWithPassword erfolgreich → phase wechselt auf verify")
func enrollSuccess() async {
let mocked = await signedInAuth()
let model = TwoFactorEnrollmentViewModel(auth: mocked.auth)
model.password = "pw"
mocked.setHandler { _ in
(200, Data(#"""
{"totpURI":"otpauth://totp/Mana:u@x.de?secret=ABC","backupCodes":["a","b","c"]}
"""#.utf8))
}
await model.enrollWithPassword()
if case let .verify(uri, codes) = model.phase {
#expect(uri.hasPrefix("otpauth://totp/"))
#expect(codes == ["a", "b", "c"])
} else {
Issue.record("Expected .verify, got \(model.phase)")
}
#expect(model.password == "") // out of memory
}
@Test("enrollWithPassword falsches PW → .error, phase bleibt password")
func enrollWrongPassword() async {
let mocked = await signedInAuth()
let model = TwoFactorEnrollmentViewModel(auth: mocked.auth)
model.password = "wrong"
mocked.setHandler { _ in
(401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8))
}
await model.enrollWithPassword()
if case .password = model.phase {
#expect(Bool(true))
} else {
Issue.record("Expected .password, got \(model.phase)")
}
if case let .error(message) = model.status {
#expect(message == "Email oder Passwort falsch")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("canSubmitVerify fordert 6 Ziffern")
func canSubmitVerify() async {
let mocked = await signedInAuth()
let model = TwoFactorEnrollmentViewModel(auth: mocked.auth)
model.password = "pw"
mocked.setHandler { _ in
(200, Data(#"""
{"totpURI":"otpauth://totp/X","backupCodes":["a"]}
"""#.utf8))
}
await model.enrollWithPassword()
model.verifyCode = ""
#expect(model.canSubmitVerify == false)
model.verifyCode = "12345"
#expect(model.canSubmitVerify == false)
model.verifyCode = "123456"
#expect(model.canSubmitVerify == true)
model.verifyCode = "abcdef"
#expect(model.canSubmitVerify == false)
}
@Test("confirmVerify wechselt von verify auf backupCodes")
func confirmVerifySwitchesPhase() async {
let mocked = await signedInAuth()
let model = TwoFactorEnrollmentViewModel(auth: mocked.auth)
model.password = "pw"
mocked.setHandler { _ in
(200, Data(#"""
{"totpURI":"otpauth://totp/X","backupCodes":["a","b","c"]}
"""#.utf8))
}
await model.enrollWithPassword()
model.verifyCode = "123456"
model.confirmVerify()
if case let .backupCodes(codes) = model.phase {
#expect(codes == ["a", "b", "c"])
} else {
Issue.record("Expected .backupCodes, got \(model.phase)")
}
#expect(model.verifyCode == "")
}
@Test("backupCodes computed property returnt Codes aus verify- und backupCodes-Phase")
func backupCodesAccessor() async {
let mocked = await signedInAuth()
let model = TwoFactorEnrollmentViewModel(auth: mocked.auth)
// Phase .password keine Codes
#expect(model.backupCodes == [])
model.password = "pw"
mocked.setHandler { _ in
(200, Data(#"""
{"totpURI":"otpauth://totp/X","backupCodes":["c1","c2"]}
"""#.utf8))
}
await model.enrollWithPassword()
// Phase .verify Codes verfügbar
#expect(model.backupCodes == ["c1", "c2"])
model.verifyCode = "123456"
model.confirmVerify()
// Phase .backupCodes Codes weiter verfügbar
#expect(model.backupCodes == ["c1", "c2"])
}
}