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:
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")
+}