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