mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 03:55:05 +00:00
/api/health needs X-Secret-Key but stays outside the rate limit. Only the admin key gets load_errors from /api/services, default error details no longer name the exception class, and a prepare signal replaces aiohttp's Server banner on every response while the CDM routes keep their own.
302 lines
13 KiB
Python
302 lines
13 KiB
Python
"""The CDM call log of `unshackle serve` and its `/api/dashboard/cdm-logs` route."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from click.testing import CliRunner
|
|
|
|
from unshackle.commands.serve import serve
|
|
from unshackle.core.api.stats import SeqRing, cdm_calls, cdm_log_middleware, cdm_sessions, key_id
|
|
from unshackle.core.config import config
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
USER = {"X-Secret-Key": "tier-key"}
|
|
DASH = {"X-Secret-Key": "dash-secret"}
|
|
INIT_DATA = "AAAAaW5pdC1kYXRhLW1hcmtlcg=="
|
|
CHALLENGE_B64_PREFIX = "Y2hhbGxlbmdl"
|
|
LICENCE = "bGljZW5jZS1tYXJrZXI="
|
|
CONTENT_KEY = "00112233445566778899aabbccddeeff"
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self) -> None:
|
|
self.opened_at = time.time()
|
|
|
|
|
|
class Cdm:
|
|
"""A stand-in for both libraries' Cdm. The class name keeps the ``_Cdm__sessions`` mangling."""
|
|
|
|
MAX_NUM_OF_SESSIONS = 16
|
|
system_id = 1234
|
|
security_level = 3
|
|
|
|
def __init__(self) -> None:
|
|
self.__sessions: dict[bytes, FakeSession] = {}
|
|
|
|
@classmethod
|
|
def from_device(cls, _device: Any) -> Cdm:
|
|
return cls()
|
|
|
|
def open(self) -> bytes:
|
|
session_id = bytes([len(self.__sessions) + 1]) * 16
|
|
self.__sessions[session_id] = FakeSession()
|
|
return session_id
|
|
|
|
def close(self, session_id: bytes) -> None:
|
|
del self.__sessions[session_id]
|
|
|
|
def get_service_certificate(self, session_id: bytes) -> None:
|
|
return None
|
|
|
|
def get_license_challenge(self, session_id: bytes, **_: Any) -> Any:
|
|
return b"challenge-marker" * 40
|
|
|
|
def parse_license(self, session_id: bytes, _message: Any) -> None:
|
|
return None
|
|
|
|
def age(self, seconds: float) -> None:
|
|
for session in self.__sessions.values():
|
|
session.opened_at -= seconds
|
|
|
|
|
|
class PlayReadyCdm(Cdm):
|
|
SESSION_TIMEOUT = 30
|
|
|
|
def get_license_challenge(self, session_id: bytes, **_: Any) -> Any:
|
|
return "challenge-marker" * 40
|
|
|
|
|
|
@pytest.fixture
|
|
def serve_app(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> web.Application:
|
|
"""The app a keyed, full-mode `unshackle serve` builds, with both CDM libraries stubbed."""
|
|
from pyplayready.remote import serve as pyplayready_serve
|
|
from pywidevine import serve as pywidevine_serve
|
|
|
|
(tmp_path / "chrome_l3.wvd").touch()
|
|
(tmp_path / "sl2000.prd").touch()
|
|
monkeypatch.setattr(config.directories, "wvds", tmp_path)
|
|
monkeypatch.setattr(config.directories, "prds", tmp_path)
|
|
monkeypatch.setattr(
|
|
config,
|
|
"serve",
|
|
{
|
|
"api_secret": "master-key",
|
|
"dashboard": {"key": "dash-secret"},
|
|
"users": {
|
|
"tier-key": {"username": "tier1", "devices": ["chrome_l3"], "playready_devices": ["sl2000"]},
|
|
},
|
|
},
|
|
)
|
|
for module, cdm in ((pywidevine_serve, Cdm), (pyplayready_serve, PlayReadyCdm)):
|
|
monkeypatch.setattr(module, "Cdm", cdm)
|
|
monkeypatch.setattr(module.Device, "load", staticmethod(lambda _path: object()))
|
|
monkeypatch.setattr(pywidevine_serve, "PSSH", lambda data: data)
|
|
monkeypatch.setattr(pyplayready_serve, "PSSH", lambda _data: type("P", (), {"wrm_headers": ["header"]})())
|
|
|
|
captured: dict[str, web.Application] = {}
|
|
monkeypatch.setattr(web, "run_app", lambda app, **kwargs: captured.update(app=app))
|
|
result = CliRunner().invoke(serve, [])
|
|
assert result.exit_code == 0, result.output
|
|
cdm_calls.records.clear()
|
|
cdm_calls.seq = 0
|
|
return captured["app"]
|
|
|
|
|
|
async def cdm_log(client: Any, since: int = 0) -> dict[str, Any]:
|
|
resp = await client.get(f"/api/dashboard/cdm-logs?since={since}", headers=DASH)
|
|
assert resp.status == 200
|
|
return await resp.json()
|
|
|
|
|
|
async def test_widevine_exchange_is_recorded_per_call(aiohttp_client, serve_app) -> None:
|
|
client = await aiohttp_client(serve_app)
|
|
opened = await (await client.get("/chrome_l3/open", headers=USER)).json()
|
|
session_id = opened["data"]["session_id"]
|
|
assert (await cdm_log(client))["sessions"] == [
|
|
{"drm": "widevine", "device": "chrome_l3", "key_id": key_id("tier-key"), "user": "tier1", "open": 1, "max": 17}
|
|
]
|
|
|
|
# Accept-Encoding makes compression_middleware gzip the long challenge reply.
|
|
challenge = await client.post(
|
|
"/chrome_l3/get_license_challenge/STREAMING",
|
|
headers={**USER, "Accept-Encoding": "gzip"},
|
|
json={"session_id": session_id.upper(), "init_data": INIT_DATA},
|
|
)
|
|
assert challenge.status == 200, await challenge.text()
|
|
parsed = await client.post(
|
|
"/chrome_l3/parse_license", headers=USER, json={"session_id": session_id, "license_message": LICENCE}
|
|
)
|
|
assert parsed.status == 200, await parsed.text()
|
|
assert (await client.get(f"/chrome_l3/close/{session_id}", headers=USER)).status == 200
|
|
|
|
page = await cdm_log(client)
|
|
records = page["records"]
|
|
assert [r["op"] for r in records] == ["open", "get_license_challenge", "parse_license", "close"]
|
|
assert [r["seq"] for r in records] == [1, 2, 3, 4] and page["seq"] == 4
|
|
for record in records:
|
|
assert record["drm"] == "widevine" and record["device"] == "chrome_l3"
|
|
assert record["session_id"] == session_id
|
|
assert record["user"] == "tier1" and record["key_id"] == key_id("tier-key")
|
|
assert record["status"] == 200 and record["ok"] is True and record["error"] is None
|
|
assert isinstance(record["ms"], float) and abs(record["ts"] - time.time()) < 60
|
|
assert set(record) == {
|
|
"seq", "ts", "drm", "op", "device", "key_id", "user", "session_id", "status", "ok", "error", "ms",
|
|
} # fmt: skip
|
|
assert page["sessions"][0]["open"] == 0
|
|
|
|
dump = json.dumps(page)
|
|
for secret in ("tier-key", INIT_DATA, CHALLENGE_B64_PREFIX, "challenge-marker", LICENCE, CONTENT_KEY):
|
|
assert secret not in dump
|
|
|
|
|
|
async def test_playready_sub_app_calls_are_tagged_playready(aiohttp_client, serve_app) -> None:
|
|
client = await aiohttp_client(serve_app)
|
|
opened = await (await client.get("/playready/sl2000/open", headers=USER)).json()
|
|
session_id = opened["data"]["session_id"]
|
|
challenge = await client.post(
|
|
"/playready/sl2000/get_license_challenge", headers=USER, json={"session_id": session_id, "init_data": INIT_DATA}
|
|
)
|
|
assert challenge.status == 200, await challenge.text()
|
|
|
|
page = await cdm_log(client)
|
|
assert [(r["drm"], r["op"], r["device"], r["session_id"]) for r in page["records"]] == [
|
|
("playready", "open", "sl2000", session_id),
|
|
("playready", "get_license_challenge", "sl2000", session_id),
|
|
]
|
|
assert page["sessions"] == [
|
|
{"drm": "playready", "device": "sl2000", "key_id": key_id("tier-key"), "user": "tier1", "open": 1, "max": 17}
|
|
]
|
|
# A session past SESSION_TIMEOUT is one the library drops on its next open.
|
|
serve_app["playready_app"]["cdms"][("tier-key", "sl2000")].age(PlayReadyCdm.SESSION_TIMEOUT + 1)
|
|
assert (await cdm_log(client))["sessions"][0]["open"] == 0
|
|
|
|
|
|
async def test_refusals_are_recorded_with_their_message(aiohttp_client, serve_app) -> None:
|
|
client = await aiohttp_client(serve_app)
|
|
assert (await client.get("/other_device/open", headers=USER)).status == 403
|
|
assert (await client.get("/chrome_l3/open", headers={"X-Secret-Key": "wrong-key"})).status == 401
|
|
assert (await client.get("/chrome_l3/open")).status == 401
|
|
assert (await client.get("/playready/sl2000/open", headers={"X-Secret-Key": "wrong-key"})).status == 401
|
|
unopened = await client.post("/chrome_l3/get_keys/CONTENT", headers=USER, json={"session_id": "AB" * 16})
|
|
assert unopened.status == 400
|
|
assert (await client.post("/chrome_l3/parse_license", headers=USER, json={})).status == 400
|
|
|
|
page = await cdm_log(client)
|
|
forbidden, invalid, empty, playready, no_cdm, no_field = page["records"]
|
|
assert forbidden["status"] == 403 and forbidden["ok"] is False and forbidden["user"] == "tier1"
|
|
assert forbidden["device"] == "other_device" and "not authorized" in forbidden["error"]
|
|
assert forbidden["session_id"] is None
|
|
assert invalid["status"] == 401 and invalid["ok"] is False and "Invalid" in invalid["error"]
|
|
assert invalid["user"] == "anonymous" and invalid["key_id"] == "anonymous"
|
|
assert empty["status"] == 401 and "Empty" in empty["error"] and empty["user"] == "anonymous"
|
|
assert playready["drm"] == "playready" and playready["status"] == 401 and "Invalid" in playready["error"]
|
|
assert no_cdm["op"] == "get_keys" and no_cdm["status"] == 400 and no_cdm["session_id"] == "ab" * 16
|
|
assert no_field["op"] == "parse_license" and no_field["session_id"] is None and "session_id" in no_field["error"]
|
|
assert page["sessions"] == [] and "wrong-key" not in json.dumps(page)
|
|
|
|
|
|
async def test_gzipped_refusal_is_read_and_capped(aiohttp_client, serve_app) -> None:
|
|
"""compression_middleware runs inside the log middleware, so a long refusal arrives as gzip."""
|
|
client = await aiohttp_client(serve_app)
|
|
device = "d" * 400
|
|
resp = await client.get(f"/{device}/open", headers={**USER, "Accept-Encoding": "gzip"})
|
|
assert resp.status == 403 and resp.headers["Content-Encoding"] == "gzip"
|
|
(record,) = (await cdm_log(client))["records"]
|
|
assert record["device"] == device[:255] and len(record["error"]) == 300
|
|
assert record["error"].startswith("Device 'ddd")
|
|
|
|
|
|
async def test_refused_call_leaves_the_request_body_unread(aiohttp_client, serve_app) -> None:
|
|
"""A 401 is decided before the handler, so the body stays unread: no cost, and no session id, for an unknown key."""
|
|
client = await aiohttp_client(serve_app)
|
|
body = {"session_id": "ab" * 16, "license_message": LICENCE}
|
|
assert (
|
|
await client.post("/chrome_l3/parse_license", headers={"X-Secret-Key": "wrong-key"}, json=body)
|
|
).status == 401
|
|
assert (await client.post("/chrome_l3/parse_license", headers=USER, json=body)).status == 400
|
|
refused, unopened = (await cdm_log(client))["records"]
|
|
assert refused["status"] == 401 and refused["session_id"] is None
|
|
assert unopened["status"] == 400 and unopened["session_id"] == "ab" * 16
|
|
|
|
|
|
async def test_raised_http_exception_is_recorded_with_its_status(aiohttp_client) -> None:
|
|
"""aiohttp answers a raised ``HTTPException`` itself, so the record carries that status, not 500."""
|
|
|
|
async def too_large(_request: web.Request) -> web.Response:
|
|
raise web.HTTPRequestEntityTooLarge(max_size=1, actual_size=2)
|
|
|
|
app = web.Application(middlewares=[cdm_log_middleware])
|
|
app.router.add_post("/{device}/parse_license", too_large)
|
|
before = cdm_calls.seq
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post("/chrome_l3/parse_license", json={"session_id": "ab" * 16})
|
|
(record,) = cdm_calls.since(before)
|
|
assert resp.status == 413 and record["status"] == 413 and record["ok"] is False
|
|
assert record["error"] == resp.reason
|
|
|
|
|
|
async def test_since_cursor_and_dashboard_key(aiohttp_client, serve_app) -> None:
|
|
client = await aiohttp_client(serve_app)
|
|
for _ in range(3):
|
|
await client.get("/chrome_l3/open", headers=USER)
|
|
assert (await client.get("/api/dashboard/cdm-logs")).status == 401
|
|
assert (await client.get("/api/dashboard/cdm-logs", headers=USER)).status == 401
|
|
# Only the device routes are CDM calls: not the API, not the ping, not this route.
|
|
await client.get("/api/health", headers=USER)
|
|
await client.get("/", headers=USER)
|
|
|
|
page = await cdm_log(client, since=1)
|
|
assert [r["seq"] for r in page["records"]] == [2, 3] and page["seq"] == 3
|
|
assert await cdm_log(client, since=3) == {"seq": 3, "records": [], "sessions": page["sessions"]}
|
|
resp = await client.get("/api/dashboard/cdm-logs?since=junk", headers=DASH)
|
|
assert len((await resp.json())["records"]) == 3
|
|
assert page["sessions"][0]["open"] == 3
|
|
|
|
|
|
async def test_handler_exception_is_recorded_without_its_text(aiohttp_client) -> None:
|
|
"""An unhandled error still answers 500, and its text (it can hold the API key) stays out."""
|
|
|
|
async def broken(request: web.Request) -> web.Response:
|
|
raise KeyError(request.headers["X-Secret-Key"])
|
|
|
|
app = web.Application(middlewares=[cdm_log_middleware])
|
|
app.router.add_get("/{device}/open", broken)
|
|
before = cdm_calls.seq
|
|
client = await aiohttp_client(app)
|
|
assert (await client.get("/chrome_l3/open", headers={"X-Secret-Key": "leaky-key"})).status == 500
|
|
(record,) = cdm_calls.since(before)
|
|
assert record["status"] == 500 and record["ok"] is False and record["error"] == "KeyError"
|
|
|
|
|
|
async def test_recording_failure_never_fails_the_cdm_call(aiohttp_client, monkeypatch) -> None:
|
|
async def opened(_request: web.Request) -> web.Response:
|
|
return web.json_response({"message": "Success"})
|
|
|
|
def refuse(_record: dict[str, Any]) -> None:
|
|
raise RuntimeError("log is broken")
|
|
|
|
monkeypatch.setattr(cdm_calls, "append", refuse)
|
|
app = web.Application(middlewares=[cdm_log_middleware])
|
|
app.router.add_get("/{device}/open", opened)
|
|
client = await aiohttp_client(app)
|
|
assert (await client.get("/chrome_l3/open")).status == 200
|
|
|
|
|
|
def test_call_log_keeps_the_newest_and_counts_on() -> None:
|
|
log = SeqRing(maxlen=2)
|
|
for op in ("open", "get_keys", "close"):
|
|
log.append({"op": op})
|
|
assert [(r["seq"], r["op"]) for r in log.since()] == [(2, "get_keys"), (3, "close")]
|
|
assert log.since(2) == [{"seq": 3, "op": "close"}] and log.seq == 3
|
|
|
|
|
|
def test_sessions_snapshot_is_empty_without_a_cdm() -> None:
|
|
assert cdm_sessions(web.Application()) == []
|