388 lines
18 KiB
Swift
388 lines
18 KiB
Swift
import Foundation
|
|
import RemoteCore
|
|
import Testing
|
|
@testable import TVTransport
|
|
|
|
private let baseDate = Date(timeIntervalSince1970: 10_000)
|
|
|
|
private func envelope(
|
|
sessionID: UUID,
|
|
televisionID: UUID,
|
|
sequence: UInt64,
|
|
command: RemoteCommand,
|
|
id: UUID = UUID(),
|
|
issuedAt: Date = baseDate,
|
|
expiresAt: Date = baseDate.addingTimeInterval(0.8)
|
|
) -> CommandEnvelope {
|
|
CommandEnvelope(
|
|
sessionID: sessionID,
|
|
televisionID: televisionID,
|
|
sequence: sequence,
|
|
issuedAt: issuedAt,
|
|
expiresAt: expiresAt,
|
|
command: command,
|
|
id: id
|
|
)
|
|
}
|
|
|
|
@Test func explicitPairThenTokenReconnectAuthorizesBeforeReady() async throws {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let pairSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
|
|
let reconnectSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
|
|
let factory = FakeSocketFactory(sockets: [pairSocket, reconnectSocket])
|
|
let store = FakeAuthorizationStore()
|
|
let transport = makeTransport(factory: factory, store: store)
|
|
|
|
let firstSession = UUID()
|
|
#expect(await transport.pair(target: tv, sessionID: firstSession) == .ready(televisionID: tv.id))
|
|
#expect(await store.savedToken(for: tv.id) == "redacted-token")
|
|
let firstEndpoint = try #require(await factory.endpoint(at: 0))
|
|
#expect(URLComponents(url: firstEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.contains(where: { $0.name == "token" }) == false)
|
|
|
|
await transport.disconnect()
|
|
let secondSession = UUID()
|
|
#expect(await transport.connect(target: tv, sessionID: secondSession) == .ready(televisionID: tv.id))
|
|
let secondEndpoint = try #require(await factory.endpoint(at: 1))
|
|
#expect(URLComponents(url: secondEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "token" })?.value == "redacted-token")
|
|
}
|
|
|
|
@Test func pairingDenialTimeoutAndMissingInitialTokenFailClosed() async {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let deniedSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.unauthorized"}"#.utf8)])
|
|
let missingTokenSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
|
|
let timeoutSocket = FakeSocket(events: [])
|
|
let factory = FakeSocketFactory(sockets: [deniedSocket, missingTokenSocket, timeoutSocket])
|
|
let store = FakeAuthorizationStore()
|
|
let transport = makeTransport(factory: factory, store: store, pairingTimeout: 0.01)
|
|
|
|
#expect(await transport.pair(target: tv, sessionID: UUID()) == .denied(televisionID: tv.id))
|
|
#expect(await transport.pair(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
|
|
#expect(await transport.pair(target: tv, sessionID: UUID()) == .timedOut(televisionID: tv.id))
|
|
}
|
|
|
|
@Test func sendsInOrderConsumesAdmissionAndExpiresQueuedWork() async throws {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let session = UUID()
|
|
let clock = TestClock(wall: baseDate, monotonic: 50)
|
|
let socket = FakeSocket(
|
|
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
|
|
blockedSendCount: 1
|
|
)
|
|
let factory = FakeSocketFactory(sockets: [socket])
|
|
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore(), clock: clock.value)
|
|
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
|
|
|
|
let first = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .right)
|
|
let second = envelope(sessionID: session, televisionID: tv.id, sequence: 2, command: .left)
|
|
let firstTask = Task { await transport.send(first) }
|
|
await socket.waitForSendCount(1)
|
|
let secondTask = Task { await transport.send(second) }
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
clock.advanceMonotonic(seconds: 1)
|
|
await socket.releaseBlockedSend()
|
|
|
|
#expect(await firstTask.value.status == .sentToTransport)
|
|
#expect(await secondTask.value.status == .expired)
|
|
#expect(await socket.sentKeyNames() == ["KEY_RIGHT"])
|
|
#expect(await transport.send(first).status == .duplicate)
|
|
}
|
|
|
|
@Test func failedWriteIsConsumedAndNeverRetried() async {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let session = UUID()
|
|
let socket = FakeSocket(
|
|
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
|
|
failingSendCount: 1
|
|
)
|
|
let transport = makeTransport(
|
|
factory: FakeSocketFactory(sockets: [socket]),
|
|
store: FakeAuthorizationStore()
|
|
)
|
|
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
|
|
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up)
|
|
#expect(await transport.send(command).status == .unavailable)
|
|
#expect(await transport.send(command).status == .unavailable)
|
|
#expect(await socket.sentKeyNames() == ["KEY_UP"])
|
|
}
|
|
|
|
@Test func targetChangeCancelsInflightAndQueuedOldGenerationCommands() async {
|
|
let firstTV = TelevisionTarget(id: UUID(), host: "first.synthetic.test")
|
|
let secondTV = TelevisionTarget(id: UUID(), host: "second.synthetic.test")
|
|
let firstSession = UUID()
|
|
let firstSocket = FakeSocket(
|
|
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-one"}}"#.utf8)],
|
|
blockedSendCount: 1
|
|
)
|
|
let secondSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-two"}}"#.utf8)])
|
|
let factory = FakeSocketFactory(sockets: [firstSocket, secondSocket])
|
|
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore())
|
|
#expect(await transport.pair(target: firstTV, sessionID: firstSession) == .ready(televisionID: firstTV.id))
|
|
|
|
let first = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 1, command: .right)
|
|
let second = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 2, command: .left)
|
|
let firstTask = Task { await transport.send(first) }
|
|
await firstSocket.waitForSendCount(1)
|
|
let secondTask = Task { await transport.send(second) }
|
|
await Task.yield()
|
|
|
|
#expect(await transport.pair(target: secondTV, sessionID: UUID()) == .ready(televisionID: secondTV.id))
|
|
#expect(await firstTask.value.status == .unavailable)
|
|
#expect(await secondTask.value.status == .unavailable)
|
|
#expect(await firstSocket.sentKeyNames() == ["KEY_RIGHT"])
|
|
}
|
|
|
|
@Test func reconnectRequiresTokenAndRevocationDeletesIt() async {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let store = FakeAuthorizationStore()
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: []), store: store)
|
|
#expect(await transport.connect(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
|
|
|
|
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
|
|
await transport.revokeAuthorization()
|
|
#expect(await store.savedToken(for: tv.id) == nil)
|
|
}
|
|
|
|
@Test func forgetDeletesAuthorizationAndSelectedTarget() async {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let store = FakeAuthorizationStore()
|
|
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
|
|
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
|
|
#expect(await transport.connect(target: tv, sessionID: UUID()) == .ready(televisionID: tv.id))
|
|
await transport.forgetTelevision()
|
|
#expect(await transport.connectionState() == .disconnected)
|
|
#expect(await store.savedToken(for: tv.id) == nil)
|
|
}
|
|
|
|
private func makeTransport(
|
|
factory: FakeSocketFactory,
|
|
store: FakeAuthorizationStore,
|
|
clock: TVTransportClock = TestClock(wall: baseDate, monotonic: 50).value,
|
|
pairingTimeout: TimeInterval = 1
|
|
) -> TVConnectionActor {
|
|
TVConnectionActor(
|
|
socketFactory: factory,
|
|
authorizationStore: store,
|
|
clock: clock,
|
|
configuration: .init(pairingTimeout: pairingTimeout)
|
|
)
|
|
}
|
|
|
|
private final class TestClock: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var wall: Date
|
|
private var monotonic: TimeInterval
|
|
|
|
init(wall: Date, monotonic: TimeInterval) {
|
|
self.wall = wall
|
|
self.monotonic = monotonic
|
|
}
|
|
|
|
var value: TVTransportClock {
|
|
TVTransportClock(
|
|
wallNow: { [self] in lock.withLock { wall } },
|
|
monotonicNow: { [self] in lock.withLock { monotonic } }
|
|
)
|
|
}
|
|
|
|
func advance(seconds: TimeInterval) {
|
|
lock.withLock {
|
|
wall = wall.addingTimeInterval(seconds)
|
|
monotonic += seconds
|
|
}
|
|
}
|
|
|
|
func advanceMonotonic(seconds: TimeInterval) {
|
|
lock.withLock { monotonic += seconds }
|
|
}
|
|
}
|
|
|
|
private actor FakeAuthorizationStore: TVAuthorizationStore {
|
|
private var values: [UUID: StoredTVAuthorization] = [:]
|
|
|
|
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
|
|
values[televisionID] ?? StoredTVAuthorization(token: nil)
|
|
}
|
|
|
|
func saveToken(_ token: String, for televisionID: UUID) async throws {
|
|
let trust = values[televisionID]?.trust ?? .systemDefault
|
|
values[televisionID] = StoredTVAuthorization(token: token, trust: trust)
|
|
}
|
|
|
|
func deleteToken(for televisionID: UUID) async throws {
|
|
let trust = values[televisionID]?.trust ?? .systemDefault
|
|
values[televisionID] = StoredTVAuthorization(token: nil, trust: trust)
|
|
}
|
|
|
|
func deleteTrust(for televisionID: UUID) async throws {
|
|
let token = values[televisionID]?.token
|
|
values[televisionID] = StoredTVAuthorization(token: token, trust: .systemDefault)
|
|
}
|
|
|
|
func savedToken(for televisionID: UUID) -> String? { values[televisionID]?.token }
|
|
|
|
func setAuthorization(_ authorization: StoredTVAuthorization, for televisionID: UUID) {
|
|
values[televisionID] = authorization
|
|
}
|
|
}
|
|
|
|
private actor FakeSocketFactory: TVWebSocketFactory {
|
|
private var sockets: [FakeSocket]
|
|
private var endpoints: [URL] = []
|
|
|
|
init(sockets: [FakeSocket]) { self.sockets = sockets }
|
|
|
|
func open(endpoint: URL, trust: TVTrustPolicy, maximumMessageBytes: Int) async throws -> any TVWebSocket {
|
|
endpoints.append(endpoint)
|
|
guard !sockets.isEmpty else { throw TVSocketError.unavailable }
|
|
return sockets.removeFirst()
|
|
}
|
|
|
|
func endpoint(at index: Int) -> URL? {
|
|
endpoints.indices.contains(index) ? endpoints[index] : nil
|
|
}
|
|
}
|
|
|
|
private actor FakeSocket: TVWebSocket {
|
|
private var events: [Data]
|
|
private var receiveContinuation: CheckedContinuation<Data, Error>?
|
|
private var sendContinuation: CheckedContinuation<Void, Error>?
|
|
private var sent: [Data] = []
|
|
private var blockedSendCount: Int
|
|
private var failingSendCount: Int
|
|
private var closed = false
|
|
|
|
init(events: [Data], blockedSendCount: Int = 0, failingSendCount: Int = 0) {
|
|
self.events = events
|
|
self.blockedSendCount = blockedSendCount
|
|
self.failingSendCount = failingSendCount
|
|
}
|
|
|
|
func receive() async throws -> Data {
|
|
if closed { throw TVSocketError.closed }
|
|
if !events.isEmpty { return events.removeFirst() }
|
|
return try await withCheckedThrowingContinuation { receiveContinuation = $0 }
|
|
}
|
|
|
|
func send(_ data: Data) async throws {
|
|
if closed { throw TVSocketError.closed }
|
|
sent.append(data)
|
|
if failingSendCount > 0 {
|
|
failingSendCount -= 1
|
|
throw TVSocketError.unavailable
|
|
}
|
|
if blockedSendCount > 0 {
|
|
blockedSendCount -= 1
|
|
try await withCheckedThrowingContinuation { sendContinuation = $0 }
|
|
}
|
|
}
|
|
|
|
func close() async {
|
|
closed = true
|
|
receiveContinuation?.resume(throwing: TVSocketError.closed)
|
|
receiveContinuation = nil
|
|
sendContinuation?.resume(throwing: TVSocketError.closed)
|
|
sendContinuation = nil
|
|
}
|
|
|
|
func deliver(_ data: Data) {
|
|
if let pending = receiveContinuation {
|
|
receiveContinuation = nil
|
|
pending.resume(returning: data)
|
|
} else { events.append(data) }
|
|
}
|
|
|
|
func releaseBlockedSend() {
|
|
sendContinuation?.resume()
|
|
sendContinuation = nil
|
|
}
|
|
|
|
func waitForSendCount(_ count: Int) async {
|
|
while sent.count < count { await Task.yield() }
|
|
}
|
|
|
|
func sentKeyNames() -> [String] {
|
|
sent.compactMap { data in
|
|
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let parameters = object["params"] as? [String: String] else { return nil }
|
|
return parameters["DataOfCmd"]
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test func watchNegotiationKeepsIdleSocketAndPhoneCommandsDoNotStealGrant() async throws {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
|
|
_ = await transport.pair(target: tv, sessionID: UUID())
|
|
let peer = WatchPeerGeneration()
|
|
await transport.watchBridgeDidActivate(peer: peer)
|
|
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
|
|
#expect(await transport.sendPhoneCommand(.left).status == .sentToTransport)
|
|
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
|
|
#expect(await transport.submitWatchCommand(command, peer: peer).status == .sentToTransport)
|
|
#expect(await transport.submitWatchCommand(command, peer: peer).status == .duplicate)
|
|
#expect(await socket.sentKeyNames() == ["KEY_LEFT", "KEY_RIGHT"])
|
|
await transport.disconnect()
|
|
}
|
|
|
|
@Test func stalePeerAndStaleEndCannotInvalidateCurrentGrant() async throws {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
|
|
_ = await transport.pair(target: tv, sessionID: UUID())
|
|
let oldPeer = WatchPeerGeneration(), peer = WatchPeerGeneration()
|
|
await transport.watchBridgeDidActivate(peer: oldPeer)
|
|
let old = try await transport.beginWatchSession(.init(), peer: oldPeer).get()
|
|
await transport.watchBridgeDidActivate(peer: peer)
|
|
let current = try await transport.beginWatchSession(.init(), peer: peer).get()
|
|
await transport.watchBridgeDidDeactivate(peer: oldPeer)
|
|
#expect(await transport.endWatchSession(.init(sessionID: old.sessionID), peer: peer) == .failure(.init(code: .sessionInvalidated)))
|
|
#expect(await transport.submitWatchCommand(envelope(sessionID: old.sessionID, televisionID: tv.id, sequence: 1, command: .left), peer: oldPeer).status == .rejected)
|
|
#expect(await transport.submitWatchCommand(envelope(sessionID: current.sessionID, televisionID: tv.id, sequence: 1, command: .right), peer: peer).status == .sentToTransport)
|
|
await transport.disconnect()
|
|
}
|
|
|
|
@Test func rotatingGrantDuringWriteClosesSocketBeforeNewGrant() async throws {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
|
|
_ = await transport.pair(target: tv, sessionID: UUID())
|
|
let peer = WatchPeerGeneration()
|
|
await transport.watchBridgeDidActivate(peer: peer)
|
|
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
|
|
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
|
|
let pending = Task { await transport.submitWatchCommand(command, peer: peer) }
|
|
await socket.waitForSendCount(1)
|
|
#expect(await transport.beginWatchSession(.init(), peer: peer) == .failure(.init(code: .unavailable)))
|
|
#expect(await pending.value.status == .unavailable)
|
|
#expect(await transport.connectionState() == .disconnected)
|
|
}
|
|
|
|
@Test func readyConnectionConsumesRevocationAndStopsCommands() async throws {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
|
|
let store = FakeAuthorizationStore()
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
|
|
_ = await transport.pair(target: tv, sessionID: UUID())
|
|
await socket.deliver(Data(#"{"event":"ms.channel.unauthorized"}"#.utf8))
|
|
for _ in 0..<1000 {
|
|
if await transport.connectionState() == .unauthorized(televisionID: tv.id) { break }
|
|
await Task.yield()
|
|
}
|
|
#expect(await transport.connectionState() == .unauthorized(televisionID: tv.id))
|
|
#expect(await transport.sendPhoneCommand(.left).status == .unavailable)
|
|
}
|
|
|
|
@Test func sendDeadlineClosesNonCompletingSocket() async {
|
|
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
|
|
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
|
|
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
|
|
let session = UUID()
|
|
_ = await transport.pair(target: tv, sessionID: session)
|
|
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up, expiresAt: baseDate.addingTimeInterval(0.02))
|
|
#expect(await transport.send(command).status == .unavailable)
|
|
#expect(await transport.connectionState() == .unavailable(televisionID: tv.id, failure: .socket))
|
|
#expect(await socket.sentKeyNames() == ["KEY_UP"])
|
|
}
|