Files
buzz/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift
Tom Brow 868e2f8f32 refactor(push): one app profile per gateway (#8206)
Buzz push currently selects an application profile, such as `dogfood`,
during client registration. We want each gateway to serve one Apple app
configured by the operator instead. This removes profile selection from
the client, relay, and gateway, while allowing internal and public
gateways to use their own Apple identities.

This PR implements that model in the Buzz source and upstream deployment
chart. Discovery, enrollment, grants, and leases no longer carry an
application-profile selector. Each gateway has one configured Apple
application and APNs transport.

The protocol changes are incompatible with the old registration format,
so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do
not discover the new push capability. Updated clients use separate
Keychain records and register again. The [push
notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md)
describe the experimental contract.

Landing this PR is the first step. Publish the matching gateway image
and upstream chart, then land the [shared chart
update](https://github.com/squareup/cash-helm-charts/pull/5740) and
[gateway deployment
update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085).
Before cutover, stop old gateway instances and check legacy registration
state. Migrations refuse to proceed if old gateway installations or
active relay leases remain; they do not delete that state.
Physical-device verification remains necessary before production
activation.

### Validation
Gateway and focused Flutter push tests pass, as do the Swift package,
simulator build, and native notification/enrichment tests. A local relay
advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The
PostgreSQL suite passed 868 tests; its two migration-test failures were
corrected and pass a focused rerun together with the gateway cutover
guard. Chart rendering and release-contract checks pass.

Desktop JavaScript tests, desktop and web builds, native desktop
compilation, mobile analysis/tests, and desktop native tests pass. The
full Rust unit lane and prescribed `just test` command pass, including
workspace integration tests against task-isolated local services. The
production overlay now requires an explicitly supplied APNs topic, with
omission and rendered-value regression coverage. The corrected Swift
checksum assertion passes the complete package suite: 78 XCTest tests
and 42 Swift Testing tests. No physical-device enrollment or production
cutover is claimed.

Local merge validation: the embedded migration test and formatting pass
after reserving profile migration 0060 behind rollback migration 0059.
The local pre-push Rust lane failed ACP timing/steering tests
(`idle_resets_on_stdout_activity`,
`keepalive_resets_idle_past_deadline`, and the failed/missing-outcome
steering tests). ACP and its dependency manifests are unchanged by this
branch; all four pass in an isolated single-thread rerun. The documented
local-check exception skips only the Rust pre-push lane for publication;
required CI remains mandatory. Mobile and desktop pre-push lanes passed.

Combined migration validation: after rollback 0059 landed, the main
merge preserves profile migration 0060 and both embedded migration
assertions. The focused migration test and formatting pass. The next
local push again failed only the unchanged ACP idle/keepalive timing
tests (1053 passed, 2 failed, 3 ignored); other lanes passed.
Publication uses the same documented local Rust-lane exception; required
CI remains mandatory.

Follow-up review fix: removed the legacy profile column from the
copied-lease fixture. All four event-follow-up contract tests pass on
isolated PostgreSQL databases, covering desired and migrated schemas in
dual and app-only modes. Formatting passes. Publication retains the
prior passing local-lane evidence and uses the authorized pre-push
exception for the known unrelated ACP timing failures; required CI
remains mandatory.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 15:09:53 -07:00

1420 lines
50 KiB
Swift

import CryptoKit
import Foundation
import Security
import XCTest
@testable import BuzzPushKit
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
final class BuzzDevPushEnrollmentDriverTests: XCTestCase {
private static let gatewayURL = URL(string: "http://push.example/")!
private static let gatewayOrigin = "http://push.example"
private static let relayURL = URL(string: "wss://relay.example/")!
private static let relayPubkey = String(repeating: "a", count: 64)
private static let firstChallengeId = "11111111-1111-4111-8111-111111111111"
private static let secondChallengeId = "33333333-3333-4333-8333-333333333333"
private static let installationHandle = "22222222-2222-4222-8222-222222222222"
private static let installationId = "000102030405060708090a0b0c0d0e0f"
private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
private static let now: Int64 = 1_752_620_000
private static let expiresAt: Int64 = 1_752_624_000
private static let endpoint =
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="
fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString()
fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString()
override func setUp() {
super.setUp()
URLProtocolStub.reset()
}
override func tearDown() {
URLProtocolStub.reset()
super.tearDown()
}
func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws {
let store = MemoryGrantStore()
let appAttest = RecordingAppAttest()
let driver = try makeDriver(store: store, appAttest: appAttest)
var challengeCount = 0
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json")
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": [
"keys": [
["id": "current", "pubkey": Self.relayPubkey, "current": true]
]
],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
let body = try Self.body(request)
XCTAssertEqual(body["v"] as? Int, 1)
XCTAssertEqual(body.count, 1)
let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId
return Self.response(
request,
status: 200,
json: [
"challenge_id": id,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
let body = try Self.body(request)
XCTAssertEqual(body["endpoint"] as? String, Self.endpoint)
XCTAssertEqual(body["endpoint_epoch"] as? Int, 1)
XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt)
XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId)
XCTAssertEqual(body["challenge"] as? String, Self.challenge)
XCTAssertEqual(body["key_id"] as? String, Self.keyId)
XCTAssertEqual(body["attestation"] as? String, Self.attestation)
XCTAssertNil(body["app_profile"])
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
let body = try Self.body(request)
XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey)
XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle)
XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId)
XCTAssertEqual(body["challenge"] as? String, Self.challenge)
XCTAssertEqual(body["endpoint_epoch"] as? Int, 1)
XCTAssertEqual(body["not_before"] as? Int64, Self.now)
XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt)
XCTAssertEqual(body["assertion"] as? String, Self.assertion)
XCTAssertEqual(body["generation"] as? Int, 1)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant"]
)
default:
XCTFail(
"Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")"
)
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(appAttest.clientData.count, 2)
XCTAssertEqual(record.gatewayOrigin, Self.gatewayOrigin)
XCTAssertEqual(record.relayOrigin, "wss://relay.example")
try assertMatchesVector(
"enroll",
actual: appAttest.clientData[0],
expectedSHA256: "792966649266bfcebdb79de54e8ed3880746f58efd77427a0f43e88c650d79d0",
fixture: makeFixtureTranscript(
name: "enroll",
replacements: []
)
)
try assertMatchesVector(
"delegate",
actual: appAttest.clientData[1],
expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323",
fixture: makeFixtureTranscript(
name: "delegate",
replacements: [
(Self.firstChallengeId, Self.secondChallengeId)
]
)
)
XCTAssertEqual(
record,
BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
appAttestKeyId: Self.keyId,
installationId: Self.installationId,
endpointGrant: "opaque-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
)
XCTAssertEqual(store.saved, [record])
}
func testCommittedInstallationRecoversAfterFinalGrantSaveFailure() async throws {
let store = MemoryGrantStore(grantSaveFailuresRemaining: 1)
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
var challengeCount = 0
var installationCount = 0
var delegationCount = 0
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
return Self.response(
request,
status: 200,
json: [
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
installationCount += 1
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
delegationCount += 1
let body = try Self.body(request)
XCTAssertEqual(body["generation"] as? Int, delegationCount)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant-\(delegationCount)"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
do {
_ = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTFail("Expected the injected local save failure")
} catch {
XCTAssertEqual((error as NSError).domain, "MemoryGrantStore")
}
XCTAssertEqual(store.pending.first?.delegationGeneration, 1)
let recovered = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(installationCount, 1)
XCTAssertEqual(delegationCount, 2)
XCTAssertEqual(recovered.generation, 2)
XCTAssertEqual(recovered.endpointGrant, "opaque-grant-2")
XCTAssertTrue(store.pending.isEmpty)
}
func testCommittedInstallationRecoversAfterResponseLoss() async throws {
let store = MemoryGrantStore()
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
var challengeCount = 0
var installationCount = 0
var firstInstallationBody: [String: Any]?
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
return Self.response(
request,
status: 200,
json: [
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
installationCount += 1
let body = try Self.body(request)
if installationCount == 1 {
firstInstallationBody = body
throw URLError(.networkConnectionLost)
}
XCTAssertTrue(
NSDictionary(dictionary: body).isEqual(to: try XCTUnwrap(firstInstallationBody))
)
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
do {
_ = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTFail("Expected the simulated lost installation response")
} catch {
XCTAssertEqual((error as NSError).domain, NSURLErrorDomain)
}
XCTAssertNil(store.pending.first?.gatewayInstallationHandle)
let recovered = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(challengeCount, 2)
XCTAssertEqual(installationCount, 2)
XCTAssertEqual(recovered.endpointGrant, "opaque-grant")
XCTAssertTrue(store.pending.isEmpty)
}
func testResponseLostEnrollmentSurvivesTokenRotationAndAnotherReplayFailure() async throws {
let store = MemoryGrantStore()
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
let rotatedToken = Data(repeating: 42, count: 32)
let rotatedHandle = "44444444-4444-4444-8444-444444444444"
var oldRequests = 0
var newRequests = 0
var firstBody: [String: Any]?
var delegatedHandles: [String] = []
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(request, status: 200, json: [
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]
])
case ("POST", "http://push.example/v1/installations/challenges"):
return Self.response(request, status: 200, json: [
"challenge_id": Self.firstChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
])
case ("POST", "http://push.example/v1/installations"):
let body = try Self.body(request)
let oldEndpoint = body["endpoint"] as? String == Self.endpoint
if oldEndpoint {
oldRequests += 1
if let firstBody {
XCTAssertTrue(NSDictionary(dictionary: body).isEqual(to: firstBody))
} else {
firstBody = body
}
if oldRequests <= 2 { throw URLError(.networkConnectionLost) }
} else {
newRequests += 1
XCTAssertEqual(body["endpoint"] as? String, Self.hex(rotatedToken))
XCTAssertEqual(delegatedHandles, [Self.installationHandle])
}
return Self.response(request, status: 201, json: [
"installation_handle": oldEndpoint ? Self.installationHandle : rotatedHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
])
case ("POST", "http://push.example/v1/delegations"):
let handle = try XCTUnwrap(Self.body(request)["installation_handle"] as? String)
delegatedHandles.append(handle)
return Self.response(request, status: 201, json: ["endpoint_grant": "grant-\(handle)"])
default:
XCTFail("Unexpected request, including any migration/revocation route: \(request.url!)")
return Self.response(request, status: 500, json: [:])
}
}
for token in [Data((1...32).map(UInt8.init)), rotatedToken] {
do {
_ = try await driver.enroll(deviceToken: token, relayURL: Self.relayURL)
XCTFail("Expected response loss")
} catch {
XCTAssertEqual((error as NSError).domain, NSURLErrorDomain)
}
XCTAssertEqual(store.pending.first?.endpoint, Self.endpoint)
XCTAssertTrue(store.saved.isEmpty)
}
let grant = try await driver.enroll(deviceToken: rotatedToken, relayURL: Self.relayURL)
XCTAssertEqual(oldRequests, 3)
XCTAssertEqual(newRequests, 1)
XCTAssertEqual(delegatedHandles, [Self.installationHandle, rotatedHandle])
XCTAssertEqual(grant.endpointHash, Self.hex(SHA256.hash(data: rotatedToken)))
XCTAssertTrue(store.pending.isEmpty)
}
func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws {
let relayURL = URL(string: "wss://relay.example:8443/")!
let store = MemoryGrantStore()
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
var challengeCount = 0
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example:8443/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
challengeCount += 1
return Self.response(
request,
status: 200,
json: [
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
return Self.response(
request,
status: 201,
json: [
"installation_handle": Self.installationHandle,
"endpoint_epoch": 1,
"expires_at": Self.expiresAt,
]
)
case ("POST", "http://push.example/v1/delegations"):
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "opaque-grant"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: relayURL
)
XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443")
}
func testLegacyGrantWithoutGatewayOriginIsRejected() throws {
let data = Data(
#"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#
.utf8
)
XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data))
}
func testGrantWithoutAppAttestKeyIsRejected() throws {
let data = Data(
#"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#
.utf8
)
XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data))
}
func testOtherGatewayGrantIsNeitherExportedNorMigrated() throws {
let other = BuzzPushEndpointGrantRecord(
gatewayOrigin: "https://other-push.example",
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
appAttestKeyId: Self.keyId,
installationId: Self.installationId,
endpointGrant: "other-gateway-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [other])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
XCTAssertEqual(try driver.endpointGrants(), [])
XCTAssertEqual(store.saved, [other])
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
}
func testRealAppAttestFailsLoudlyWhenUnsupported() async throws {
let service = RecordingDCAppAttestService(isSupported: false)
let provider = BuzzDCAppAttestProvider(
service: service,
keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId)
)
do {
_ = try await provider.prepareAttestation()
XCTFail("Expected App Attest to be unavailable")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported)
}
XCTAssertEqual(service.generateKeyCallCount, 0)
}
func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws {
let service = RecordingDCAppAttestService(
generatedKeyId: Self.keyId,
attestationObject: Data([0x01, 0x02, 0x03])
)
let keyIdStore = MemoryAppAttestKeyIdStore()
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
let clientData = Data("enrollment transcript".utf8)
let prepared = try await provider.prepareAttestation()
let attestation = try await provider.attestation(prepared, clientData: clientData)
XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: ""))
XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId])
XCTAssertEqual(attestation.keyId, Self.keyId)
XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString())
XCTAssertEqual(service.attestedKeyIds, [Self.keyId])
XCTAssertEqual(
service.attestationClientDataHashes,
[Data(SHA256.hash(data: clientData))]
)
}
func testRealAppAttestAssertionUsesRequestedKeyAndMapsObject() async throws {
let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06]))
let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId)
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
let clientData = Data("delegation transcript".utf8)
let assertion = try await provider.assertion(keyId: Self.keyId, clientData: clientData)
XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString())
XCTAssertEqual(service.assertedKeyIds, [Self.keyId])
XCTAssertEqual(
service.assertionClientDataHashes,
[Data(SHA256.hash(data: clientData))]
)
XCTAssertEqual(service.generateKeyCallCount, 0)
}
func testRealAppAttestAssertionUsesInstallationKeyInsteadOfLatestStoredKey() async throws {
let installationKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString()
let service = RecordingDCAppAttestService(assertionObject: Data([0x07]))
let provider = BuzzDCAppAttestProvider(
service: service,
keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId)
)
_ = try await provider.assertion(
keyId: installationKeyId,
clientData: Data("retired installation transcript".utf8)
)
XCTAssertEqual(service.assertedKeyIds, [installationKeyId])
}
func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws {
for invalidKeyId in [
"not-a-key-id",
String(Self.keyId.dropLast(2)) + "p=",
Data(repeating: 0xAA, count: 31).base64EncodedString(),
Data(repeating: 0xAA, count: 33).base64EncodedString(),
] {
let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId)
let keyIdStore = MemoryAppAttestKeyIdStore()
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
do {
_ = try await provider.prepareAttestation()
XCTFail("Accepted invalid generated key ID: \(invalidKeyId)")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId)
}
XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty)
}
}
func testRealAppAttestRejectsMismatchedPreparedKey() async throws {
let service = RecordingDCAppAttestService()
let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId)
let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore)
let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString()
do {
_ = try await provider.attestation(
BuzzDevAttestation(keyId: otherKeyId, attestation: ""),
clientData: Data("enrollment transcript".utf8)
)
XCTFail("Expected the prepared key ID to match persistent state")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId)
}
XCTAssertTrue(service.attestedKeyIds.isEmpty)
}
func testRealAppAttestForwardsServiceErrors() async throws {
let expected = NSError(domain: "DeviceCheckTest", code: 41)
let service = RecordingDCAppAttestService(error: expected)
let provider = BuzzDCAppAttestProvider(
service: service,
keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId)
)
do {
_ = try await provider.assertion(
keyId: Self.keyId,
clientData: Data("delegation transcript".utf8)
)
XCTFail("Expected the DeviceCheck error")
} catch {
XCTAssertEqual((error as NSError).domain, expected.domain)
XCTAssertEqual((error as NSError).code, expected.code)
}
}
func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws {
var capturedQuery: [String: Any] = [:]
let store = BuzzAppAttestKeyIdKeychainStore(
accessGroup: "group.buzz",
copyMatching: { query, result in
capturedQuery = query as! [String: Any]
result?.pointee = Data(Self.keyId.utf8) as CFData
return errSecSuccess
}
)
XCTAssertEqual(try store.keyId(), Self.keyId)
XCTAssertEqual(
capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String)
XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest")
XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1")
XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz")
XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true)
XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String)
}
func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws {
let missing = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
copyMatching: { _, _ in errSecItemNotFound }
)
XCTAssertNil(try missing.keyId())
for invalidKeyId in [
"bad",
String(Self.keyId.dropLast(2)) + "p=",
Data(repeating: 0xAA, count: 31).base64EncodedString(),
Data(repeating: 0xAA, count: 33).base64EncodedString(),
] {
let invalid = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
copyMatching: { _, result in
result?.pointee = Data(invalidKeyId.utf8) as CFData
return errSecSuccess
}
)
XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") {
XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId)
}
}
}
func testKeychainStoreUpdatesExistingKeyId() throws {
var updatedQuery: [String: Any] = [:]
var updatedValues: [String: Any] = [:]
var addCallCount = 0
let store = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
update: { query, values in
updatedQuery = query as! [String: Any]
updatedValues = values as! [String: Any]
return errSecSuccess
},
add: { _, _ in
addCallCount += 1
return errSecSuccess
}
)
try store.saveKeyId(Self.keyId)
XCTAssertEqual(updatedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest")
XCTAssertEqual(updatedValues[kSecValueData as String] as? Data, Data(Self.keyId.utf8))
XCTAssertEqual(addCallCount, 0)
}
func testKeychainStoreAddsMissingKeyIdWithDeviceOnlyAccessibility() throws {
var addedItem: [String: Any] = [:]
let store = BuzzAppAttestKeyIdKeychainStore(
accessGroup: "group.buzz",
update: { _, _ in errSecItemNotFound },
add: { item, _ in
addedItem = item as! [String: Any]
return errSecSuccess
}
)
try store.saveKeyId(Self.keyId)
XCTAssertEqual(addedItem[kSecValueData as String] as? Data, Data(Self.keyId.utf8))
XCTAssertEqual(
addedItem[kSecAttrAccessible as String] as? String,
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String
)
XCTAssertEqual(addedItem[kSecAttrAccessGroup as String] as? String, "group.buzz")
}
func testKeychainStoreSurfacesReadUpdateAndAddErrors() throws {
let readFailure = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
copyMatching: { _, _ in errSecInteractionNotAllowed }
)
XCTAssertThrowsError(try readFailure.keyId()) {
XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed))
}
let updateFailure = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
update: { _, _ in errSecInteractionNotAllowed }
)
XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) {
XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed))
}
let addFailure = BuzzAppAttestKeyIdKeychainStore(
accessGroup: nil,
update: { _, _ in errSecItemNotFound },
add: { _, _ in errSecDuplicateItem }
)
XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) {
XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem))
}
}
func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws {
let existing = BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://first.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
appAttestKeyId: Self.keyId,
installationId: String(repeating: "f", count: 32),
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
endpointEpoch: 1,
generation: 4,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.absoluteString, "https://second.example/")
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: URL(string: "wss://second.example/")!
)
XCTAssertEqual(record.relayOrigin, "wss://second.example")
XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(record.generation, existing.generation)
XCTAssertEqual(record.installationId, Self.installationId)
XCTAssertNotEqual(record.installationId, existing.installationId)
XCTAssertEqual(store.saved.count, 2)
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws {
let secondRelayPubkey = String(repeating: "b", count: 64)
let existing = BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://first.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
appAttestKeyId: Self.keyId,
installationId: String(repeating: "f", count: 32),
endpointGrant: "first-relay-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
endpointEpoch: 1,
generation: 7,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let appAttest = RecordingAppAttest()
let driver = try makeDriver(store: store, appAttest: appAttest)
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://second.example/"):
return Self.response(
request,
status: 200,
json: [
"self": secondRelayPubkey,
"push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
return Self.response(
request,
status: 200,
json: [
"challenge_id": Self.firstChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/delegations"):
let body = try Self.body(request)
XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle)
XCTAssertEqual(body["relay_pubkey"] as? String, secondRelayPubkey)
XCTAssertEqual(body["generation"] as? Int, 1)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "second-relay-grant"]
)
case ("POST", "http://push.example/v1/installations"):
XCTFail("A second relay must not create a duplicate APNs installation")
return Self.response(request, status: 500, json: [:])
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: URL(string: "wss://second.example/")!
)
XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle)
XCTAssertEqual(record.relayPubkey, secondRelayPubkey)
XCTAssertEqual(record.endpointGrant, "second-relay-grant")
XCTAssertEqual(record.generation, 1)
XCTAssertEqual(appAttest.clientData.count, 1)
XCTAssertEqual(store.saved.count, 2)
}
func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws {
let existing = BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
gatewayInstallationHandle: Self.installationHandle,
appAttestKeyId: Self.keyId,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
endpointEpoch: 1,
generation: 7,
expiresAt: Self.now + 300
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(
store: store,
appAttest: RecordingAppAttest(),
installationIdBytes: {
XCTFail("Grant refresh must reuse the persisted installation id")
return Data(repeating: 0xFF, count: 16)
}
)
URLProtocolStub.handler = { request in
switch (request.httpMethod, request.url?.absoluteString) {
case ("GET", "https://relay.example/"):
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
case ("POST", "http://push.example/v1/installations/challenges"):
return Self.response(
request,
status: 200,
json: [
"challenge_id": Self.firstChallengeId,
"challenge": Self.challenge,
"expires_at": Self.now + 300,
]
)
case ("POST", "http://push.example/v1/installations"):
XCTFail("An expiring installation must renew through authenticated delegation")
return Self.response(request, status: 500, json: [:])
case ("POST", "http://push.example/v1/delegations"):
let body = try Self.body(request)
XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle)
XCTAssertEqual(body["generation"] as? Int, 8)
XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt)
return Self.response(
request,
status: 201,
json: ["endpoint_grant": "refreshed-grant"]
)
default:
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
return Self.response(request, status: 500, json: [:])
}
}
let record = try await driver.enroll(
deviceToken: Data((1...32).map(UInt8.init)),
relayURL: Self.relayURL
)
XCTAssertEqual(record.installationId, Self.installationId)
XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle)
XCTAssertEqual(record.generation, 8)
XCTAssertEqual(record.expiresAt, Self.expiresAt)
XCTAssertEqual(record.endpointGrant, "refreshed-grant")
}
func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws {
let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": [
"keys": [
["pubkey": Self.relayPubkey, "current": true],
["pubkey": String(repeating: "b", count: 64), "current": true],
]
],
]
)
}
do {
_ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL)
XCTFail("Expected an invalid relay descriptor")
} catch {
XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor)
}
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws {
let pushPubkey = String(repeating: "b", count: 64)
let oldMetadataPubkey = String(repeating: "c", count: 64)
let deviceToken = Data((1...32).map(UInt8.init))
let existing = BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://relay.example",
relayPubkey: pushPubkey,
relayMetadataPubkey: oldMetadataPubkey,
appAttestKeyId: Self.keyId,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": [
"keys": [
["pubkey": pushPubkey, "current": true]
]
],
]
)
}
let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL)
XCTAssertEqual(record.relayPubkey, pushPubkey)
XCTAssertEqual(record.relayMetadataPubkey, Self.relayPubkey)
XCTAssertNotEqual(record.relayMetadataPubkey, oldMetadataPubkey)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(store.saved, [record])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws {
let deviceToken = Data((1...32).map(UInt8.init))
let existing = BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
appAttestKeyId: Self.keyId,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"push": [
"keys": [["pubkey": Self.relayPubkey, "current": true]]
]
]
)
}
let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL)
XCTAssertEqual(record.relayPubkey, Self.relayPubkey)
XCTAssertNil(record.relayMetadataPubkey)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(store.saved, [record])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws {
let deviceToken = Data((1...32).map(UInt8.init))
let existing = BuzzPushEndpointGrantRecord(
gatewayOrigin: Self.gatewayOrigin,
relayOrigin: "wss://relay.example",
relayPubkey: Self.relayPubkey,
relayMetadataPubkey: Self.relayPubkey,
appAttestKeyId: Self.keyId,
installationId: Self.installationId,
endpointGrant: "existing-grant",
endpointHash: Self.hex(SHA256.hash(data: deviceToken)),
endpointEpoch: 1,
generation: 1,
expiresAt: Self.expiresAt
)
let store = MemoryGrantStore(records: [existing])
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
json: [
"self": 42,
"push": [
"keys": [["pubkey": Self.relayPubkey, "current": true]]
],
]
)
}
let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL)
XCTAssertEqual(record.relayPubkey, Self.relayPubkey)
XCTAssertNil(record.relayMetadataPubkey)
XCTAssertEqual(record.endpointGrant, existing.endpointGrant)
XCTAssertEqual(store.saved, [record])
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testFailsLoudlyOnUnexpectedGatewayStatus() async throws {
let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest())
URLProtocolStub.handler = { request in
if request.httpMethod == "GET" {
return Self.response(
request,
status: 200,
json: [
"self": Self.relayPubkey,
"push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]],
]
)
}
return Self.response(request, status: 400, json: ["error": "invalid_request"])
}
do {
_ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL)
XCTFail("Expected the gateway error")
} catch let error as BuzzDevPushEnrollmentError {
XCTAssertEqual(
error,
.unexpectedStatus(
route: "v1/installations/challenges",
expected: 200,
actual: 400,
body: "{\"error\":\"invalid_request\"}"
)
)
}
}
private func makeDriver(
gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL,
store: BuzzPushEndpointGrantStore,
appAttest: BuzzDevAppAttesting,
installationIdBytes: @escaping () throws -> Data = {
Data(0..<16)
}
) throws -> BuzzDevPushEnrollmentDriver {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
return try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayBaseURL,
store: store,
session: URLSession(configuration: configuration),
appAttest: appAttest,
now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) },
lifetimeSeconds: Self.expiresAt - Self.now,
installationIdBytes: installationIdBytes
)
}
private func makeFixtureTranscript(
name: String,
replacements: [(String, String)]
) throws -> (bytes: Data, sha256: String) {
let fixture = try Self.fixture()
let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name })
let transcript = replacements.reduce(vector.transcript) {
$0.replacingOccurrences(of: $1.0, with: $1.1)
}
return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8))))
}
private func assertMatchesVector(
_ name: String,
actual: Data,
expectedSHA256: String,
fixture: (bytes: Data, sha256: String),
file: StaticString = #filePath,
line: UInt = #line
) throws {
XCTAssertEqual(
fixture.sha256,
expectedSHA256,
"\(name) substituted gateway vector SHA-256",
file: file,
line: line
)
XCTAssertEqual(
actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line)
XCTAssertEqual(
Self.hex(SHA256.hash(data: actual)),
fixture.sha256,
"\(name) transcript SHA-256",
file: file,
line: line
)
}
private struct Fixture: Decodable {
struct Vector: Decodable {
let name: String
let transcript: String
}
let vectors: [Vector]
}
private static func fixture() throws -> Fixture {
let path = try XCTUnwrap(
Bundle.module.url(
forResource: "app_attest_transcripts",
withExtension: "json"
),
"missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)"
)
let data = try Data(contentsOf: path)
return try JSONDecoder().decode(Fixture.self, from: data)
}
private static func body(_ request: URLRequest) throws -> [String: Any] {
let data: Data
if let httpBody = request.httpBody {
data = httpBody
} else {
let stream = try XCTUnwrap(request.httpBodyStream)
stream.open()
defer { stream.close() }
var bytes = Data()
var buffer = [UInt8](repeating: 0, count: 1_024)
while true {
let count = stream.read(&buffer, maxLength: buffer.count)
if count < 0 {
throw try XCTUnwrap(stream.streamError)
}
if count == 0 { break }
bytes.append(buffer, count: count)
}
data = bytes
}
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private static func response(
_ request: URLRequest,
status: Int,
json: [String: Any]
) -> (HTTPURLResponse, Data) {
let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])
let response = HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
private static func hex<D: Sequence>(_ data: D) -> String where D.Element == UInt8 {
data.map { String(format: "%02x", $0) }.joined()
}
}
private final class MemoryGrantStore: BuzzPushEndpointGrantStore {
var saved: [BuzzPushEndpointGrantRecord]
var pending: [BuzzPushPendingEnrollmentRecord] = []
var grantSaveFailuresRemaining: Int
var pendingRemoveFailuresRemaining: Int
init(
records: [BuzzPushEndpointGrantRecord] = [],
pending: [BuzzPushPendingEnrollmentRecord] = [],
grantSaveFailuresRemaining: Int = 0,
pendingRemoveFailuresRemaining: Int = 0
) {
saved = records
self.pending = pending
self.grantSaveFailuresRemaining = grantSaveFailuresRemaining
self.pendingRemoveFailuresRemaining = pendingRemoveFailuresRemaining
}
func records() throws -> [BuzzPushEndpointGrantRecord] { saved }
func save(_ record: BuzzPushEndpointGrantRecord) throws {
if grantSaveFailuresRemaining > 0 {
grantSaveFailuresRemaining -= 1
throw NSError(domain: "MemoryGrantStore", code: 1)
}
saved.removeAll {
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
}
saved.append(record)
}
func pendingEnrollment(
gatewayOrigin: String,
relayOrigin: String
) throws -> BuzzPushPendingEnrollmentRecord? {
pending.first {
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
}
}
func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws {
pending.removeAll {
$0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin
}
pending.append(record)
}
func removePendingEnrollment(
gatewayOrigin: String,
relayOrigin: String
) throws {
if pendingRemoveFailuresRemaining > 0 {
pendingRemoveFailuresRemaining -= 1
throw NSError(domain: "MemoryGrantStore", code: 3)
}
pending.removeAll {
$0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin
}
}
}
private final class RecordingAppAttest: BuzzDevAppAttesting {
var clientData: [Data] = []
var preparedAttestations: [BuzzDevAttestation] = []
var assertionKeyIds: [String] = []
func prepareAttestation() async throws -> BuzzDevAttestation {
let prepared = BuzzDevAttestation(
keyId: BuzzDevPushEnrollmentDriverTests.keyId,
attestation: BuzzDevPushEnrollmentDriverTests.attestation
)
preparedAttestations.append(prepared)
return prepared
}
func attestation(
_ prepared: BuzzDevAttestation,
clientData: Data
) async throws -> BuzzDevAttestation {
self.clientData.append(clientData)
return prepared
}
func assertion(keyId: String, clientData: Data) async throws -> String {
self.clientData.append(clientData)
assertionKeyIds.append(keyId)
return BuzzDevPushEnrollmentDriverTests.assertion
}
}
private final class MemoryAppAttestKeyIdStore: BuzzAppAttestKeyIdStoring {
var keyIdValue: String?
var savedKeyIds: [String] = []
init(keyId: String? = nil) {
keyIdValue = keyId
}
func keyId() throws -> String? { keyIdValue }
func saveKeyId(_ keyId: String) throws {
savedKeyIds.append(keyId)
keyIdValue = keyId
}
}
private final class RecordingDCAppAttestService: BuzzDCAppAttestServicing {
let isSupported: Bool
let generatedKeyId: String
let attestationObject: Data
let assertionObject: Data
let error: Error?
var generateKeyCallCount = 0
var attestedKeyIds: [String] = []
var attestationClientDataHashes: [Data] = []
var assertedKeyIds: [String] = []
var assertionClientDataHashes: [Data] = []
init(
isSupported: Bool = true,
generatedKeyId: String = BuzzDevPushEnrollmentDriverTests.keyId,
attestationObject: Data = Data("attestation-object".utf8),
assertionObject: Data = Data("assertion-object".utf8),
error: Error? = nil
) {
self.isSupported = isSupported
self.generatedKeyId = generatedKeyId
self.attestationObject = attestationObject
self.assertionObject = assertionObject
self.error = error
}
func generateKey() async throws -> String {
generateKeyCallCount += 1
if let error { throw error }
return generatedKeyId
}
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data {
attestedKeyIds.append(keyId)
attestationClientDataHashes.append(clientDataHash)
if let error { throw error }
return attestationObject
}
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data {
assertedKeyIds.append(keyId)
assertionClientDataHashes.append(clientDataHash)
if let error { throw error }
return assertionObject
}
}
private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
static let lock = NSLock()
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static var requests: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.lock.lock()
Self.requests.append(request)
let handler = Self.handler
Self.lock.unlock()
do {
let (response, data) =
try handler?(request)
?? {
throw URLError(.unsupportedURL)
}()
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
static func reset() {
lock.lock()
handler = nil
requests = []
lock.unlock()
}
}