feat(mobile/ios): NIP-PL installation lifecycle state machine + Keychain store

Implements the B2/B4 client-side lifecycle rules from the iOS cold review
as a pure decision machine plus a Keychain persistence seam, all in
BuzzPushKit and fully unit-tested without App Attest/network:

- BuzzPushInstallationState: durable installation record with a two-phase
  pendingRotation intent and local APNs-token fingerprinting (SHA-256 of
  the lowercase-hex token), so change detection never retains the token.
- BuzzPushLifecycle.onDeviceToken: rotate ONLY on a real token fingerprint
  change (iOS re-delivers the token every launch; unconditional rotate
  bumps the server epoch and kills every outstanding relay delegation);
  proactive re-enroll inside a 7-day renewal window before expires_at;
  surviving intents resume instead of desyncing.
- BuzzPushLifecycle.applyRotate: persist-then-confirm epoch commit;
  transient retains the intent; invalid_attestation (zombie key after
  backup-restore) clears and re-enrolls; ambiguous not_authorized after a
  crash window adopts the pending epoch and escalates once before giving
  up — deterministic reconvergence without reading server state.
- KeychainPushStateStore: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  (NSE must read while locked; ThisDeviceOnly excludes the record from
  backup-restore, killing the zombie-installation class at the storage
  layer) with optional access group for the extension; versioned JSON
  envelope that fails closed to 'corrupt' instead of crashing.

swift test: 32/32 green (10 transcript vectors + 22 lifecycle/store).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-15 18:35:57 -04:00
co-authored by Tyler Longwell
parent 88be670406
commit 0438c0ef63
4 changed files with 731 additions and 0 deletions
@@ -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()
}
}
@@ -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
}
}
@@ -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
@@ -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)
}
}
}