import Foundation import XCTest @preconcurrency import VnidropCore @testable import VniDrop /// Headless Apple harness for the saved-device core/platform contract (ticket 14). /// /// Exercises protected identity restart, event revision recovery, and binding /// hygiene against the generated UniFFI surface. The full two-node public-API /// lifecycle (eligibility → unblock) lives in /// `crates/vnidrop/src/tests/platform_contract_apple.rs` so it can run without /// the iOS simulator. final class SavedDeviceCoreContractTests: XCTestCase { private final class RecordingSink: CoreEventSink, @unchecked Sendable { private let lock = NSLock() private var events: [CoreEvent] = [] func onEvent(event: CoreEvent) { lock.lock() events.append(event) lock.unlock() } func snapshot() -> [CoreEvent] { lock.lock() defer { lock.unlock() } return events } } func testExperimentalKeychainIdentitySurvivesRestart() throws { let directory = try FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: FileManager.default.temporaryDirectory, create: true ).appendingPathComponent("vnidrop-apple-contract-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let sink = RecordingSink() let first = try VnidropCore.initializeWithExperimentalSavedDevices( appDataDir: directory.path, eventSink: sink, limits: defaultCoreLimits(), networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: []) ) let endpointId = first.status().endpointId XCTAssertFalse(endpointId.isEmpty) XCTAssertFalse( FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path), "protected identity must not fall back to plaintext" ) first.shutdown() let restarted = try VnidropCore.initializeWithExperimentalSavedDevices( appDataDir: directory.path, eventSink: RecordingSink(), limits: defaultCoreLimits(), networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: []) ) defer { restarted.shutdown() } XCTAssertEqual(restarted.status().endpointId, endpointId) } func testEventRevisionRecoveryUsesStableIdsThenListApis() throws { let directory = try FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: FileManager.default.temporaryDirectory, create: true ).appendingPathComponent("vnidrop-apple-events-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let sink = RecordingSink() let core = try VnidropCore.initializeWithExperimentalSavedDevices( appDataDir: directory.path, eventSink: sink, limits: defaultCoreLimits(), networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: []) ) defer { core.shutdown() } // Force at least one durable event through the public surface. XCTAssertThrowsError( try core.receive(ticket: "not-a-ticket", outputDir: directory.path, receiverName: nil) ) let listed = try core.listEvents(transferId: nil) XCTAssertFalse(listed.isEmpty) var seenIds = Set() var revisions = Set() for event in listed { XCTAssertTrue(seenIds.insert(event.id).inserted, "event ids must be stable/unique") XCTAssertGreaterThanOrEqual(event.revision, 1) XCTAssertTrue(revisions.insert(event.revision).inserted, "revisions must be distinct") } // Simulate duplicate delivery after a listener restart, then trust list APIs. let duplicates = listed var recoveredIds = Set() var maxRevision: UInt64 = 0 for event in listed + duplicates { if recoveredIds.insert(event.id).inserted { maxRevision = max(maxRevision, event.revision) } } XCTAssertEqual(recoveredIds.count, listed.count) XCTAssertGreaterThanOrEqual(maxRevision, 1) XCTAssertEqual(try core.listSavedDevices().count, 0) XCTAssertEqual(try core.listDeviceRelationships().count, 0) XCTAssertEqual(try core.listBlockedDevices().count, 0) } func testGeneratedBindingsOmitRawSecretsAndGenericMutation() throws { let candidates = [ Bundle(for: SavedDeviceCoreContractTests.self).bundleURL .deletingLastPathComponent() .appendingPathComponent("Vnidrop.swift"), URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .appendingPathComponent("VnidropCore/Sources/VnidropCore/Vnidrop.swift"), ] guard let bindingsURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) else { // Source package path may be absent in a clean CI checkout before // build-core; linking the typed APIs below still proves the // regenerated surface is what the harness compiles against. let _: ( (String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore ) = VnidropCore.initializeWithExperimentalSavedDevices XCTAssertNotNil(defaultCoreLimits().maxSavedDevices) return } let source = try String(contentsOf: bindingsURL, encoding: .utf8) let forbidden = [ "SecretMaterial", "SecretHandle", "SecureSecretStore", "executeSql", "executeSQL", "mutateState", "applyRawState", "rawSecret", "grantSecret", "pairingCapabilityBytes", "func setState(", "func mutate(", ] for needle in forbidden { XCTAssertFalse( source.contains(needle), "generated bindings must not expose \(needle)" ) } XCTAssertTrue(source.contains("initializeWithExperimentalSavedDevices")) XCTAssertTrue(source.contains("setSavedDeviceLabel")) XCTAssertTrue(source.contains("listSavedDevices")) XCTAssertTrue(source.contains("revision")) } }