field_ios

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

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 }