feat(ios): device enrollment flow + silent cert rotation (B3)
Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B3 · The silent rotation scheduler decides — from the installed identity's
|
||||
// rotation timing — whether to renew before expiry, then drives an injected
|
||||
// mTLS renew. Pure and transport-free: fully unit-testable with fakes.
|
||||
|
||||
private let t0 = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
|
||||
private let renewAfter = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
|
||||
private let notAfter = ISO8601DateFormatter().date(from: "2026-10-06T00:00:00Z")!
|
||||
private let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
|
||||
private let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
|
||||
|
||||
private func state(renewAfter: Date? = renewAfter) -> DeviceRenewalState {
|
||||
DeviceRenewalState(deviceId: "dev-1", notAfter: notAfter, renewAfter: renewAfter)
|
||||
}
|
||||
|
||||
/// Records how many times the renew operation ran. `@unchecked Sendable`: guarded
|
||||
/// by a lock.
|
||||
private final class RenewSpy: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _count = 0
|
||||
var count: Int { lock.withLock { _count } }
|
||||
func bump() { lock.withLock { _count += 1 } }
|
||||
}
|
||||
|
||||
@Test("no enrolled identity → notEnrolled, and renew never runs")
|
||||
func schedulerNotEnrolled() async {
|
||||
let spy = RenewSpy()
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { nil },
|
||||
performRenew: { spy.bump(); return nil },
|
||||
now: { after }
|
||||
)
|
||||
let outcome = await scheduler.runIfDue()
|
||||
#expect(outcome == .notEnrolled)
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
|
||||
@Test("cert not yet due → notDue, and renew never runs")
|
||||
func schedulerNotDue() async {
|
||||
let spy = RenewSpy()
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { state() },
|
||||
performRenew: { spy.bump(); return nil },
|
||||
now: { before }
|
||||
)
|
||||
let outcome = await scheduler.runIfDue()
|
||||
#expect(outcome == .notDue(renewAfter: renewAfter))
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
|
||||
@Test("past renewAfter → renews exactly once")
|
||||
func schedulerRenews() async {
|
||||
let spy = RenewSpy()
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { state() },
|
||||
performRenew: { spy.bump(); return nil },
|
||||
now: { after }
|
||||
)
|
||||
let outcome = await scheduler.runIfDue()
|
||||
#expect(outcome == .renewed)
|
||||
#expect(spy.count == 1)
|
||||
}
|
||||
|
||||
@Test("due exactly at renewAfter (>= boundary) renews")
|
||||
func schedulerRenewsAtBoundary() async {
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { state() },
|
||||
performRenew: { nil },
|
||||
now: { renewAfter } // now == renewAfter
|
||||
)
|
||||
#expect(await scheduler.runIfDue() == .renewed)
|
||||
}
|
||||
|
||||
@Test("a missing renewAfter never triggers (fail-safe — TLS stack is the gate)")
|
||||
func schedulerMissingRenewAfterNeverDue() async {
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { state(renewAfter: nil) },
|
||||
performRenew: { nil },
|
||||
now: { after }
|
||||
)
|
||||
#expect(await scheduler.runIfDue() == .notDue(renewAfter: nil))
|
||||
}
|
||||
|
||||
@Test("a renew that throws is surfaced as .failed (never crashes, cert still valid)")
|
||||
func schedulerRenewFailure() async {
|
||||
struct Boom: Error {}
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { state() },
|
||||
performRenew: { throw Boom() },
|
||||
now: { after }
|
||||
)
|
||||
#expect(await scheduler.runIfDue() == .failed)
|
||||
}
|
||||
|
||||
@Test("a keychain read fault is surfaced as .failed, not a crash")
|
||||
func schedulerReadFailure() async {
|
||||
struct Boom: Error {}
|
||||
let scheduler = CertificateRotationScheduler(
|
||||
renewalState: { throw Boom() },
|
||||
performRenew: { nil },
|
||||
now: { after }
|
||||
)
|
||||
#expect(await scheduler.runIfDue() == .failed)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B3 · ControlPlaneLoginClient request-building + response-mapping, driven by a
|
||||
// stub transport (no network). Mirrors the PINNED login contract exactly:
|
||||
//
|
||||
// POST /auth/login { "password": "<operator secret>" }
|
||||
// → 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
|
||||
|
||||
private let cpBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
/// Records the last request and replays a canned response. `@unchecked Sendable`:
|
||||
/// the mutable capture is guarded by a lock.
|
||||
private final class LoginStubTransport: EnrollmentTransport, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _lastRequest: URLRequest?
|
||||
private let status: Int
|
||||
private let body: Data
|
||||
|
||||
init(status: Int, body: Data) {
|
||||
self.status = status
|
||||
self.body = body
|
||||
}
|
||||
|
||||
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
lock.withLock { _lastRequest = request }
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||
)!
|
||||
return (body, response)
|
||||
}
|
||||
}
|
||||
|
||||
private func loginBody(
|
||||
enrollToken: String = "v4.public.enroll-token",
|
||||
accountId: String = "11111111-2222-3333-4444-555555555555",
|
||||
expiresIn: Int = 600
|
||||
) -> Data {
|
||||
let json: [String: Any] = [
|
||||
"enrollToken": enrollToken,
|
||||
"accountId": accountId,
|
||||
"expiresIn": expiresIn,
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
|
||||
@Test("login builds a POST /auth/login carrying only the password (never bearer)")
|
||||
func loginBuildsRequest() async throws {
|
||||
// Arrange
|
||||
let stub = LoginStubTransport(status: 201, body: loginBody())
|
||||
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||
|
||||
// Act
|
||||
_ = try await client.login(password: "operator-secret")
|
||||
|
||||
// Assert
|
||||
let request = try #require(stub.lastRequest)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/auth/login")
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
// Login is credential-based, NOT bearer-authed — no Authorization header.
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
|
||||
let sent = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
|
||||
#expect(object["password"] as? String == "operator-secret")
|
||||
#expect(object.keys.count == 1) // password ONLY — no extra fields leak
|
||||
}
|
||||
|
||||
@Test("login maps a 201 response into a typed LoginResult")
|
||||
func loginMapsResponse() async throws {
|
||||
// Arrange
|
||||
let stub = LoginStubTransport(status: 201, body: loginBody(
|
||||
enrollToken: "v4.public.abc", accountId: "acct-uuid", expiresIn: 900
|
||||
))
|
||||
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||
|
||||
// Act
|
||||
let result = try await client.login(password: "pw")
|
||||
|
||||
// Assert
|
||||
#expect(result.enrollToken == "v4.public.abc")
|
||||
#expect(result.accountId == "acct-uuid")
|
||||
#expect(result.expiresIn == 900)
|
||||
}
|
||||
|
||||
@Test("an empty password fails fast without any network call")
|
||||
func loginEmptyPasswordRejected() async throws {
|
||||
// Arrange
|
||||
let stub = LoginStubTransport(status: 201, body: loginBody())
|
||||
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||
|
||||
// Act / Assert — boundary validation: reject before sending.
|
||||
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
|
||||
_ = try await client.login(password: "")
|
||||
}
|
||||
#expect(stub.lastRequest == nil) // proves zero network happened
|
||||
}
|
||||
|
||||
@Test("a 401 login response surfaces http with the server error code")
|
||||
func loginRejectSurfacesStatus() async throws {
|
||||
// The login stub seam denies-by-default → { error: "login rejected" }.
|
||||
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
|
||||
let stub = LoginStubTransport(status: 401, body: body)
|
||||
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||
|
||||
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
|
||||
_ = try await client.login(password: "wrong")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a malformed 201 body throws malformedResponse")
|
||||
func loginMalformedBody() async throws {
|
||||
let stub = LoginStubTransport(status: 201, body: Data("not json".utf8))
|
||||
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
|
||||
|
||||
await #expect(throws: ControlPlaneLoginError.malformedResponse) {
|
||||
_ = try await client.login(password: "pw")
|
||||
}
|
||||
}
|
||||
@@ -169,3 +169,46 @@ func renewTargetsRenewEndpoint() async throws {
|
||||
#expect(stub.lastRequest?.url?.absoluteString
|
||||
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
|
||||
}
|
||||
|
||||
@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)")
|
||||
func renewSendsCsrOnlyBody() async throws {
|
||||
// The control-plane /device/:id/renew schema authenticates by the presented
|
||||
// mTLS cert and accepts a body of exactly { csr } — a stray `keyAlg` (or any
|
||||
// non-csr field) trips its strict validation and 400s every silent renewal.
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub)
|
||||
let csr = Data([0x0A, 0x0B, 0x0C])
|
||||
|
||||
_ = try await client.renew(deviceId: "dev-9", csrDER: csr)
|
||||
|
||||
let request = try #require(stub.lastRequest)
|
||||
let sent = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
|
||||
#expect(object["csr"] as? String == csr.base64EncodedString())
|
||||
#expect(object["keyAlg"] == nil) // NOT sent — the enroll-only field must be dropped
|
||||
#expect(object.keys.sorted() == ["csr"]) // exactly one field
|
||||
}
|
||||
|
||||
@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)")
|
||||
func renewMTLSOmitsBearer() async throws {
|
||||
// /device/:id/renew authenticates by the CURRENT device client cert (mTLS),
|
||||
// NOT a bearer — the silent rotation scheduler has no fresh bearer weeks
|
||||
// later, so it constructs the client with a nil bearer and relies on the
|
||||
// transport presenting the installed identity. The Authorization header
|
||||
// must therefore be ABSENT (a stale/empty "Bearer " would be wrong).
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil
|
||||
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
|
||||
let request = try #require(stub.lastRequest)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
// Content-Type is still set — it's a JSON POST regardless of auth mechanism.
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
}
|
||||
|
||||
@Test("enroll still sets the bearer when one is supplied (unchanged contract)")
|
||||
func enrollKeepsBearerWhenSupplied() async throws {
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
|
||||
#expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B3 · DeviceEnrollmentFlow composes the pinned bootstrap end-to-end:
|
||||
// login(password) → bearer → enroll(CSR, subdomain) with that bearer → leaf.
|
||||
// Driven by a path-routing stub transport (no network, no keychain): asserts the
|
||||
// bearer minted at login is exactly what authenticates the enroll call.
|
||||
|
||||
private let flowBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
/// Routes by path: `/auth/login` → login body, `/device/enroll` → enroll body.
|
||||
/// Records every request so the test can assert the enroll bearer. `@unchecked
|
||||
/// Sendable`: mutable captures guarded by a lock.
|
||||
private final class RoutingStubTransport: EnrollmentTransport, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _requests: [URLRequest] = []
|
||||
private let loginStatus: Int
|
||||
private let loginBody: Data
|
||||
|
||||
init(loginStatus: Int = 201, loginBody: Data) {
|
||||
self.loginStatus = loginStatus
|
||||
self.loginBody = loginBody
|
||||
}
|
||||
|
||||
var requests: [URLRequest] { lock.withLock { _requests } }
|
||||
func request(forPathSuffix suffix: String) -> URLRequest? {
|
||||
lock.withLock { _requests.first { $0.url?.path.hasSuffix(suffix) == true } }
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
lock.withLock { _requests.append(request) }
|
||||
let path = request.url?.path ?? ""
|
||||
let (status, body): (Int, Data)
|
||||
if path.hasSuffix("/auth/login") {
|
||||
(status, body) = (loginStatus, loginBody)
|
||||
} else {
|
||||
// /device/enroll → a minimal 201 leaf.
|
||||
(status, body) = (201, enrollLeafBody())
|
||||
}
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||
)!
|
||||
return (body, response)
|
||||
}
|
||||
}
|
||||
|
||||
private func loginResponseBody(enrollToken: String) -> Data {
|
||||
try! JSONSerialization.data(withJSONObject: [
|
||||
"enrollToken": enrollToken,
|
||||
"accountId": "acct-123",
|
||||
"expiresIn": 600,
|
||||
])
|
||||
}
|
||||
|
||||
private func enrollLeafBody() -> Data {
|
||||
try! JSONSerialization.data(withJSONObject: [
|
||||
"deviceId": "dev-1",
|
||||
"cert": Data([0x30, 0x01]).base64EncodedString(),
|
||||
"caChain": [Data([0x30, 0x02]).base64EncodedString()],
|
||||
"notBefore": "2026-07-18T00:00:00Z",
|
||||
"notAfter": "2026-10-16T00:00:00Z",
|
||||
"renewAfter": "2026-09-15T00:00:00Z",
|
||||
])
|
||||
}
|
||||
|
||||
/// A fake installer that invokes the real `client.enroll` (so the transport sees
|
||||
/// the enroll request with the login-minted bearer), then returns a summary.
|
||||
/// Records the subdomain/deviceName it was handed. `@unchecked Sendable`: lock.
|
||||
private final class SpyInstaller: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var subdomain: String?
|
||||
private(set) var deviceName: String?
|
||||
|
||||
func install(
|
||||
_ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String
|
||||
) async throws -> ClientCertificateSummary? {
|
||||
lock.withLock { self.subdomain = subdomain; self.deviceName = deviceName }
|
||||
_ = try await client.enroll(csrDER: Data([0xAB]), subdomain: subdomain, deviceName: deviceName)
|
||||
return ClientCertificateSummary(
|
||||
subjectCommonName: deviceName, issuerCommonName: "webterm-device-ca", notAfter: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("flow logs in then enrolls with the login-minted bearer (end to end)")
|
||||
func flowEndToEnd() async throws {
|
||||
// Arrange
|
||||
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "TOKEN-XYZ"))
|
||||
let installer = SpyInstaller()
|
||||
let flow = DeviceEnrollmentFlow(
|
||||
baseURL: flowBaseURL,
|
||||
transport: transport,
|
||||
install: installer.install
|
||||
)
|
||||
|
||||
// Act
|
||||
let summary = try await flow.run(password: "pw", subdomain: "alice", deviceName: "Alice iPhone")
|
||||
|
||||
// Assert — login happened, THEN enroll with the exact bearer from login.
|
||||
#expect(transport.request(forPathSuffix: "/auth/login") != nil)
|
||||
let enroll = try #require(transport.request(forPathSuffix: "/device/enroll"))
|
||||
#expect(enroll.value(forHTTPHeaderField: "Authorization") == "Bearer TOKEN-XYZ")
|
||||
#expect(installer.subdomain == "alice")
|
||||
#expect(installer.deviceName == "Alice iPhone")
|
||||
#expect(summary?.subjectCommonName == "Alice iPhone")
|
||||
}
|
||||
|
||||
@Test("a login failure short-circuits: enroll is never attempted")
|
||||
func flowLoginFailureShortCircuits() async {
|
||||
// 401 login → the flow must NOT reach /device/enroll.
|
||||
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
|
||||
let transport = RoutingStubTransport(loginStatus: 401, loginBody: body)
|
||||
let installer = SpyInstaller()
|
||||
let flow = DeviceEnrollmentFlow(
|
||||
baseURL: flowBaseURL, transport: transport, install: installer.install
|
||||
)
|
||||
|
||||
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
|
||||
_ = try await flow.run(password: "wrong", subdomain: "alice", deviceName: "d")
|
||||
}
|
||||
#expect(transport.request(forPathSuffix: "/device/enroll") == nil)
|
||||
#expect(installer.subdomain == nil) // installer never ran
|
||||
}
|
||||
|
||||
@Test("an empty password is rejected before any network")
|
||||
func flowEmptyPassword() async {
|
||||
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "t"))
|
||||
let flow = DeviceEnrollmentFlow(
|
||||
baseURL: flowBaseURL, transport: transport, install: { _, _, _ in nil }
|
||||
)
|
||||
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
|
||||
_ = try await flow.run(password: "", subdomain: "alice", deviceName: "d")
|
||||
}
|
||||
#expect(transport.requests.isEmpty)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS · The keychain-replace helper backs the rotation scheduler's identity
|
||||
// updates. Its one job: a crash/failure mid-write must NEVER leave zero installed
|
||||
// items (which would lock the device out of its own mTLS identity until a manual
|
||||
// re-enroll). It enforces ADD-new-BEFORE-DELETE-old ordering — the inverse of the
|
||||
// delete-then-add sequence whose window has zero items installed.
|
||||
|
||||
/// A tiny in-memory stand-in for the keychain: the current set of installed item
|
||||
/// ids plus the smallest count ever observed, so a test can assert the invariant
|
||||
/// that the installed set is NEVER empty at any step of the replace.
|
||||
private final class FakeKeychain {
|
||||
private(set) var items: Set<String>
|
||||
private(set) var log: [String] = []
|
||||
private(set) var minCount: Int
|
||||
init(seed: Set<String>) {
|
||||
items = seed
|
||||
minCount = seed.count
|
||||
}
|
||||
|
||||
func add(_ id: String) {
|
||||
items.insert(id)
|
||||
log.append("add(\(id))")
|
||||
minCount = min(minCount, items.count)
|
||||
}
|
||||
|
||||
func delete(_ id: String) {
|
||||
items.remove(id)
|
||||
log.append("delete(\(id))")
|
||||
minCount = min(minCount, items.count)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("replace ADDS the new item before DELETING the old — never a zero-item window")
|
||||
func replaceAddsBeforeDeletes() throws {
|
||||
let keychain = FakeKeychain(seed: ["old"])
|
||||
try replaceKeychainItemAtomically(
|
||||
addNew: { keychain.add("new") },
|
||||
deleteOld: { keychain.delete("old") }
|
||||
)
|
||||
#expect(keychain.items == ["new"])
|
||||
#expect(keychain.log == ["add(new)", "delete(old)"]) // add strictly precedes delete
|
||||
#expect(keychain.minCount >= 1) // at least one identity installed at every step
|
||||
}
|
||||
|
||||
@Test("a failed add leaves the prior item intact and never runs the delete")
|
||||
func replaceAddFailureKeepsOld() {
|
||||
struct Boom: Error {}
|
||||
let keychain = FakeKeychain(seed: ["old"])
|
||||
#expect(throws: Boom.self) {
|
||||
try replaceKeychainItemAtomically(
|
||||
addNew: { throw Boom() },
|
||||
deleteOld: { keychain.delete("old") }
|
||||
)
|
||||
}
|
||||
#expect(keychain.items == ["old"]) // old preserved — a working identity remains
|
||||
#expect(keychain.log.contains("delete(old)") == false)
|
||||
#expect(keychain.minCount >= 1)
|
||||
}
|
||||
|
||||
@Test("a failed delete-of-old is non-fatal — the new item stays installed and the fault is surfaced")
|
||||
func replaceDeleteFailureKeepsNew() throws {
|
||||
struct Boom: Error {}
|
||||
let keychain = FakeKeychain(seed: ["old"])
|
||||
var reported: Error?
|
||||
try replaceKeychainItemAtomically(
|
||||
addNew: { keychain.add("new") },
|
||||
deleteOld: { throw Boom() },
|
||||
reportStaleDeleteFailure: { reported = $0 }
|
||||
)
|
||||
#expect(keychain.items.contains("new")) // renewal succeeded despite the stale-delete fault
|
||||
#expect(reported is Boom) // surfaced (not silently swallowed)
|
||||
#expect(keychain.minCount >= 1)
|
||||
}
|
||||
Reference in New Issue
Block a user