Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 291 additions & 16 deletions iosApp/Tests/PairingCoordinatorTests.swift

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions iosApp/Tests/PairingProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ final class PairingProtocolTests: XCTestCase {
.hello(tvName: "Living Room", tvDeviceId: "ABC-123", state: .setup, supportedVersions: [1]),
.pushServer(serverURL: "https://media.example.com", serverName: "Home"),
.pushServer(serverURL: "https://media.example.com", serverName: nil),
.pushServer(
serverURL: "https://media.example.com", serverName: "Home",
serverIdentity: "96c1bd08-b839-4d47-980e-57d4e7a44cfa",
endpoints: [
ServerEndpoint(url: "https://media.example.com", kind: .public),
ServerEndpoint(url: "https://media.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale"),
]
),
.serverResult(serverURL: "https://media.example.com", status: .failed, error: PairingFailureCode.unreachable.rawValue),
.deviceStarted(serverURL: "https://media.example.com", userCode: "WXYZ-12", matchCode: "brave-otter"),
.serverResult(serverURL: "https://media.example.com", status: .signedIn, error: nil),
.serverResult(serverURL: "https://media.example.com", status: .failed, error: "timeout"),
Expand All @@ -34,6 +43,42 @@ final class PairingProtocolTests: XCTestCase {
XCTAssertTrue(json["v"] as? Int == PairingProtocol.version, "missing/incorrect version")
}

/// Protocol stays v1: the identity fields are additive and optional, so a
/// legacy push decodes and a legacy peer sees no new required key.
func testPushServerIdentityFieldsAreOptionalOnTheWire() throws {
let legacy = Data(#"{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home"}"#.utf8)
guard case let .pushServer(url, name, identity, endpoints) = try decoder.decode(PairingMessage.self, from: legacy) else {
return XCTFail("expected pushServer")
}
XCTAssertEqual(url, "https://media.example.com")
XCTAssertEqual(name, "Home")
XCTAssertNil(identity)
XCTAssertNil(endpoints)

let data = try encoder.encode(PairingMessage.pushServer(serverURL: "https://media.example.com", serverName: nil))
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertNil(json["serverIdentity"])
XCTAssertNil(json["endpoints"])
XCTAssertEqual(json["v"] as? Int, 1)

let full = try encoder.encode(PairingMessage.pushServer(
serverURL: "https://media.example.com", serverName: "Home", serverIdentity: "S",
endpoints: [ServerEndpoint(url: "https://media.overlay.example/", kind: .provider, provider: "tailscale", displayName: "Tailscale")]
))
let fullJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: full) as? [String: Any])
let endpoint = try XCTUnwrap((fullJSON["endpoints"] as? [[String: Any]])?.first)
XCTAssertEqual(endpoint["url"] as? String, "https://media.overlay.example")
XCTAssertEqual(endpoint["kind"] as? String, "provider")
XCTAssertEqual(endpoint["displayName"] as? String, "Tailscale")
}

func testFailureCodesReadUnknownAsGenericFailure() {
XCTAssertEqual(PairingFailureCode(wire: "unreachable"), .unreachable)
XCTAssertEqual(PairingFailureCode(wire: "identity_mismatch"), .identityMismatch)
XCTAssertEqual(PairingFailureCode(wire: nil), .authFailed)
XCTAssertEqual(PairingFailureCode(wire: "something_new"), .authFailed)
}

func testUnknownTypeFailsToDecode() {
let data = #"{"type":"bogus","v":1}"#.data(using: .utf8)!
var threw = false
Expand Down
180 changes: 180 additions & 0 deletions iosApp/Tests/RemotePlaybackRoutingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#if os(iOS)
import XCTest
@testable import Silo

/// Every streaming play on iOS funnels through `AppRouter.presentPlayer`,
/// where one interceptor decides between the engaged TV and the local
/// player. These guard the funnel: an engaged TV takes the request and the
/// local cover never appears; with no TV the cover appears as before; a
/// second Play during the decision cannot slip past it; offline plays
/// prompt instead of silently starting a second player.
@MainActor
final class RemotePlaybackRoutingTests: XCTestCase {
private func expectEventually(_ label: String, timeout: TimeInterval = 3, _ condition: () -> Bool) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() { return }
try? await Task.sleep(for: .milliseconds(10))
}
XCTFail("timed out waiting for: \(label)")
}

