A device runs no tool-hosted test bundle, so the package's tests run on one hosted in TestHost/: a minimal application (an empty window) and an Xcode project whose unit-test target compiles the same test files against this package by its path, with the corpus in its resources and local networking allowed for the loopback origin and relay. The package itself is unchanged for consumers. The tests import WaiSink plainly (they use only its public interface) and read the corpus from Bundle.module in the package, from their own bundle in the host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
102 lines
5.5 KiB
Swift
102 lines
5.5 KiB
Swift
// The relay a player's requests take (native-sink §4.4), with the reference package's floor as
|
|
// the origin's presentation: its media playlist sealed by its receipt, its fragments named by
|
|
// their SHA-256. What the relay presents, it presents byte for byte; a fragment changed at the
|
|
// origin is answered 502 and never handed on; and the session's bundle verifies.
|
|
import CryptoKit
|
|
import XCTest
|
|
import WaiSink
|
|
|
|
final class RelayTests: XCTestCase {
|
|
private func fetch(_ url: URL) -> (Int, Data) {
|
|
let done = expectation(description: url.absoluteString)
|
|
var out: (Int, Data) = (0, Data())
|
|
URLSession(configuration: .ephemeral).dataTask(with: url) { d, r, _ in
|
|
out = ((r as? HTTPURLResponse)?.statusCode ?? 0, d ?? Data())
|
|
done.fulfill()
|
|
}.resume()
|
|
wait(for: [done], timeout: 20)
|
|
return out
|
|
}
|
|
|
|
/// The signed case of the corpus, served from an origin on loopback: playlist, receipt, init
|
|
/// and fragments, the third fragment changed when `tamper`.
|
|
private func origin(tamper: Bool) throws -> (TestOrigin, [String: Any]) {
|
|
let doc = try corpus()
|
|
let objects = doc["objects"] as! [String: String]
|
|
let o = TestOrigin()
|
|
try o.start()
|
|
let c = (doc["cases"] as! [[String: Any]]).first { $0["name"] as? String == "signed-vod" }!
|
|
for call in c["calls"] as! [[String: Any]] {
|
|
let d = call["do"] as! [String: Any]
|
|
if d["call"] as? String == "playlist", d["receipt"] is String {
|
|
let pl = hexData(objects[d["body"] as! String]!)
|
|
o.put("/title/v/v.m3u8", pl, type: "application/vnd.apple.mpegurl")
|
|
o.put("/title/v/" + WaiSink.receiptName(pl), hexData(objects[d["receipt"] as! String]!), type: "application/json")
|
|
}
|
|
if d["call"] as? String == "object", let m = d["meta"] as? [String: Any], let u = URL(string: m["url"] as! String), u.path.hasPrefix("/title/v/"), u.lastPathComponent != "v.m3u8" {
|
|
var b = hexData(objects[d["body"] as! String]!)
|
|
if tamper && d["body"] as? String == "f2" { b[b.count - 1] ^= 1 }
|
|
o.put(u.path, b, type: u.pathExtension == "m4s" ? "video/iso.segment" : "video/mp4")
|
|
}
|
|
}
|
|
return (o, doc)
|
|
}
|
|
|
|
func testTheRelayPresentsWhatVerifiesAndWithholdsWhatDoesNot() throws {
|
|
for tamper in [false, true] {
|
|
let (o, doc) = try origin(tamper: tamper)
|
|
defer { o.stop() }
|
|
let sink = try WaiSink(config: .init(channel: "film-library", owner: doc["owner"] as! String))
|
|
let relay = WaiRelay(sink: sink)
|
|
try relay.start()
|
|
defer { relay.stop() }
|
|
let (st, pl) = fetch(relay.relayURL(for: o.url("/title/v/v.m3u8")))
|
|
XCTAssertEqual(st, 200)
|
|
let text = String(decoding: pl, as: UTF8.self)
|
|
let uris = text.components(separatedBy: "\n").filter { !$0.isEmpty && !$0.hasPrefix("#") }
|
|
XCTAssertEqual(uris.count, 5)
|
|
XCTAssertTrue(uris.allSatisfy { $0.hasPrefix("http://127.0.0.1:\(relay.port)/s/") }, "every URI points at the relay")
|
|
XCTAssertTrue(text.contains("URI=\"http://127.0.0.1:\(relay.port)/s/"), "the map points at the relay")
|
|
XCTAssertEqual(relay.events.first { $0.url.hasSuffix("v.m3u8") }?.listing, "signed")
|
|
var refused = 0
|
|
for u in uris {
|
|
let (s, b) = fetch(URL(string: u)!)
|
|
let name = URL(string: u)!.lastPathComponent
|
|
if s == 200 {
|
|
XCTAssertEqual(SHA.hex(b) + ".m4s", name, "presented bytes are the bytes the name states")
|
|
} else {
|
|
XCTAssertEqual(s, 502)
|
|
XCTAssertTrue(b.isEmpty, "a refused fragment is never handed on")
|
|
refused += 1
|
|
}
|
|
}
|
|
XCTAssertEqual(refused, tamper ? 1 : 0)
|
|
let ev = relay.events.filter { $0.url.hasSuffix(".m4s") }
|
|
XCTAssertTrue(ev.allSatisfy { $0.listing == "signed" && $0.bound == "sha256" })
|
|
XCTAssertEqual(ev.compactMap(\.refused), tamper ? ["object_digest_mismatch"] : [])
|
|
print("WAI-SINK relay\(tamper ? " (a fragment changed at the origin)" : ""): playlist \(relay.events.first { $0.url.hasSuffix("v.m3u8") }?.listing ?? ""), fragments \(ev.map { "\($0.status)" }.joined(separator: " "))")
|
|
let bundle = try sink.end(tamper ? .error : .ended, atUs: WaiSink.nowUs)
|
|
let out = try WaiSink.verifyBundle(bundle, owner: hexData(doc["owner"] as! String), channels: doc["channels"] as! [String])
|
|
XCTAssertEqual(out["outcome"] as? String, "accept", "\(out)")
|
|
// Every fragment received is acknowledged, the refused one too.
|
|
let delivery = out["delivery"] as! [[String: Any]]
|
|
XCTAssertEqual(delivery.first { $0["class"] as? String == "fetch" }?["objects"] as? Int, 5)
|
|
}
|
|
}
|
|
|
|
func testARequestOutsideTheSessionIsNotAnswered() throws {
|
|
let sink = try WaiSink(config: .init(channel: "film-library", owner: String(repeating: "0", count: 64)))
|
|
let relay = WaiRelay(sink: sink)
|
|
try relay.start()
|
|
defer { relay.stop() }
|
|
let (st, _) = fetch(URL(string: "http://127.0.0.1:\(relay.port)/s/00000000000000000000000000000000/aHR0cDovL2EuZXhhbXBsZS94/x")!)
|
|
XCTAssertEqual(st, 404)
|
|
}
|
|
}
|
|
|
|
/// SHA-256, for the tests' own checks: the platform's.
|
|
enum SHA {
|
|
static func hex(_ d: Data) -> String {
|
|
SHA256.hash(data: d).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|