import Foundation @testable import ClientTLS // B4 · Shared enrollment-transport double for the store / flow tests. // // Unlike the per-file stubs in DeviceEnrollmentClientTests (which only need the // LAST request), the store tests must re-parse the CSR that was actually sent — // that is how "renew re-signs with the SAME device key" is pinned — so this one // records every request body and replays a scripted reply per call. /// A recording, scriptable `EnrollmentTransport`. /// /// `@unchecked Sendable`: all mutable state is behind `lock`. final class RecordingEnrollmentTransport: EnrollmentTransport, @unchecked Sendable { struct Reply { let status: Int let body: Data } private let lock = NSLock() private var pendingReplies: [Reply] private var recorded: [URLRequest] = [] init(replies: [Reply]) { pendingReplies = replies } convenience init(status: Int, body: Data) { self.init(replies: [Reply(status: status, body: body)]) } var requests: [URLRequest] { lock.withLock { recorded } } var callCount: Int { lock.withLock { recorded.count } } /// The JSON body of call `index`, decoded as a `[String: Any]` object. func jsonBody(at index: Int) -> [String: Any]? { guard let data = requests.indices.contains(index) ? requests[index].httpBody : nil else { return nil } return try? JSONSerialization.jsonObject(with: data) as? [String: Any] } /// The `csr` field of call `index`, base64-decoded back to DER. func csrDER(at index: Int) -> Data? { guard let base64 = jsonBody(at: index)?["csr"] as? String else { return nil } return Data(base64Encoded: base64) } func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { let reply: Reply = lock.withLock { recorded.append(request) guard !pendingReplies.isEmpty else { return Reply(status: 500, body: Data()) } return pendingReplies.removeFirst() } let response = HTTPURLResponse( url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: nil )! return (reply.body, response) } } /// DER bytes that `SecCertificateCreateWithData` rejects (a truncated SEQUENCE). /// /// Every store test that drives `enroll` / `renew` to the persistence step uses /// this as the server-returned leaf ON PURPOSE: it makes `storeEnrolledLeaf` /// fail at the very first line — BEFORE any `SecItemAdd(kSecClassCertificate)`. /// That matters on macOS, where `swift test` talks to the file-based login /// keychain: it OVERRIDES the `kSecAttrLabel` of an added certificate with the /// cert's own subject summary, so `KeychainClientIdentityStore`'s label-keyed /// delete can never remove it again (verified 2026-07-30: adding the leaf, then /// `SecItemDelete(kSecClass: kSecClassCertificate, kSecAttrLabel: …)` → /// `errSecItemNotFound (-25300)`, and the cert had to be swept manually with /// `security delete-certificate -Z `). A test that installed a leaf would /// therefore permanently pollute the developer's login keychain. let undecodableCertificateDER = Data([0x30, 0x01, 0x02]) /// A `POST /device/enroll` / `…/renew` 201 body in the A4 wire shape. func enrollmentResponseJSON( deviceId: String = "dev-b4", certDER: Data = undecodableCertificateDER, caChain: [Data] = [], notBefore: String? = "2026-07-30T00:00:00.000Z", notAfter: String? = "2026-10-28T00:00:00.000Z", renewAfter: String? = "2026-09-27T00:00:00.000Z" ) -> Data { var json: [String: Any] = [ "deviceId": deviceId, "cert": certDER.base64EncodedString(), "caChain": caChain.map { $0.base64EncodedString() }, ] if let notBefore { json["notBefore"] = notBefore } if let notAfter { json["notAfter"] = notAfter } if let renewAfter { json["renewAfter"] = renewAfter } return try! JSONSerialization.data(withJSONObject: json) }