func testEngagedTVTakesTheRequestAndNoLocalPlayerAppears() async {
let router = AppRouter()
var received: [SiloControlPlaybackRequest] = []
router.remotePlaybackInterceptor = { request in
received.append(request)
return true
}

router.presentPlayer(contentId: "c1", fileId: 7, startFromBeginning: false, resumePosition: 120)

await expectEventually("interceptor called") { received.count == 1 }
try? await Task.sleep(for: .milliseconds(50))
XCTAssertNil(router.presentedPlayer)
XCTAssertEqual(received.first?.contentId, "c1")
XCTAssertEqual(received.first?.fileId, 7)
XCTAssertEqual(received.first?.resumePosition, 120)
}

func testNoEngagedTVOpensTheLocalPlayer() async {
let router = AppRouter()
router.remotePlaybackInterceptor = { _ in false }

router.presentPlayer(contentId: "c1")

await expectEventually("local player presented") { router.presentedPlayer?.contentId == "c1" }
}

func testWithoutAnInterceptorTheLocalPlayerOpensSynchronously() {
let router = AppRouter()
router.presentPlayer(contentId: "c1")
XCTAssertEqual(router.presentedPlayer?.contentId, "c1")
}

func testSecondPlayDuringTheDecisionIsDropped() async {
let router = AppRouter()
var calls = 0
let gate = AsyncGate()
router.remotePlaybackInterceptor = { _ in
calls += 1
await gate.wait()
return true
}

router.presentPlayer(contentId: "first")
router.presentPlayer(contentId: "second")
await expectEventually("first decision started") { calls == 1 }
gate.open()
try? await Task.sleep(for: .milliseconds(50))

XCTAssertEqual(calls, 1, "the second tap must not reach the interceptor or the local player")
XCTAssertNil(router.presentedPlayer)
}

func testOfflinePlayPromptsWhileATVIsEngagedAndHonoursTheChoice() async {
let router = AppRouter()
var sentToTV: [SiloControlPlaybackRequest] = []
router.isRemotePlaybackEngaged = { true }
router.remotePlaybackInterceptor = { request in
sentToTV.append(request)
return true
}

router.presentOfflinePlayer(downloadId: "d1", contentId: "c1", resumePosition: 30)
XCTAssertNil(router.presentedPlayer)
XCTAssertEqual(router.pendingOfflinePlayChoice?.presentation.offlineDownloadId, "d1")

router.confirmOfflinePlayHere()
XCTAssertEqual(router.presentedPlayer?.offlineDownloadId, "d1")
XCTAssertNil(router.pendingOfflinePlayChoice)

router.presentedPlayer = nil
router.presentOfflinePlayer(downloadId: "d1", contentId: "c1", resumePosition: 30)
router.sendPendingOfflinePlayToTV()
await expectEventually("streamed to TV") { sentToTV.count == 1 }
XCTAssertEqual(sentToTV.first?.contentId, "c1")
XCTAssertEqual(sentToTV.first?.resumePosition, 30)
XCTAssertNil(router.presentedPlayer)
}

func testOfflinePlayWithoutATVOpensLocallyWithoutPrompting() {
let router = AppRouter()
router.isRemotePlaybackEngaged = { false }
router.presentOfflinePlayer(downloadId: "d1", contentId: "c1")
XCTAssertNil(router.pendingOfflinePlayChoice)
XCTAssertEqual(router.presentedPlayer?.offlineDownloadId, "d1")
}

func testPlayingADifferentTitleAsksBeforeReplacingWhatTheTVIsPlaying() async {
let router = AppRouter()
var sent: [String] = []
router.remotePlaybackInterceptor = { request in sent.append(request.contentId); return true }
router.remotePlaybackCurrentTitle = { (title: "Oak Street", contentId: "oak", targetName: "Living Room") }

router.presentPlayer(contentId: "miasma")
XCTAssertEqual(router.pendingReplaceRemotePlayback?.currentTitle, "Oak Street")
XCTAssertEqual(router.pendingReplaceRemotePlayback?.targetName, "Living Room")
XCTAssertNil(router.presentedPlayer, "the local player must never open behind the prompt")
XCTAssertTrue(sent.isEmpty)

router.confirmReplaceRemotePlayback()
await expectEventually("sent after confirmation") { sent == ["miasma"] }
XCTAssertNil(router.pendingReplaceRemotePlayback)
}

