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:
2026-07-23 14:27:40 +02:00
parent 5939489432
commit cbace73908
66 changed files with 5692 additions and 168 deletions

View File

@@ -0,0 +1,95 @@
import XCTest
@testable import VniDrop
final class RelayConfigurationTests: XCTestCase {
func testAutomaticModeIgnoresRelayDrafts() throws {
let result = try RelayConfigurationValidator.validate(
mode: .automatic,
relayURLs: ["not a URL"]
)
XCTAssertEqual(result, .automatic)
}
func testAutomaticModeRetainsPreviouslySavedRelayURLs() throws {
let result = try RelayConfigurationValidator.validate(
mode: .automatic,
relayURLs: ["not a URL"],
retainedRelayURLs: ["https://relay.example"]
)
XCTAssertEqual(result, RelayConfiguration(
mode: .automatic,
relayURLs: ["https://relay.example"]
))
}
func testCustomModeTrimsValidHTTPSRelayURLs() throws {
let result = try RelayConfigurationValidator.validate(
mode: .custom,
relayURLs: [" https://relay.example/ ", "https://backup.example:443"]
)
XCTAssertEqual(result, RelayConfiguration(
mode: .custom,
relayURLs: ["https://relay.example", "https://backup.example"]
))
}
func testCustomModeIgnoresEmptyURLRows() throws {
let result = try RelayConfigurationValidator.validate(
mode: .custom,
relayURLs: ["", " ", "https://relay.example"]
)
XCTAssertEqual(result.relayURLs, ["https://relay.example"])
}
func testCustomModeRequiresAtLeastOneRelay() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [])) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL)
}
}
func testCustomModeRequiresHTTPS() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
mode: .custom,
relayURLs: ["http://relay.example"]
)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0))
}
}
func testCustomModeRejectsCredentialsQueryFragmentAndPath() {
let invalidURLs = [
"https://user:password@relay.example",
"https://relay.example?token=secret",
"https://relay.example#fragment",
"https://relay.example/custom/path",
"https://relay.example:0",
"https://relay.example:99999",
]
for relayURL in invalidURLs {
XCTAssertThrowsError(
try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [relayURL]),
"Expected \(relayURL) to be rejected"
) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0))
}
}
}
func testCustomModeRejectsNormalizedDuplicate() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
mode: .custom,
relayURLs: ["https://relay.example", "https://RELAY.example:443/"]
)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1))
}
}
func testCustomModeRejectsMoreThanEightRelays() {
let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map {
"https://relay-\($0).example"
}
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: relayURLs)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs)
}
}
}