diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushInstallationState.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushInstallationState.swift new file mode 100644 index 000000000..e894db32b --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushInstallationState.swift @@ -0,0 +1,74 @@ +import CryptoKit +import Foundation + +/// Durable client-side record of one NIP-PL push installation. +/// +/// This is the state the cold review's B2/B4 findings are about: it must +/// survive relaunch, must NOT survive backup-restore onto a new device +/// (App Attest keys are device-bound, so a restored copy is a zombie), and +/// must stay epoch-consistent with the gateway across a crash between +/// `POST /v1/installations/endpoint` succeeding and the local commit. +/// +/// The two-phase `pendingRotation` field is the crash-window answer: the +/// intent is persisted *before* the rotate request is sent, and cleared only +/// after the outcome is known. `BuzzPushLifecycle` turns a surviving intent +/// back into a resumable decision. +public struct BuzzPushInstallationState: Codable, Equatable { + /// Two-phase commit record for an in-flight endpoint rotation. + public struct PendingRotation: Codable, Equatable { + /// `endpoint_epoch + 1` at the time the intent was written. + public var newEndpointEpoch: Int64 + /// Fingerprint of the APNs token the rotation is moving to. + public var newEndpointFingerprint: String + + public init(newEndpointEpoch: Int64, newEndpointFingerprint: String) { + self.newEndpointEpoch = newEndpointEpoch + self.newEndpointFingerprint = newEndpointFingerprint + } + } + + /// Gateway-issued installation handle (`installation_handle`). + public var installationHandle: UUID + /// App Attest key identifier (standard base64), device-bound. + public var keyId: String + /// Registered profile, e.g. `buzz-ios-production`. + public var appProfile: String + /// Current committed endpoint epoch (matches gateway on the happy path). + public var endpointEpoch: Int64 + /// SHA-256 hex of the lowercase-hex APNs token currently enrolled. + /// Stored instead of the raw token so change detection never requires + /// retaining the token itself. + public var endpointFingerprint: String + /// Installation expiry (unix seconds), as returned by enrollment. + public var expiresAt: Int64 + /// In-flight rotation intent, if a rotate was started but not confirmed. + public var pendingRotation: PendingRotation? + + public init( + installationHandle: UUID, + keyId: String, + appProfile: String, + endpointEpoch: Int64, + endpointFingerprint: String, + expiresAt: Int64, + pendingRotation: PendingRotation? = nil + ) { + self.installationHandle = installationHandle + self.keyId = keyId + self.appProfile = appProfile + self.endpointEpoch = endpointEpoch + self.endpointFingerprint = endpointFingerprint + self.expiresAt = expiresAt + self.pendingRotation = pendingRotation + } + + /// Canonical fingerprint of an APNs token: SHA-256 hex over the ASCII + /// bytes of the lowercase-hex token string (the same representation the + /// transcripts carry). Purely local — this is NOT the gateway's + /// `(app_profile, SHA-256(token))` uniqueness fingerprint. + public static func fingerprint(ofEndpoint endpointHex: String) -> String { + SHA256.hash(data: Data(endpointHex.lowercased().utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLifecycle.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLifecycle.swift new file mode 100644 index 000000000..4fafb45da --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushLifecycle.swift @@ -0,0 +1,216 @@ +import Foundation + +/// Pure NIP-PL client lifecycle decision machine. +/// +/// This encodes the B2/B4 rules from the iOS lifecycle cold review as a +/// side-effect-free function of (persisted state, observed APNs token, +/// clock), so every rule is unit-testable without App Attest, Keychain, or +/// network: +/// +/// - **Rotate only on real token change.** iOS re-delivers the device token +/// on every launch; an unconditional rotate bumps the server epoch each +/// launch and invalidates every outstanding relay delegation. The machine +/// compares fingerprints and answers `.noop` for a re-delivered token. +/// - **Persist-then-confirm epoch.** A rotation is two-phase: the caller +/// persists the `pendingRotation` intent *before* sending +/// `POST /v1/installations/endpoint`, and commits/clears it only on a known +/// outcome. A crash in between leaves the intent in place; the machine +/// answers `.resumeRotation` on the next launch instead of desyncing. +/// - **Zombie-key recovery.** After backup-restore to a new device the +/// restored state names an App Attest key that no longer exists (keys are +/// device-bound), so every assertion fails. `apply(outcome:)` maps +/// attestation-level rejection to `.clearAndReenroll` instead of retrying +/// forever. +/// - **Renewal.** Installations expire (`expires_at`); an expired or +/// near-expiry installation re-enrolls instead of silently failing rotate +/// and delegate calls with a stale handle. +public enum BuzzPushLifecycle { + /// How far before `expires_at` the client proactively re-enrolls. + /// 7 days against the 90-day default lifetime: early enough to cover + /// long app dormancy between launches, tiny relative to the lease. + public static let renewalLeadTimeSeconds: Int64 = 7 * 24 * 3600 + + // MARK: Launch decision + + /// What to do when APNs (re)delivers a device token. + public enum TokenDecision: Equatable { + /// No persisted installation (or it must be discarded): run full + /// enrollment for the given token fingerprint. + case enroll + /// Installation valid and the token is unchanged: do nothing. + case noop + /// Installation expired or inside the renewal window: re-enroll + /// (fresh attest key + handle), then discard the old state. + case reenroll + /// Token genuinely changed: persist `stateWithIntent` FIRST, then + /// send rotate with `endpoint_epoch = stateWithIntent.endpointEpoch` + /// and `new_endpoint_epoch = pending.newEndpointEpoch`. + case beginRotation(stateWithIntent: BuzzPushInstallationState) + /// A previous rotation intent survived (crash window): re-send the + /// same rotate. The intent already pins the target epoch, so this is + /// idempotent from the client's perspective; `apply(outcome:)` + /// resolves whether the server had already committed. + case resumeRotation(state: BuzzPushInstallationState) + } + + /// Decide the launch/token action. `tokenFingerprint` is + /// `BuzzPushInstallationState.fingerprint(ofEndpoint:)` of the freshly + /// delivered token; `now` is unix seconds. + public static func onDeviceToken( + state: BuzzPushInstallationState?, + tokenFingerprint: String, + now: Int64 + ) -> TokenDecision { + guard let state else { return .enroll } + if now >= state.expiresAt - renewalLeadTimeSeconds { + return .reenroll + } + if let pending = state.pendingRotation { + // A surviving intent wins over fingerprint comparison: the local + // committed fingerprint may or may not match the server, and the + // intent records where we were headed. + if pending.newEndpointFingerprint == tokenFingerprint { + return .resumeRotation(state: state) + } + // Token moved again while a rotation was in flight. Re-point the + // intent at the new token but keep the same target epoch — the + // old intent never confirmed, so the epoch step is still ours. + var next = state + next.pendingRotation = .init( + newEndpointEpoch: pending.newEndpointEpoch, + newEndpointFingerprint: tokenFingerprint + ) + return .beginRotation(stateWithIntent: next) + } + if state.endpointFingerprint == tokenFingerprint { + return .noop + } + var next = state + next.pendingRotation = .init( + newEndpointEpoch: state.endpointEpoch + 1, + newEndpointFingerprint: tokenFingerprint + ) + return .beginRotation(stateWithIntent: next) + } + + // MARK: Rotation outcome + + /// Gateway response classification for a rotate attempt. The caller maps + /// HTTP status/error codes to this enum. + public enum RotateOutcome: Equatable { + /// `200 {"status":"rotated"}`. + case rotated + /// `404 not_authorized` — epoch mismatch, consumed challenge, or + /// missing/expired installation. Indistinguishable by design. + case notAuthorized + /// `401 invalid_attestation` — assertion failed to verify. With a + /// device-bound key this is the zombie-key (backup-restore) or + /// tampered-state signature. + case invalidAttestation + /// Transport failure / 5xx / timeout: outcome unknown. + case transient + } + + /// What the caller must do after a rotate attempt resolves. + public enum RotateResolution: Equatable { + /// Persist this state (intent committed or advanced). + case persist(BuzzPushInstallationState) + /// Keep the persisted intent as-is and retry later (with backoff). + case retainIntent + /// Local state is unrecoverable: delete it and run full enrollment. + case clearAndReenroll + } + + /// Resolve a rotate attempt that was sent with + /// `endpoint_epoch = state.endpointEpoch` and + /// `new_endpoint_epoch = state.pendingRotation!.newEndpointEpoch`. + /// + /// `notAuthorized` on a *resumed* intent is the ambiguous crash-window + /// case: the server may have committed the previous attempt (so our old + /// epoch no longer matches). We cannot read the server epoch back, so + /// the deterministic reconvergence is: adopt the pending epoch as + /// committed, then immediately begin a fresh rotation FROM it — if the + /// server had committed, this succeeds and both sides converge; if the + /// installation is actually gone, the next attempt maps to + /// `.clearAndReenroll` via `escalated`. + public static func applyRotate( + state: BuzzPushInstallationState, + outcome: RotateOutcome, + escalated: Bool = false + ) -> RotateResolution { + guard let pending = state.pendingRotation else { + // No intent recorded: nothing to commit; treat as corrupt state. + return .clearAndReenroll + } + switch outcome { + case .rotated: + var next = state + next.endpointEpoch = pending.newEndpointEpoch + next.endpointFingerprint = pending.newEndpointFingerprint + next.pendingRotation = nil + return .persist(next) + case .transient: + return .retainIntent + case .invalidAttestation: + // Assertion itself failed: the attest key no longer signs for + // this state (restored backup / wiped key). Retrying is futile. + return .clearAndReenroll + case .notAuthorized: + if escalated { + // Already adopted-and-retried once; the installation is gone + // or expired server-side. + return .clearAndReenroll + } + // Adopt the pending epoch and step the intent forward one epoch; + // the caller persists this and sends one escalated rotate. + var next = state + next.endpointEpoch = pending.newEndpointEpoch + next.endpointFingerprint = pending.newEndpointFingerprint + next.pendingRotation = .init( + newEndpointEpoch: pending.newEndpointEpoch + 1, + newEndpointFingerprint: pending.newEndpointFingerprint + ) + return .persist(next) + } + } + + // MARK: Enrollment outcome + + /// What the caller must do after an enrollment attempt resolves. + public enum EnrollResolution: Equatable { + /// Persist this freshly enrolled state (epoch is always 1). + case persist(BuzzPushInstallationState) + /// Retry later with backoff; keep whatever state existed. + case retryLater + /// Give up on this attest key and generate a new one next attempt. + case discardKeyAndRetry + } + + /// Resolve an enrollment attempt. `keyId`/`appProfile` are what was + /// attested; the handle/epoch/expiry come from the `201` response. + public static func applyEnroll( + response: (installationHandle: UUID, endpointEpoch: Int64, expiresAt: Int64)?, + keyId: String, + appProfile: String, + endpointFingerprint: String, + invalidAttestation: Bool + ) -> EnrollResolution { + if let r = response { + return .persist( + BuzzPushInstallationState( + installationHandle: r.installationHandle, + keyId: keyId, + appProfile: appProfile, + endpointEpoch: r.endpointEpoch, + endpointFingerprint: endpointFingerprint, + expiresAt: r.expiresAt + ) + ) + } + // A rejected attestation on ENROLL means the key/attestation object + // itself was refused (or the key was already enrolled) — minting a + // fresh key is the only forward path. Transient failures retry with + // the same key. + return invalidAttestation ? .discardKeyAndRetry : .retryLater + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushStateStore.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushStateStore.swift new file mode 100644 index 000000000..e5f04f14d --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushStateStore.swift @@ -0,0 +1,159 @@ +import Foundation +#if canImport(Security) +import Security +#endif + +/// Persistence seam for `BuzzPushInstallationState`. +/// +/// The production implementation is `KeychainPushStateStore`; tests use +/// `InMemoryPushStateStore`. Kept deliberately tiny: load / save / clear. +public protocol BuzzPushStateStore { + func load() throws -> BuzzPushInstallationState? + func save(_ state: BuzzPushInstallationState) throws + func clear() throws +} + +public enum BuzzPushStateStoreError: Error, Equatable { + /// Underlying Keychain call failed with the given OSStatus. + case keychain(OSStatus) + /// Persisted bytes did not decode; the caller should treat this as + /// no-state (clear + re-enroll) rather than crash. + case corrupt +} + +/// JSON codec shared by every store. Versioned envelope so a future schema +/// change can migrate instead of tripping `corrupt`. +enum BuzzPushStateCodec { + struct Envelope: Codable { + var version: Int + var state: BuzzPushInstallationState + } + + static let currentVersion = 1 + + static func encode(_ state: BuzzPushInstallationState) throws -> Data { + try JSONEncoder().encode(Envelope(version: currentVersion, state: state)) + } + + static func decode(_ data: Data) throws -> BuzzPushInstallationState { + guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.version == currentVersion + else { + throw BuzzPushStateStoreError.corrupt + } + return envelope.state + } +} + +/// Test double / non-Darwin fallback. +public final class InMemoryPushStateStore: BuzzPushStateStore { + private var data: Data? + + public init() {} + + public func load() throws -> BuzzPushInstallationState? { + try data.map(BuzzPushStateCodec.decode) + } + + public func save(_ state: BuzzPushInstallationState) throws { + data = try BuzzPushStateCodec.encode(state) + } + + public func clear() throws { + data = nil + } +} + +#if canImport(Security) +/// Keychain-backed store (kSecClassGenericPassword). +/// +/// Attribute choices are load-bearing (cold review B2): +/// +/// - `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: +/// * *AfterFirstUnlock* — the Notification Service Extension and background +/// launches must read this while the device is locked (post-first-unlock), +/// so `WhenUnlocked` is wrong. +/// * *ThisDeviceOnly* — App Attest keys are device-bound. If this record +/// migrated through backup/restore to a new device it would name a key +/// that can never sign again (the zombie-installation failure). Excluding +/// it from device transfers kills that class at the storage layer; +/// `BuzzPushLifecycle`'s `invalidAttestation → clearAndReenroll` remains +/// as defense in depth. +/// - `kSecAttrAccessGroup` (optional) shares the item with the NSE via an +/// App Group / keychain access group. +/// +/// NOTE: this state is *authorization bookkeeping*, not key material — the +/// actual private key lives in the Secure Enclave under App Attest. +public final class KeychainPushStateStore: BuzzPushStateStore { + private let service: String + private let account: String + private let accessGroup: String? + + public init( + service: String = "xyz.buzz.push.installation", + account: String = "nip-pl-state", + accessGroup: String? = nil + ) { + self.service = service + self.account = account + self.accessGroup = accessGroup + } + + private func baseQuery() -> [String: Any] { + var q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if let accessGroup { + q[kSecAttrAccessGroup as String] = accessGroup + } + return q + } + + public func load() throws -> BuzzPushInstallationState? { + var q = baseQuery() + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var out: CFTypeRef? + let status = SecItemCopyMatching(q as CFDictionary, &out) + switch status { + case errSecSuccess: + guard let data = out as? Data else { throw BuzzPushStateStoreError.corrupt } + return try BuzzPushStateCodec.decode(data) + case errSecItemNotFound: + return nil + default: + throw BuzzPushStateStoreError.keychain(status) + } + } + + public func save(_ state: BuzzPushInstallationState) throws { + let data = try BuzzPushStateCodec.encode(state) + var add = baseQuery() + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(add as CFDictionary, nil) + if status == errSecDuplicateItem { + let update: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, update as CFDictionary) + guard updateStatus == errSecSuccess else { + throw BuzzPushStateStoreError.keychain(updateStatus) + } + } else if status != errSecSuccess { + throw BuzzPushStateStoreError.keychain(status) + } + } + + public func clear() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw BuzzPushStateStoreError.keychain(status) + } + } +} +#endif diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLifecycleTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLifecycleTests.swift new file mode 100644 index 000000000..9fb507fb7 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushLifecycleTests.swift @@ -0,0 +1,282 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +/// Unit tests for the pure lifecycle decision machine. Each test names the +/// cold-review finding (B2/B4) it pins. +final class BuzzPushLifecycleTests: XCTestCase { + // MARK: Fixtures + + static let handle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! + static let tokenA = BuzzPushInstallationState.fingerprint( + ofEndpoint: String(repeating: "01", count: 32)) + static let tokenB = BuzzPushInstallationState.fingerprint( + ofEndpoint: String(repeating: "02", count: 32)) + static let tokenC = BuzzPushInstallationState.fingerprint( + ofEndpoint: String(repeating: "03", count: 32)) + static let now: Int64 = 1_752_620_000 + + func freshState( + epoch: Int64 = 3, + fingerprint: String = tokenA, + expiresAt: Int64 = now + 60 * 24 * 3600, + pending: BuzzPushInstallationState.PendingRotation? = nil + ) -> BuzzPushInstallationState { + BuzzPushInstallationState( + installationHandle: Self.handle, + keyId: "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=", + appProfile: "buzz-ios-production", + endpointEpoch: epoch, + endpointFingerprint: fingerprint, + expiresAt: expiresAt, + pendingRotation: pending + ) + } + + // MARK: Token fingerprinting + + func testFingerprintIsCaseInsensitiveOverHex() { + XCTAssertEqual( + BuzzPushInstallationState.fingerprint(ofEndpoint: "0A0B0C"), + BuzzPushInstallationState.fingerprint(ofEndpoint: "0a0b0c") + ) + } + + // MARK: B4 — rotate only on real token change + + func testNoStateEnrolls() { + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken(state: nil, tokenFingerprint: Self.tokenA, now: Self.now), + .enroll + ) + } + + func testRedeliveredTokenIsNoop() { + // iOS re-delivers the same token every launch; this MUST NOT rotate + // (B4: unconditional rotate invalidates all delegations per launch). + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken( + state: freshState(), tokenFingerprint: Self.tokenA, now: Self.now), + .noop + ) + } + + func testChangedTokenBeginsRotationWithPersistedIntent() { + let decision = BuzzPushLifecycle.onDeviceToken( + state: freshState(), tokenFingerprint: Self.tokenB, now: Self.now) + guard case let .beginRotation(next) = decision else { + return XCTFail("expected beginRotation, got \(decision)") + } + // Committed fields untouched until the gateway confirms. + XCTAssertEqual(next.endpointEpoch, 3) + XCTAssertEqual(next.endpointFingerprint, Self.tokenA) + XCTAssertEqual(next.pendingRotation, + .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + } + + // MARK: B4 — renewal window + + func testExpiredInstallationReenrolls() { + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken( + state: freshState(expiresAt: Self.now - 1), + tokenFingerprint: Self.tokenA, now: Self.now), + .reenroll + ) + } + + func testInsideRenewalLeadTimeReenrolls() { + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken( + state: freshState(expiresAt: Self.now + BuzzPushLifecycle.renewalLeadTimeSeconds - 1), + tokenFingerprint: Self.tokenA, now: Self.now), + .reenroll + ) + } + + func testOutsideRenewalLeadTimeDoesNotReenroll() { + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken( + state: freshState(expiresAt: Self.now + BuzzPushLifecycle.renewalLeadTimeSeconds + 1), + tokenFingerprint: Self.tokenA, now: Self.now), + .noop + ) + } + + // MARK: B2 — crash window (persist-then-confirm) + + func testSurvivingIntentResumesRotation() { + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken( + state: state, tokenFingerprint: Self.tokenB, now: Self.now), + .resumeRotation(state: state) + ) + } + + func testTokenMovedAgainDuringInFlightRotationKeepsTargetEpoch() { + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + let decision = BuzzPushLifecycle.onDeviceToken( + state: state, tokenFingerprint: Self.tokenC, now: Self.now) + guard case let .beginRotation(next) = decision else { + return XCTFail("expected beginRotation, got \(decision)") + } + // The unconfirmed epoch step is reused; only the destination token moves. + XCTAssertEqual(next.pendingRotation, + .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenC)) + } + + func testSurvivingIntentMatchingCommittedFingerprintStillResumes() { + // Crash AFTER server commit but BEFORE local commit, then the token + // reverts (or never actually changed): the intent must still resolve + // through the rotate path, never be dropped on the floor. + let state = freshState( + fingerprint: Self.tokenA, + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenA)) + XCTAssertEqual( + BuzzPushLifecycle.onDeviceToken( + state: state, tokenFingerprint: Self.tokenA, now: Self.now), + .resumeRotation(state: state) + ) + } + + // MARK: Rotate outcomes + + func testRotatedCommitsEpochAndClearsIntent() { + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + let resolution = BuzzPushLifecycle.applyRotate(state: state, outcome: .rotated) + guard case let .persist(next) = resolution else { + return XCTFail("expected persist, got \(resolution)") + } + XCTAssertEqual(next.endpointEpoch, 4) + XCTAssertEqual(next.endpointFingerprint, Self.tokenB) + XCTAssertNil(next.pendingRotation) + } + + func testTransientRetainsIntent() { + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + XCTAssertEqual( + BuzzPushLifecycle.applyRotate(state: state, outcome: .transient), + .retainIntent + ) + } + + // MARK: B2 — zombie key (backup-restore) + + func testInvalidAttestationClearsAndReenrolls() { + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + XCTAssertEqual( + BuzzPushLifecycle.applyRotate(state: state, outcome: .invalidAttestation), + .clearAndReenroll + ) + } + + // MARK: B2 — epoch desync reconvergence + + func testNotAuthorizedAdoptsPendingEpochAndEscalates() { + // Ambiguous crash window: the server may have committed epoch 4. + // First notAuthorized adopts 4 and points a fresh intent at 5. + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + let resolution = BuzzPushLifecycle.applyRotate(state: state, outcome: .notAuthorized) + guard case let .persist(next) = resolution else { + return XCTFail("expected persist, got \(resolution)") + } + XCTAssertEqual(next.endpointEpoch, 4) + XCTAssertEqual(next.endpointFingerprint, Self.tokenB) + XCTAssertEqual(next.pendingRotation, + .init(newEndpointEpoch: 5, newEndpointFingerprint: Self.tokenB)) + } + + func testEscalatedNotAuthorizedClearsAndReenrolls() { + let state = freshState( + epoch: 4, fingerprint: Self.tokenB, + pending: .init(newEndpointEpoch: 5, newEndpointFingerprint: Self.tokenB)) + XCTAssertEqual( + BuzzPushLifecycle.applyRotate(state: state, outcome: .notAuthorized, escalated: true), + .clearAndReenroll + ) + } + + func testRotateWithoutIntentIsCorruptState() { + XCTAssertEqual( + BuzzPushLifecycle.applyRotate(state: freshState(), outcome: .rotated), + .clearAndReenroll + ) + } + + // MARK: Enrollment outcomes + + func testEnrollSuccessPersistsEpochOneState() { + let resolution = BuzzPushLifecycle.applyEnroll( + response: (Self.handle, 1, Self.now + 90 * 24 * 3600), + keyId: "key", + appProfile: "buzz-ios-production", + endpointFingerprint: Self.tokenA, + invalidAttestation: false + ) + guard case let .persist(state) = resolution else { + return XCTFail("expected persist, got \(resolution)") + } + XCTAssertEqual(state.endpointEpoch, 1) + XCTAssertEqual(state.endpointFingerprint, Self.tokenA) + XCTAssertNil(state.pendingRotation) + } + + func testEnrollInvalidAttestationDiscardsKey() { + XCTAssertEqual( + BuzzPushLifecycle.applyEnroll( + response: nil, keyId: "key", appProfile: "buzz-ios-production", + endpointFingerprint: Self.tokenA, invalidAttestation: true), + .discardKeyAndRetry + ) + } + + func testEnrollTransientRetries() { + XCTAssertEqual( + BuzzPushLifecycle.applyEnroll( + response: nil, keyId: "key", appProfile: "buzz-ios-production", + endpointFingerprint: Self.tokenA, invalidAttestation: false), + .retryLater + ) + } + + // MARK: Store round-trip (codec + in-memory store) + + func testStateStoreRoundTrip() throws { + let store = InMemoryPushStateStore() + XCTAssertNil(try store.load()) + let state = freshState( + pending: .init(newEndpointEpoch: 4, newEndpointFingerprint: Self.tokenB)) + try store.save(state) + XCTAssertEqual(try store.load(), state) + try store.clear() + XCTAssertNil(try store.load()) + } + + func testCorruptEnvelopeThrowsCorrupt() { + XCTAssertThrowsError(try BuzzPushStateCodec.decode(Data("{}".utf8))) { error in + XCTAssertEqual(error as? BuzzPushStateStoreError, .corrupt) + } + XCTAssertThrowsError(try BuzzPushStateCodec.decode(Data("not json".utf8))) { error in + XCTAssertEqual(error as? BuzzPushStateStoreError, .corrupt) + } + } + + func testFutureVersionEnvelopeIsCorruptNotCrash() throws { + let state = freshState() + var object = try JSONSerialization.jsonObject( + with: BuzzPushStateCodec.encode(state)) as! [String: Any] + object["version"] = 999 + let data = try JSONSerialization.data(withJSONObject: object) + XCTAssertThrowsError(try BuzzPushStateCodec.decode(data)) { error in + XCTAssertEqual(error as? BuzzPushStateStoreError, .corrupt) + } + } +}