func testResumingTheSameTitleDoesNotAsk() async {
let router = AppRouter()
var sent: [String] = []
router.remotePlaybackInterceptor = { request in sent.append(request.contentId); return true }
router.remotePlaybackCurrentTitle = { (title: "Oak Street", contentId: "oak", targetName: "Living Room") }

router.presentPlayer(contentId: "oak", resumePosition: 90)
await expectEventually("sent without a prompt") { sent == ["oak"] }
XCTAssertNil(router.pendingReplaceRemotePlayback)
}

func testIdleTVDoesNotAsk() async {
let router = AppRouter()
var sent: [String] = []
router.remotePlaybackInterceptor = { request in sent.append(request.contentId); return true }
router.remotePlaybackCurrentTitle = { nil }

router.presentPlayer(contentId: "miasma")
await expectEventually("sent without a prompt") { sent == ["miasma"] }
XCTAssertNil(router.pendingReplaceRemotePlayback)
}

// MARK: - Engaged predicate

/// The mode button, mini-bar, and routing all read one predicate. A
/// fresh client has nothing engaged, and the predicate is what the
/// legacy per-site `hasActiveSession` checks were replaced with.
func testFreshClientIsNotEngagedAndLaunchOnEngagedTVDeclines() async {
let client = SiloControlClient()
XCTAssertFalse(client.remotePlaybackEngaged)
let taken = await client.launchOnEngagedTV(SiloControlPlaybackRequest(
contentId: "c1", fileId: nil, audioTrackIndex: nil, subtitleTrackIndex: nil,
startFromBeginning: true, resumePosition: nil
))
XCTAssertFalse(taken, "no TV engaged ⇒ the caller may play locally")
XCTAssertFalse(client.isShowingRemoteControl)
}
}

@MainActor
private final class AsyncGate {
private var continuations: [CheckedContinuation<Void, Never>] = []
private var isOpen = false
func wait() async {
if isOpen { return }
await withCheckedContinuation { continuations.append($0) }
}
func open() {
isOpen = true
let waiting = continuations
continuations.removeAll()
waiting.forEach { $0.resume() }
}
}
#endif
125 changes: 125 additions & 0 deletions iosApp/Tests/ServerIdentityMatchingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import XCTest
@testable import Silo

