Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift
Yaojia Wang 07bcbf0c08 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.
2026-07-18 13:32:05 +02:00

77 lines
2.8 KiB
Swift

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)
}