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 }