TeraComposerMediaOwnershipTests.swift (11593B)
1 import Foundation 2 import RadrootsKit 3 @testable import TeraApp 4 import XCTest 5 6 @MainActor 7 final class TeraComposerMediaOwnershipTests: XCTestCase { 8 func testLateFileConfirmationCannotAcknowledgeNewerEditing() async throws { 9 let storage = ComposerTestStorage() 10 let pause = ResourceTestPause() 11 var persistence = storage.port 12 persistence.confirm = { _ in await pause.wait() } 13 let autosave = TeraComposerAutosave(persistence: persistence, delay: {}) 14 autosave.reset(scope: TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default")) 15 var first = TeraComposerForm(commandType: .createUpdate) 16 first.content = "first" 17 let original = first 18 let saving = Task { try await autosave.save(original) } 19 await pause.entered.wait() 20 first.content = "newer editing" 21 autosave.change(first) 22 await pause.resume.open() 23 do { _ = try await saving.value; XCTFail("Old file proof must not acknowledge new editing") } catch {} 24 let latest = try await autosave.save(first) 25 XCTAssertEqual(latest.form.content, "newer editing") 26 XCTAssertEqual(autosave.state, .saved) 27 autosave.stop() 28 } 29 30 func testMissingCorruptSymlinkAndLockedFilesCannotCommitReferences() async throws { 31 for damage in ["missing", "corrupt", "symlink", "locked"] { 32 let fixture = try OfflineMediaFixture() 33 defer { fixture.remove() } 34 let client = TeraRuntimeClient.production() 35 let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 36 let coordinator = fixture.coordinator() 37 let request = try await request(client, coordinator: coordinator, snapshot: snapshot) 38 let item = try XCTUnwrap(request.form.media.first) 39 let path = fixture.roots.stagedBlobsRoot.appendingPathComponent(item.sha256) 40 let original = try Data(contentsOf: path) 41 try damageFile(path, bytes: original, damage: damage, root: fixture.runtime.root) 42 let persistence = TeraComposerPersistence(client: client).protectingMedia(coordinator) 43 do { 44 _ = try await persistence.save(request) 45 XCTFail("Unowned bytes must not be acknowledged: \(damage)") 46 } catch {} 47 let page = try await client.listComposers(scope: request.scope) 48 XCTAssertTrue(page.entries.isEmpty, damage) 49 if damage == "locked" { 50 try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) 51 } 52 _ = try await client.stop() 53 } 54 } 55 56 func testInterruptedBeforeDatabaseCommitRetainsIdentifiableOrphanAndRetries() async throws { 57 let fixture = try OfflineMediaFixture() 58 defer { fixture.remove() } 59 let client = TeraRuntimeClient.production() 60 let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 61 let coordinator = fixture.coordinator() 62 let request = try await request(client, coordinator: coordinator, snapshot: snapshot) 63 let path = try path(request, fixture: fixture) 64 let bytes = try Data(contentsOf: path) 65 // Inject the storage result at the file/DB seam. Producer tests separately 66 // exercise write/flush faults; this is not a physical disk exhaustion claim. 67 let failed = TeraComposerPersistence(reserve: { request.id }, save: { _ in throw POSIXError(.ENOSPC) }, 68 load: { try await client.loadComposer(scope: $0, id: $1) }).protectingMedia(coordinator) 69 do { _ = try await failed.save(request); XCTFail("Full storage must fail acknowledgment") } catch {} 70 XCTAssertEqual(try Data(contentsOf: path), bytes) 71 let absent = try await client.listComposers(scope: request.scope) 72 XCTAssertTrue(absent.entries.isEmpty) 73 _ = try await client.stop() 74 _ = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 75 let restarted = TeraComposerPersistence(client: client).protectingMedia(fixture.coordinator()) 76 let saved = try await restarted.save(request) 77 XCTAssertEqual(saved.draft.form, request.form) 78 XCTAssertEqual(try Data(contentsOf: path), bytes) 79 _ = try await client.stop() 80 } 81 82 func testLostDatabaseReplyReconcilesExactReferenceWithoutSecondWrite() async throws { 83 let fixture = try OfflineMediaFixture() 84 defer { fixture.remove() } 85 let client = TeraRuntimeClient.production() 86 let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 87 let coordinator = fixture.coordinator() 88 let request = try await request(client, coordinator: coordinator, snapshot: snapshot) 89 let unknown = TeraComposerPersistence(reserve: { request.id }, save: { value in 90 _ = try await client.saveComposer(request: value) 91 throw TeraComposerAcknowledgment.unconfirmed 92 }, load: { try await client.loadComposer(scope: $0, id: $1) }).protectingMedia(coordinator) 93 let autosave = TeraComposerAutosave(persistence: unknown) 94 autosave.reset(scope: request.scope) 95 do { _ = try await autosave.save(request.form); XCTFail("Lost reply is initially unknown") } catch {} 96 XCTAssertEqual(autosave.state, .failed) 97 XCTAssertNil(autosave.acknowledged) 98 let recovered = try await autosave.save(request.form) 99 XCTAssertEqual(recovered.revision, 1) 100 XCTAssertEqual(autosave.state, .saved) 101 let page = try await client.listComposers(scope: request.scope) 102 XCTAssertEqual(page.entries.count, 1) 103 autosave.stop() 104 _ = try await client.stop() 105 } 106 107 func testMissingAfterCommitBlocksAcknowledgmentAndRestartUntilBytesRecover() async throws { 108 let fixture = try OfflineMediaFixture() 109 defer { fixture.remove() } 110 let client = TeraRuntimeClient.production() 111 let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 112 let coordinator = fixture.coordinator() 113 let request = try await request(client, coordinator: coordinator, snapshot: snapshot) 114 let path = try path(request, fixture: fixture) 115 let bytes = try Data(contentsOf: path) 116 let interrupted = TeraComposerPersistence(reserve: { request.id }, save: { value in 117 let receipt = try await client.saveComposer(request: value) 118 try FileManager.default.removeItem(at: path) 119 return receipt 120 }, load: { try await client.loadComposer(scope: $0, id: $1) }).protectingMedia(coordinator) 121 do { _ = try await interrupted.save(request); XCTFail("Missing post-commit bytes cannot report saved") } catch {} 122 let raw = try await client.loadComposer(scope: request.scope, id: request.id) 123 XCTAssertEqual(raw.form, request.form) 124 _ = try await client.stop() 125 let restarted = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 126 let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator()) 127 store.configure(snapshot: restarted) 128 await store.start() 129 let refused = await store.reopenSaved(.composer(request.id)) 130 XCTAssertFalse(refused) 131 XCTAssertNil(store.savedComposer) 132 try bytes.write(to: path) 133 let reopened = await store.reopenSaved(.composer(request.id)) 134 XCTAssertTrue(reopened) 135 XCTAssertEqual(store.savedComposer, raw) 136 try FileManager.default.removeItem(at: path) 137 await store.save() 138 XCTAssertEqual(store.composerState, .failed) 139 XCTAssertNotEqual(store.message, "Draft saved on this device.") 140 store.stop() 141 _ = try await client.stop() 142 } 143 144 func testSharedHashSurvivesFailedOtherOwnerAndCachePurge() async throws { 145 let fixture = try OfflineMediaFixture() 146 defer { fixture.remove() } 147 let client = TeraRuntimeClient.production() 148 let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 149 let coordinator = fixture.coordinator() 150 let first = try await request(client, coordinator: coordinator, snapshot: snapshot) 151 let persistence = TeraComposerPersistence(client: client).protectingMedia(coordinator) 152 let saved = try await persistence.save(first) 153 let second = try await TeraComposerSaveRequest(scope: first.scope, id: client.reserveComposerID(), 154 expectedRevision: 1, editSequence: 1, form: first.form) 155 do { _ = try await persistence.save(second); XCTFail("Missing owner revision must fail") } catch {} 156 try FileManager.default.removeItem(at: fixture.roots.cacheRoot) 157 if FileManager.default.fileExists(atPath: fixture.roots.temporaryRoot.path) { 158 try FileManager.default.removeItem(at: fixture.roots.temporaryRoot) 159 } 160 _ = try await client.stop() 161 _ = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 162 let recovered = try await TeraComposerPersistence(client: client).protectingMedia(fixture.coordinator()) 163 .load(first.scope, first.id) 164 XCTAssertEqual(recovered, saved.draft) 165 _ = try await client.stop() 166 } 167 168 func testEvictableRootsAndAbsentMediaOwnerFailClosed() async throws { 169 let fixture = try OfflineMediaFixture() 170 defer { fixture.remove() } 171 let client = TeraRuntimeClient.production() 172 let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) 173 let request = try await request(client, coordinator: fixture.coordinator(), snapshot: snapshot) 174 let legacy = try RadrootsAppleFileRoots(appIdentifier: fixture.roots.appIdentifier, 175 dataRoot: fixture.roots.dataRoot, cacheRoot: fixture.roots.cacheRoot, 176 temporaryRoot: fixture.roots.temporaryRoot) 177 XCTAssertThrowsError(try TeraComposerMediaOwnership.confirm(request.form.media, roots: legacy)) 178 do { 179 _ = try await TeraComposerPersistence(client: client).protectingMedia(nil).save(request) 180 XCTFail("Media needs an explicit durable file owner") 181 } catch {} 182 let page = try await client.listComposers(scope: request.scope) 183 XCTAssertTrue(page.entries.isEmpty) 184 _ = try await client.stop() 185 } 186 187 private func request(_ client: TeraRuntimeClient, coordinator: TeraAddMediaCoordinator, 188 snapshot: TeraRuntimeSnapshot) async throws -> TeraComposerSaveRequest 189 { 190 var editing = TeraAddPresentation.newForm(type: .createPhotoUpdate, identifier: { "draft" }, clock: .system) 191 editing.media = try await coordinator.importImages(limit: 1) 192 let configuration = TeraPresentationConfiguration(snapshot: snapshot) 193 return try await TeraComposerSaveRequest( 194 scope: TeraComposerScope(authorPublicKey: configuration.publicKey, localNetworkID: configuration.context.id), 195 id: client.reserveComposerID(), expectedRevision: nil, editSequence: 1, form: TeraComposerForm(editing: editing) 196 ) 197 } 198 199 private func path(_ request: TeraComposerSaveRequest, fixture: OfflineMediaFixture) throws -> URL { 200 try fixture.roots.stagedBlobsRoot.appendingPathComponent(XCTUnwrap(request.form.media.first).sha256) 201 } 202 203 private func damageFile(_ path: URL, bytes: Data, damage: String, root: URL) throws { 204 switch damage { 205 case "missing": try FileManager.default.removeItem(at: path) 206 case "corrupt": try Data(repeating: 0, count: bytes.count).write(to: path) 207 case "locked": try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: path.path) 208 default: 209 let outside = root.appendingPathComponent("outside") 210 try bytes.write(to: outside) 211 try FileManager.default.removeItem(at: path) 212 try FileManager.default.createSymbolicLink(at: path, withDestinationURL: outside) 213 } 214 } 215 }