import Foundation import Testing @testable import ClientTLS // C-iOS · DeviceEnrollmentClient request-building + response-mapping, driven by a // stub transport (no network). Mirrors the A4 contract exactly. private let baseURL = URL(string: "https://cp.terminal.yaojia.wang")! private let bearer = "device-enroll-token-abc" /// Records the last request and replays a canned response. `@unchecked Sendable`: /// the mutable capture is guarded by a lock. private final class StubTransport: 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 enrollBody( deviceId: String = "dev-1", cert: Data = Data([0x30, 0x01, 0x02]), caChain: [Data] = [Data([0x30, 0xAA])], notBefore: String = "2026-07-08T00:00:00.000Z", notAfter: String = "2026-10-06T00:00:00.000Z", renewAfter: String = "2026-09-05T00:00:00.000Z" ) -> Data { let json: [String: Any] = [ "deviceId": deviceId, "cert": cert.base64EncodedString(), "caChain": caChain.map { $0.base64EncodedString() }, "notBefore": notBefore, "notAfter": notAfter, "renewAfter": renewAfter, ] return try! JSONSerialization.data(withJSONObject: json) } @Test("enroll builds a bearer-authenticated POST /device/enroll with the A4 body") func enrollBuildsRequest() async throws { // Arrange let stub = StubTransport(status: 201, body: enrollBody()) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) let csr = Data([0xDE, 0xAD, 0xBE, 0xEF]) // Act _ = try await client.enroll(csrDER: csr, subdomain: "alice", deviceName: "Alice iPhone") // Assert let request = try #require(stub.lastRequest) #expect(request.httpMethod == "POST") #expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll") #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)") #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") 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"] as? String == "ec-p256") #expect(object["subdomain"] as? String == "alice") #expect(object["deviceName"] as? String == "Alice iPhone") #expect(object["attestation"] == nil) // omitted when not provided } @Test("enroll maps a 201 response into a typed EnrollmentResult") func enrollMapsResponse() async throws { // Arrange let cert = Data([0x30, 0x82, 0x01, 0x23]) let ca = Data([0x30, 0x82, 0x02, 0x00]) let stub = StubTransport(status: 201, body: enrollBody(deviceId: "dev-xyz", cert: cert, caChain: [ca])) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) // Act let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "alice", deviceName: "iPhone") // Assert #expect(result.deviceId == "dev-xyz") #expect(result.certificate == cert) #expect(result.caChain == [ca]) #expect(result.notAfter != nil) #expect(result.renewAfter != nil) // renewAfter (2026-09-05) is before notAfter (2026-10-06). #expect(result.renewAfter! < result.notAfter!) } @Test("isRenewalDue flips at renewAfter") func renewalDueSeam() async throws { let stub = StubTransport(status: 201, body: enrollBody()) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d") let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")! let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")! #expect(result.isRenewalDue(asOf: before) == false) #expect(result.isRenewalDue(asOf: after) == true) } @Test("attestation passphrase is forwarded when provided") func enrollForwardsAttestation() 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", attestation: "attest-blob" ) let object = try JSONSerialization.jsonObject( with: stub.lastRequest!.httpBody! ) as! [String: Any] #expect(object["attestation"] as? String == "attest-blob") } @Test("a non-201 response throws http with the server error code") func enrollRejectSurfacesStatus() async throws { // 403 subdomain-not-owned → { error: "rejected" }. let body = try! JSONSerialization.data(withJSONObject: ["error": "rejected"]) let stub = StubTransport(status: 403, body: body) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) await #expect(throws: DeviceEnrollmentError.http(status: 403, code: "rejected")) { _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "bob", deviceName: "d") } } @Test("a malformed 201 body throws malformedResponse") func enrollMalformedBody() async throws { let stub = StubTransport(status: 201, body: Data("not json".utf8)) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) await #expect(throws: DeviceEnrollmentError.malformedResponse) { _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d") } } @Test("attestChallenge maps a 200 challenge response") func attestChallengeMaps() async throws { let body = try! JSONSerialization.data( withJSONObject: ["challenge": "Y2hhbGxlbmdl", "expires_in": 120] ) let stub = StubTransport(status: 200, body: body) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) let challenge = try await client.attestChallenge() #expect(challenge.challenge == "Y2hhbGxlbmdl") #expect(challenge.expiresIn == 120) #expect(stub.lastRequest?.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/attest/challenge") } @Test("renew targets POST /device/:id/renew (A6 seam)") func renewTargetsRenewEndpoint() async throws { let stub = StubTransport(status: 201, body: enrollBody()) let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) _ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02])) #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)") }