/// Recognising one deployment across addresses (issue #341). Registry keys
/// stay URL-derived; the verified deployment identity only adds matches.
final class ServerIdentityMatchingTests: XCTestCase {
private let identity = "96c1bd08-b839-4d47-980e-57d4e7a44cfa"

func testDifferentHostnamesMatchWhenVerifiedIdentitiesAgree() {
let phone = ServerRegistry.serverId(for: "https://silo-dev-1.bonobo-kitefin.ts.net")
let tv = ServerRegistry.serverId(for: "https://silo-dev.arkyncdn.net")

XCTAssertFalse(ServerRegistry.serverIdsMatch(phone, tv), "URL rule alone cannot relate them")
XCTAssertTrue(ServerRegistry.serversMatch(
serverId: phone, verifiedServerId: identity,
serverId: tv, verifiedServerId: identity
))
}

func testMissingOrDifferentIdentityFallsBackToURLRule() {
let phone = ServerRegistry.serverId(for: "https://a.example")
let tv = ServerRegistry.serverId(for: "https://b.example")

XCTAssertFalse(ServerRegistry.serversMatch(
serverId: phone, verifiedServerId: identity, serverId: tv, verifiedServerId: nil
))
XCTAssertFalse(ServerRegistry.serversMatch(
serverId: phone, verifiedServerId: "", serverId: tv, verifiedServerId: ""
))
XCTAssertFalse(ServerRegistry.serversMatch(
serverId: phone, verifiedServerId: identity, serverId: tv, verifiedServerId: "other"
))
// Same origin still matches without any identity, as before.
XCTAssertTrue(ServerRegistry.serversMatch(
serverId: ServerRegistry.serverId(for: "https://A.example"), verifiedServerId: nil,
serverId: phone, verifiedServerId: nil
))
}

func testEntryPersistsVerifiedIdentityAndDecodesWithoutIt() throws {
let entry = ServerEntry(
id: "id", url: "https://a.example", fetchedName: "Home",
lastUsedAt: Date(timeIntervalSince1970: 1), verifiedServerId: identity
)
let data = try JSONEncoder().encode(entry)
XCTAssertEqual(try JSONDecoder().decode(ServerEntry.self, from: data).verifiedServerId, identity)

let legacy = Data(#"{"id":"id","url":"https://a.example","lastUsedAt":1}"#.utf8)
let decoded = try JSONDecoder().decode(ServerEntry.self, from: legacy)
XCTAssertNil(decoded.verifiedServerId)
XCTAssertEqual(decoded.url, "https://a.example")
}

func testRegistryKeepsIdentityWhenAnUpdateOmitsIt() throws {
let name = "ServerIdentityMatchingTests.\(UUID().uuidString)"
let suite = try XCTUnwrap(UserDefaults(suiteName: name))
addTeardownBlock { UserDefaults().removePersistentDomain(forName: name) }
let defaults = SharedDefaults(suite: suite, standard: suite)
defaults.set(true, forKey: "continuumServerRegistry.migrated.v1")
let registry = ServerRegistry(
defaults: defaults,
keychain: SharedKeychain(service: name, accessGroup: nil),
launchPreferences: ProfileLaunchPreferences(defaults: defaults),
persistenceOverride: { _, _ in true }
)
let first = ServerEntry(id: "id", url: "https://a.example", fetchedName: nil, lastUsedAt: Date())
XCTAssertNotNil(registry.addOrUpdate(first))
XCTAssertTrue(registry.updateVerifiedServerId(for: "id", verifiedServerId: identity))
XCTAssertFalse(registry.updateVerifiedServerId(for: "id", verifiedServerId: " "), "blank never replaces a known identity")

let renamed = ServerEntry(id: "id", url: "https://a.example", fetchedName: "Home", lastUsedAt: Date())
XCTAssertEqual(registry.addOrUpdate(renamed)?.verifiedServerId, identity)
XCTAssertEqual(registry.entry(verifiedServerId: identity)?.id, "id")
XCTAssertNil(registry.entry(verifiedServerId: "other"))
}

func testConnectionsDocumentOffersOnlyAddressesWithURLs() throws {
let json = #"""
{"revision":"r","state":"available","allowed":true,"server_id":"S",
"current":{"kind":"default"},
"endpoints":[
{"kind":"public","url":"https://silo.example/"},
{"kind":"provider","provider":"down","display_name":"Down Overlay","state":"unavailable"},
{"kind":"provider","url":"https://silo.overlay.example","provider":"tailscale","display_name":"Tailscale","state":"connected"},
{"kind":"public","url":"https://silo.example"}
]}
"""#
let document = try HTTPClient.makeJSONDecoder().decode(ServerConnectionsDocument.self, from: Data(json.utf8))
XCTAssertTrue(document.isAvailable)
XCTAssertEqual(document.usableEndpoints, [
ServerEndpoint(url: "https://silo.example", kind: .public),
ServerEndpoint(url: "https://silo.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale"),
])
}

func testProviderHelpNamesTheProviderWithoutClaimingItIsMissing() {
let endpoint = ServerEndpoint(url: "https://silo.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale")
let help = endpoint.unreachableHelp(serverName: "Home")
XCTAssertTrue(help.contains("through Tailscale"))
XCTAssertTrue(help.contains("Install or open the Tailscale app"))
XCTAssertFalse(help.lowercased().contains("not installed"))
XCTAssertFalse(help.contains("overlay.example"), "provider name comes from the manifest, not the host")
}

func testCandidateOrderPrefersSavedThenOfferedThenEndpointsWithoutDuplicates() {
let urls = RemotePlaybackCandidatePolicy.candidateURLs(
savedURL: "https://silo.example/",
offeredURL: "https://silo.overlay.example",
endpoints: [
ServerEndpoint(url: "https://silo.example", kind: .public),
ServerEndpoint(url: "https://silo.overlay.example/", kind: .provider, provider: "tailscale", displayName: "Tailscale"),
ServerEndpoint(url: "https://other.overlay.example", kind: .provider, provider: "zerotier", displayName: "ZeroTier"),
]
)
XCTAssertEqual(urls, [
"https://silo.example",
"https://silo.overlay.example",
"https://other.overlay.example",
])
XCTAssertEqual(
RemotePlaybackCandidatePolicy.candidateURLs(savedURL: nil, offeredURL: "https://a.example", endpoints: nil),
["https://a.example"]
)
}
}
Loading
Loading