field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraKeyRemovalTests.swift (9011B)


      1 import Foundation
      2 import RadrootsKit
      3 @testable import TeraApp
      4 import XCTest
      5 
      6 final class TeraKeyRemovalTests: XCTestCase {
      7   func testSuccessfulRemovalRetainsLocalDataAndPreparedRequests() async throws {
      8     let fixture = try await RemovalFixture()
      9     defer { fixture.state.remove() }
     10     let result = await fixture.remove([.init(id: "first", revision: 1)])
     11     XCTAssertEqual(result, .identityRequired)
     12     let identity = await fixture.identity.snapshot()
     13     let prepared = await fixture.backend.preparedRemovals
     14     let closes = await fixture.backend.shutdownCount
     15     XCTAssertEqual(identity.state, .absent)
     16     XCTAssertEqual(prepared, ["first"])
     17     XCTAssertEqual(closes, 1)
     18     XCTAssertEqual(try Data(contentsOf: fixture.retained), Data("retained local data".utf8))
     19     let restarted = await fixture.session.start()
     20     XCTAssertEqual(restarted, .identityRequired, "No silent identity regeneration")
     21   }
     22 
     23   func testPreparationFailurePreservesKeyAndEarlierSignedRequests() async throws {
     24     let fixture = try await RemovalFixture()
     25     defer { fixture.state.remove() }
     26     await fixture.backend.configureRemoval(failureID: "second")
     27     _ = await fixture.remove([.init(id: "first", revision: 1), .init(id: "second", revision: 1)])
     28     let prepared = await fixture.backend.preparedRemovals
     29     let closes = await fixture.backend.shutdownCount
     30     XCTAssertEqual(prepared, ["first"])
     31     XCTAssertEqual(closes, 0)
     32     await assertRetained(fixture)
     33   }
     34 
     35   func testCancellationBeforeAndDuringPreparationPreservesKey() async throws {
     36     for during in [false, true] {
     37       let fixture = try await RemovalFixture()
     38       defer { fixture.state.remove() }
     39       let pause = ResourceTestPause()
     40       if during {
     41         await fixture.backend.configureRemoval(pause: pause)
     42       }
     43       let task = Task {
     44         if !during {
     45           await pause.wait()
     46         }
     47         return await fixture.remove([.init(id: "first", revision: 1)])
     48       }
     49       await pause.entered.wait()
     50       task.cancel()
     51       await pause.resume.open()
     52       _ = await task.value
     53       await assertRetained(fixture)
     54     }
     55   }
     56 
     57   func testCancellationAfterPreparationAndShutdownFailurePreserveKey() async throws {
     58     for failClose in [false, true] {
     59       let fixture = try await RemovalFixture()
     60       defer { fixture.state.remove() }
     61       let pause = ResourceTestPause()
     62       await fixture.backend.pauseShutdown(pause)
     63       if failClose {
     64         await fixture.backend.failShutdownOnce(.local(operation: "test.close", code: "test.close", safeMessage: "Close failed"))
     65       }
     66       let task = Task { await fixture.remove([.init(id: "first", revision: 1)]) }
     67       await pause.entered.wait()
     68       if !failClose {
     69         task.cancel()
     70       }
     71       await pause.resume.open()
     72       _ = await task.value
     73       let prepared = await fixture.backend.preparedRemovals
     74       XCTAssertEqual(prepared, ["first"])
     75       await assertRetained(fixture)
     76     }
     77   }
     78 
     79   func testCancellationAtPresenceAndStaleAuthorCannotDeleteKey() async throws {
     80     let fixture = try await RemovalFixture()
     81     defer { fixture.state.remove() }
     82     _ = await fixture.session.removeSigningKey(author: String(repeating: "ab", count: 32), requests: [])
     83     await assertRetained(fixture)
     84     let pause = ResourceTestPause()
     85     await fixture.presence.arm(pause)
     86     let task = Task { await fixture.remove([]) }
     87     await pause.entered.wait()
     88     // A concurrent start cannot replace/reconfigure the identity during removal.
     89     _ = await fixture.session.start()
     90     task.cancel()
     91     await pause.resume.open()
     92     _ = await task.value
     93     await assertRetained(fixture)
     94   }
     95 
     96   func testLateCancellationDoesNotHideCompletedDeletion() async throws {
     97     let fixture = try await RemovalFixture()
     98     defer { fixture.state.remove() }
     99     fixture.secure.cancelOnRemoval()
    100     let task = Task { await fixture.remove([]) }
    101     let result = await task.value
    102     XCTAssertEqual(result, .identityRequired)
    103     let observed = await fixture.identity.snapshot()
    104     XCTAssertEqual(observed.state, .absent)
    105     XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.retained.path))
    106   }
    107 
    108   func testDeniedRemovalPreservesIdentityAndRetainedData() async throws {
    109     let fixture = try await RemovalFixture()
    110     defer { fixture.state.remove() }
    111     await fixture.presence.deny()
    112     _ = await fixture.remove([])
    113     await assertRetained(fixture)
    114   }
    115 
    116   @MainActor
    117   func testAppModelReportsCompletedRemovalAfterLateCancellation() async throws {
    118     let fixture = try await RemovalFixture()
    119     defer { fixture.state.remove() }
    120     let model = TeraAppModel(sessionStore: fixture.session, lifecycleCoordinator: .disabled())
    121     await model.start()
    122     fixture.secure.cancelOnRemoval()
    123     let task = Task { await model.removeSigningKey(author: fixture.author, requests: []) }
    124     await task.value
    125     XCTAssertEqual(model.phase, .identityRequired)
    126   }
    127 
    128   func testConsentSeparatesRetainedDataRemoteCopiesAndRecoveryRequirement() {
    129     XCTAssertTrue(TeraKeyRemovalConsent.local.contains("does not erase local data"))
    130     XCTAssertTrue(TeraKeyRemovalConsent.remote.contains("Published copies may remain"))
    131     XCTAssertTrue(TeraKeyRemovalConsent.recovery.contains("import the same key"))
    132     XCTAssertTrue(TeraKeyRemovalConsent.recovery.contains("delivery is not guaranteed"))
    133   }
    134 
    135   private func assertRetained(_ fixture: RemovalFixture) async {
    136     let identity = await fixture.identity.snapshot()
    137     XCTAssertNotEqual(identity.state, .absent)
    138     XCTAssertEqual(identity.publicKeyHex, fixture.author)
    139     XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.retained.path))
    140   }
    141 }
    142 
    143 private struct RemovalFixture: Sendable {
    144   let state: StateFixture
    145   let secure: RemovalSecureStore
    146   let presence: RemovalPresence
    147   let identity: TeraIdentityStore
    148   let backend: ResourceTestBackend
    149   let session: TeraSessionStore
    150   let author: String
    151   let retained: URL
    152 
    153   init() async throws {
    154     let state = try StateFixture()
    155     self.state = state
    156     retained = state.root.appendingPathComponent("retained-data")
    157     try Data("retained local data".utf8).write(to: retained)
    158     let secure = RemovalSecureStore()
    159     self.secure = secure
    160     let presence = RemovalPresence()
    161     self.presence = presence
    162     let custody = try RadrootsIdentityCustody(
    163       configuration: RadrootsIdentityCustodyConfiguration(namespace: "radroots_identity_v1", secretPolicy: .secureLocalSecret),
    164       secureStore: secure, metadataStore: InMemoryIdentityMetadataStore(), userPresence: presence
    165     )
    166     let identity = TeraIdentityStore(custody: custody, secureStore: secure, servicePrefix: UUID().uuidString)
    167     self.identity = identity
    168     let created = try await identity.create()
    169     let author = try XCTUnwrap(created.publicKeyHex)
    170     self.author = author
    171     let backend = ResourceTestBackend(publicKeyHex: author)
    172     self.backend = backend
    173     let client = TeraRuntimeClient(factory: { _ in await backend.start() })
    174     session = TeraSessionStore(configurationStore: TeraConfigurationStore(bootstrap: state.bootstrap, roots: state.roots),
    175                                identityStore: identity, runtimeClient: client, roots: state.roots,
    176                                protectedData: TeraProtectedDataMonitor(available: true))
    177     guard case .running = await session.start() else { throw TeraIdentityStoreError.unavailable }
    178   }
    179 
    180   func remove(_ requests: [TeraKeyRemovalRequest]) async -> TeraSessionPhase {
    181     await session.removeSigningKey(author: author, requests: requests)
    182   }
    183 }
    184 
    185 private actor RemovalPresence: RadrootsUserPresence {
    186   private var pause: ResourceTestPause?
    187   private var allowed = true
    188   func deny() {
    189     allowed = false
    190   }
    191 
    192   func arm(_ pause: ResourceTestPause) {
    193     self.pause = pause
    194   }
    195 
    196   func currentStatus() async throws -> RadrootsUserPresenceStatus {
    197     .unavailable
    198   }
    199 
    200   func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
    201     let pending = pause
    202     pause = nil
    203     await pending?.wait()
    204     return RadrootsUserPresenceResult(policy: request.policy, verified: allowed)
    205   }
    206 }
    207 
    208 private final class RemovalSecureStore: RadrootsSecureStore, @unchecked Sendable {
    209   private let storage = InMemorySecureStore()
    210   private let lock = NSLock()
    211   private var cancels = false
    212   func cancelOnRemoval() {
    213     lock.withLock { cancels = true }
    214   }
    215 
    216   func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws {
    217     try storage.put(value, for: key, policy: policy)
    218   }
    219 
    220   func get(_ key: RadrootsSecureStoreKey) throws -> Data? {
    221     try storage.get(key)
    222   }
    223 
    224   func contains(_ key: RadrootsSecureStoreKey) throws -> Bool {
    225     try storage.contains(key)
    226   }
    227 
    228   func delete(_ key: RadrootsSecureStoreKey) throws {
    229     try storage.delete(key)
    230     if key.name == "active_secret_v1", lock.withLock({ cancels }) {
    231       withUnsafeCurrentTask { $0?.cancel() }
    232     }
    233   }
    234 
    235   func deleteNamespace(_ namespace: String) throws {
    236     try storage.deleteNamespace(namespace)
    237   }
    238 }