apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

RadrootsUserPresenceCancellationTests.swift (4826B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import RadrootsKit
      5 
      6 @Test func presenceCancellationBeforeContinuationInstallationRetainsCancellation() async {
      7     let state = RadrootsAppleUserPresenceAsyncCallbackState<Bool>(invalidate: {})
      8     state.resume(.failure(.userCancelled))
      9     await #expect(throws: RadrootsUserPresenceError.userCancelled) {
     10         try await withCheckedThrowingContinuation { continuation in
     11             state.install(continuation, timeoutNanoseconds: 1_000_000_000) { _ in
     12                 Issue.record("Cancelled presence must not start authentication")
     13             }
     14         }
     15     }
     16 }
     17 
     18 @Test(arguments: [Double.nan, Double.infinity, 0, -1, Double(UInt64.max) / 1e9])
     19 func presenceRejectsInvalidTimeoutBeforeStarting(timeout: Double) async {
     20     await #expect(throws: RadrootsUserPresenceError.invalidRequest) {
     21         let _: Bool = try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback(
     22             timeout: timeout, timeoutMessage: "unused"
     23         ) { _ in
     24             Issue.record("Invalid timeout must not start authentication")
     25         }
     26     }
     27 }
     28 
     29 #if canImport(LocalAuthentication)
     30     import LocalAuthentication
     31 
     32     @Test func presenceAlreadyCancelledTaskInvalidatesContextWithoutEvaluation() async throws {
     33         let context = ControlledPresenceContext()
     34         let task = Task {
     35             withUnsafeCurrentTask { $0?.cancel() }
     36             return try await RadrootsAppleUserPresenceAdapters.verify(
     37                 RadrootsUserPresenceRequest(reason: "Test cancellation"),
     38                 context: context, callbackTimeout: 1
     39             )
     40         }
     41         await #expect(throws: RadrootsUserPresenceError.userCancelled) { try await task.value }
     42         #expect(context.counts == [0, 1])
     43     }
     44 
     45     @Test func presenceCancellationDuringEvaluationInvalidatesAndIgnoresLateSuccess() async throws {
     46         let context = ControlledPresenceContext()
     47         let task = Task {
     48             try await RadrootsAppleUserPresenceAdapters.verify(
     49                 RadrootsUserPresenceRequest(reason: "Test cancellation"),
     50                 context: context, callbackTimeout: 1
     51             )
     52         }
     53         for await _ in context.started { break }
     54         task.cancel()
     55         await #expect(throws: RadrootsUserPresenceError.userCancelled) { try await task.value }
     56         context.complete(success: true)
     57         context.complete(success: false)
     58         #expect(context.counts == [1, 1])
     59     }
     60 
     61     @Test func presenceTimeoutInvalidatesActualContext() async throws {
     62         let context = ControlledPresenceContext()
     63         await #expect(throws: RadrootsUserPresenceError.timeout) {
     64             try await RadrootsAppleUserPresenceAdapters.verify(
     65                 RadrootsUserPresenceRequest(reason: "Test timeout"),
     66                 context: context, callbackTimeout: 0.001
     67             )
     68         }
     69         #expect(context.counts == [1, 1])
     70     }
     71 
     72     @Test func presenceSuccessAndDuplicateCallbackResolveOnce() async throws {
     73         let context = ControlledPresenceContext()
     74         let task = Task {
     75             try await RadrootsAppleUserPresenceAdapters.verify(
     76                 RadrootsUserPresenceRequest(reason: "Test success"),
     77                 context: context, callbackTimeout: 1
     78             )
     79         }
     80         for await _ in context.started { break }
     81         context.complete(success: true)
     82         context.complete(success: false)
     83         #expect(try await task.value.verified)
     84         #expect(context.counts == [1, 1])
     85     }
     86 
     87     // NSLock protects test observations and the callback across the operation queue.
     88     private final class ControlledPresenceContext: LAContext, @unchecked Sendable {
     89         private let lock = NSLock()
     90         private var evaluations = 0
     91         private var invalidations = 0
     92         private var reply: (@Sendable (Bool, (any Error)?) -> Void)?
     93         let started: AsyncStream<Void>
     94         private let signal: AsyncStream<Void>.Continuation
     95 
     96         override init() {
     97             (started, signal) = AsyncStream.makeStream()
     98             super.init()
     99         }
    100 
    101         override func evaluatePolicy(
    102             _ policy: LAPolicy, localizedReason: String,
    103             reply: @escaping @Sendable (Bool, (any Error)?) -> Void
    104         ) {
    105             lock.lock()
    106             evaluations += 1
    107             self.reply = reply
    108             lock.unlock()
    109             signal.yield(())
    110         }
    111 
    112         override func invalidate() {
    113             lock.lock()
    114             invalidations += 1
    115             lock.unlock()
    116         }
    117 
    118         func complete(success: Bool) {
    119             lock.lock()
    120             let callback = reply
    121             lock.unlock()
    122             callback?(success, nil)
    123         }
    124 
    125         var counts: [Int] {
    126             lock.lock()
    127             defer { lock.unlock() }
    128             return [evaluations, invalidations]
    129         }
    130     }
    131 #endif