apple_kit

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

commit c9fecf9079b7c0d62413fe6b9526643eb0a7701c
parent 4ac999d25ef7da1a28293e5940822a937b570eb2
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 13:17:30 -0700

user-presence: add contracts and fakes

- add reusable user-presence policy, status, request, result, and error models
- add a protocol boundary for Apple host verification services
- add a deterministic RadrootsKitTesting fake with request recording
- cover request validation, localized errors, and fake outcomes

Diffstat:
ASources/RadrootsKit/RadrootsUserPresence.swift | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsUserPresenceTesting.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsUserPresenceTestingTests.swift | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsUserPresenceTests.swift | 32++++++++++++++++++++++++++++++++
4 files changed, 264 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsUserPresence.swift b/Sources/RadrootsKit/RadrootsUserPresence.swift @@ -0,0 +1,109 @@ +import Foundation + +public enum RadrootsUserPresencePolicy: String, Sendable, Equatable, Hashable, CaseIterable { + case deviceOwnerAuthentication + case deviceOwnerAuthenticationWithBiometrics +} + +public enum RadrootsUserPresenceSupport: String, Sendable, Equatable, Hashable { + case none + case deviceCredential + case biometricsOrDeviceCredential +} + +public enum RadrootsBiometryKind: String, Sendable, Equatable, Hashable { + case none + case touchID + case faceID + case opticID + case unknown +} + +public struct RadrootsUserPresenceStatus: Sendable, Equatable, Hashable { + public let support: RadrootsUserPresenceSupport + public let biometryKind: RadrootsBiometryKind + public let canEvaluateDeviceCredential: Bool + public let canEvaluateBiometrics: Bool + + public init( + support: RadrootsUserPresenceSupport, + biometryKind: RadrootsBiometryKind, + canEvaluateDeviceCredential: Bool, + canEvaluateBiometrics: Bool + ) { + self.support = support + self.biometryKind = biometryKind + self.canEvaluateDeviceCredential = canEvaluateDeviceCredential + self.canEvaluateBiometrics = canEvaluateBiometrics + } + + public static let unavailable = Self( + support: .none, + biometryKind: .none, + canEvaluateDeviceCredential: false, + canEvaluateBiometrics: false + ) +} + +public struct RadrootsUserPresenceRequest: Sendable, Equatable, Hashable { + public let policy: RadrootsUserPresencePolicy + public let reason: String + + public init( + policy: RadrootsUserPresencePolicy = .deviceOwnerAuthentication, + reason: String + ) throws { + let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedReason.isEmpty else { + throw RadrootsUserPresenceError.invalidRequest("user presence reason cannot be empty") + } + self.policy = policy + self.reason = normalizedReason + } +} + +public struct RadrootsUserPresenceResult: Sendable, Equatable, Hashable { + public let policy: RadrootsUserPresencePolicy + public let verified: Bool + + public init(policy: RadrootsUserPresencePolicy, verified: Bool) { + self.policy = policy + self.verified = verified + } +} + +public enum RadrootsUserPresenceError: Error, Equatable, Sendable { + case invalidRequest(String) + case userCancelled(String) + case permissionDenied(String) + case unavailable(String) + case timeout(String) + case transientFailure(String) + case permanentFailure(String) +} + +extension RadrootsUserPresenceError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .userCancelled(let message): + message + case .permissionDenied(let message): + message + case .unavailable(let message): + message + case .timeout(let message): + message + case .transientFailure(let message): + message + case .permanentFailure(let message): + message + } + } +} + +public protocol RadrootsUserPresence: Sendable { + func currentStatus() async throws -> RadrootsUserPresenceStatus + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult +} diff --git a/Sources/RadrootsKitTesting/RadrootsUserPresenceTesting.swift b/Sources/RadrootsKitTesting/RadrootsUserPresenceTesting.swift @@ -0,0 +1,63 @@ +import Foundation +import RadrootsKit + +public actor RadrootsFakeUserPresence: RadrootsUserPresence { + private var statusValue: RadrootsUserPresenceStatus + private var verificationOutcome: Result<Bool, RadrootsUserPresenceError> + private var statusRequestCountValue: Int + private var verificationRequestsValue: [RadrootsUserPresenceRequest] + + public init( + status: RadrootsUserPresenceStatus = RadrootsUserPresenceStatus( + support: .biometricsOrDeviceCredential, + biometryKind: .faceID, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: true + ), + verificationOutcome: Result<Bool, RadrootsUserPresenceError> = .success(true) + ) { + self.statusValue = status + self.verificationOutcome = verificationOutcome + self.statusRequestCountValue = 0 + self.verificationRequestsValue = [] + } + + public func setStatus(_ status: RadrootsUserPresenceStatus) { + statusValue = status + } + + public func setVerificationOutcome(_ outcome: Result<Bool, RadrootsUserPresenceError>) { + verificationOutcome = outcome + } + + public func currentStatus() async throws -> RadrootsUserPresenceStatus { + statusRequestCountValue += 1 + return statusValue + } + + public func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + verificationRequestsValue.append(request) + switch verificationOutcome { + case .success(let verified): + return RadrootsUserPresenceResult(policy: request.policy, verified: verified) + case .failure(let error): + throw error + } + } + + public var statusRequestCount: Int { + statusRequestCountValue + } + + public var verificationRequestCount: Int { + verificationRequestsValue.count + } + + public var verificationRequests: [RadrootsUserPresenceRequest] { + verificationRequestsValue + } + + public var lastVerificationRequest: RadrootsUserPresenceRequest? { + verificationRequestsValue.last + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsUserPresenceTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsUserPresenceTestingTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +import RadrootsKit +import RadrootsKitTesting + +@Test func fakeUserPresenceRecordsStatusAndVerificationRequests() async throws { + let presence = RadrootsFakeUserPresence() + let status = try await presence.currentStatus() + let request = try RadrootsUserPresenceRequest( + policy: .deviceOwnerAuthentication, + reason: "Unlock local Nostr identity" + ) + let result = try await presence.verify(request) + + #expect(status.support == .biometricsOrDeviceCredential) + #expect(result.policy == .deviceOwnerAuthentication) + #expect(result.verified) + #expect(await presence.statusRequestCount == 1) + #expect(await presence.verificationRequestCount == 1) + #expect(await presence.lastVerificationRequest == request) + #expect(await presence.verificationRequests == [request]) +} + +@Test func fakeUserPresenceReturnsConfiguredFailures() async throws { + let presence = RadrootsFakeUserPresence( + verificationOutcome: .failure(.userCancelled("verification cancelled")) + ) + let request = try RadrootsUserPresenceRequest(reason: "Delete local Nostr identity") + + await #expect(throws: RadrootsUserPresenceError.userCancelled("verification cancelled")) { + try await presence.verify(request) + } + + #expect(await presence.verificationRequestCount == 1) + #expect(await presence.lastVerificationRequest == request) +} + +@Test func fakeUserPresenceCanUpdateStatusAndOutcome() async throws { + let presence = RadrootsFakeUserPresence(status: .unavailable, verificationOutcome: .success(false)) + let initialStatus = try await presence.currentStatus() + + await presence.setStatus( + RadrootsUserPresenceStatus( + support: .deviceCredential, + biometryKind: .none, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: false + ) + ) + await presence.setVerificationOutcome(.success(true)) + + let updatedStatus = try await presence.currentStatus() + let result = try await presence.verify( + RadrootsUserPresenceRequest(reason: "Import local Nostr identity") + ) + + #expect(initialStatus == .unavailable) + #expect(updatedStatus.support == .deviceCredential) + #expect(result.verified) +} diff --git a/Tests/RadrootsKitTests/RadrootsUserPresenceTests.swift b/Tests/RadrootsKitTests/RadrootsUserPresenceTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +import RadrootsKit + +@Test func userPresenceRequestNormalizesReason() throws { + let request = try RadrootsUserPresenceRequest( + policy: .deviceOwnerAuthenticationWithBiometrics, + reason: " Unlock local Nostr identity " + ) + + #expect(request.policy == .deviceOwnerAuthenticationWithBiometrics) + #expect(request.reason == "Unlock local Nostr identity") +} + +@Test func userPresenceRequestRejectsBlankReason() { + #expect(throws: RadrootsUserPresenceError.self) { + _ = try RadrootsUserPresenceRequest(reason: " \n ") + } +} + +@Test func userPresenceStatusCanRepresentUnavailableDevices() { + #expect(RadrootsUserPresenceStatus.unavailable.support == .none) + #expect(RadrootsUserPresenceStatus.unavailable.biometryKind == .none) + #expect(!RadrootsUserPresenceStatus.unavailable.canEvaluateDeviceCredential) + #expect(!RadrootsUserPresenceStatus.unavailable.canEvaluateBiometrics) +} + +@Test func userPresenceErrorsExposeLocalizedMessages() { + let error = RadrootsUserPresenceError.permissionDenied("presence denied") + + #expect(error.errorDescription == "presence denied") +}