mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(network): support custom relay servers
Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
This commit is contained in:
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@preconcurrency import VnidropCore
|
||||
@testable import VniDrop
|
||||
|
||||
private enum BlockingCoreFactoryError: Error {
|
||||
case stopped
|
||||
}
|
||||
|
||||
private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked Sendable {
|
||||
private let release = DispatchSemaphore(value: 0)
|
||||
private let lock = NSLock()
|
||||
private var initializeCallCount = 0
|
||||
private var initializationStarted = false
|
||||
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
var callCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return initializeCallCount
|
||||
}
|
||||
|
||||
func initialize(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore {
|
||||
lock.lock()
|
||||
initializeCallCount += 1
|
||||
let call = initializeCallCount
|
||||
initializationStarted = true
|
||||
let waiters = startWaiters
|
||||
startWaiters.removeAll()
|
||||
lock.unlock()
|
||||
waiters.forEach { $0.resume() }
|
||||
|
||||
if call == 1 {
|
||||
release.wait()
|
||||
}
|
||||
throw BlockingCoreFactoryError.stopped
|
||||
}
|
||||
|
||||
func waitUntilInitializationStarts() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
lock.lock()
|
||||
if initializationStarted {
|
||||
lock.unlock()
|
||||
continuation.resume()
|
||||
} else {
|
||||
startWaiters.append(continuation)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unblockInitialization() {
|
||||
release.signal()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CoreRepositoryLifecycleTests: XCTestCase {
|
||||
func testIdleRequirementRejectsTransfersAndShares() throws {
|
||||
XCTAssertNoThrow(try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 0))
|
||||
XCTAssertThrowsError(
|
||||
try CoreNetworkLifecycle.requireIdle(activeTransfers: 1, activeShares: 0)
|
||||
) { error in
|
||||
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
|
||||
}
|
||||
XCTAssertThrowsError(
|
||||
try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 1)
|
||||
) { error in
|
||||
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
|
||||
}
|
||||
}
|
||||
|
||||
func testRestartSerializesInitializationAndRejectsNewNetworkWork() async {
|
||||
let factory = BlockingCoreBindingFactory()
|
||||
let repository = CoreRepository(coreFactory: factory)
|
||||
let firstInitialization = Task {
|
||||
await repository.initialize(appDataDir: "/tmp/first", networkConfiguration: .automatic)
|
||||
}
|
||||
await factory.waitUntilInitializationStarts()
|
||||
|
||||
let safetyRelease = Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
factory.unblockInitialization()
|
||||
}
|
||||
defer {
|
||||
safetyRelease.cancel()
|
||||
factory.unblockInitialization()
|
||||
}
|
||||
|
||||
let concurrentInitialization = await repository.initialize(
|
||||
appDataDir: "/tmp/second",
|
||||
networkConfiguration: RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"])
|
||||
)
|
||||
assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress)
|
||||
|
||||
let share = await repository.shareSources(
|
||||
[],
|
||||
transferName: "Blocked",
|
||||
senderName: "Tester",
|
||||
accessPolicy: .requireApproval
|
||||
)
|
||||
assertLifecycleFailure(share, equals: .transitionInProgress)
|
||||
|
||||
let receive = await repository.receive(ticket: "ticket", outputDir: "/tmp", receiverName: "Tester")
|
||||
assertLifecycleFailure(receive, equals: .transitionInProgress)
|
||||
XCTAssertEqual(factory.callCount, 1)
|
||||
|
||||
factory.unblockInitialization()
|
||||
guard case .failure(let error) = await firstInitialization.value else {
|
||||
return XCTFail("The blocking factory should fail the first initialization")
|
||||
}
|
||||
XCTAssertTrue(error is BlockingCoreFactoryError)
|
||||
}
|
||||
|
||||
private func assertLifecycleFailure<T>(
|
||||
_ result: Result<T, Error>,
|
||||
equals expected: CoreNetworkLifecycleError,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
guard case .failure(let error) = result else {
|
||||
return XCTFail("Expected lifecycle failure \(expected)", file: file, line: line)
|
||||
}
|
||||
XCTAssertEqual(error as? CoreNetworkLifecycleError, expected, file: file, line: line)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user