Files
wristivo/Adapters/TVTransport/Tests/TVTransportTests/TVConnectionActorTests.swift
T

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"])
}