mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
A session above the cap licenses with the client's own device, and a track above it in an uncapped -q run is handed back to the client. Also names the fallback in the ccextractor crash warning.
152 lines
5.8 KiB
Python
152 lines
5.8 KiB
Python
"""Unit tests for the /api/download security gates (per-request CDM + credential overrides)
|
|
and the secret redaction applied to job parameters and error/stderr fields."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
|
|
from unshackle.core.api import handlers
|
|
from unshackle.core.api.download_manager import (
|
|
DownloadJob,
|
|
JobStatus,
|
|
_redact_text,
|
|
redact_parameters,
|
|
secret_values,
|
|
)
|
|
from unshackle.core.api.errors import APIError, APIErrorCode
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def test_redact_parameters_masks_secrets_and_proxy_userinfo():
|
|
params = {
|
|
"service": "EXAMPLE",
|
|
"credential": "user:hunter2",
|
|
"password": "pw",
|
|
"token": "tok",
|
|
"api_key": "ak",
|
|
"proxy": "http://bob:secret@proxy.example:8080",
|
|
"quality": "1080p",
|
|
}
|
|
red = redact_parameters(params)
|
|
assert red["credential"] == "***"
|
|
assert red["password"] == "***"
|
|
assert red["token"] == "***"
|
|
assert red["api_key"] == "***"
|
|
assert red["proxy"] == "http://***@proxy.example:8080"
|
|
assert red["quality"] == "1080p" # non-secret left intact
|
|
assert params["credential"] == "user:hunter2" # original dict not mutated
|
|
|
|
|
|
def test_redact_parameters_masks_credentials_dict():
|
|
assert redact_parameters({"credentials": {"default": "u:p"}})["credentials"] == "***"
|
|
|
|
|
|
def test_secret_values_includes_password_half_and_dict_values():
|
|
secrets = secret_values({"credential": "user:hunter2", "credentials": {"d": "alice:wonder"}})
|
|
assert "user:hunter2" in secrets # full credential
|
|
assert "hunter2" in secrets # password half of user:pass
|
|
assert "alice:wonder" in secrets # value from the credentials map
|
|
|
|
|
|
def test_redact_text_scrubs_credential_and_proxy_from_free_text():
|
|
params = {"credential": "user:hunter2", "proxy": "http://bob:secret@p:1"}
|
|
out = _redact_text("auth failed for user:hunter2 via http://bob:secret@p:1", params)
|
|
assert "hunter2" not in out
|
|
assert "bob:secret@" not in out
|
|
assert "***" in out
|
|
|
|
|
|
def test_redact_text_passthrough_without_secrets():
|
|
assert _redact_text("plain error", {}) == "plain error"
|
|
assert _redact_text(None, {}) is None
|
|
|
|
|
|
def test_to_dict_full_details_redacts_error_fields_and_parameters():
|
|
job = DownloadJob(
|
|
job_id="j1",
|
|
status=JobStatus.FAILED,
|
|
created_time=datetime(2026, 1, 1),
|
|
service="EXAMPLE",
|
|
title_id="t",
|
|
parameters={"credential": "user:hunter2"},
|
|
)
|
|
job.error_message = "login failed for user:hunter2"
|
|
job.worker_stderr = "Traceback ... user:hunter2 ..."
|
|
d = job.to_dict(include_full_details=True)
|
|
assert "hunter2" not in d["error_message"]
|
|
assert "hunter2" not in d["worker_stderr"]
|
|
assert d["parameters"]["credential"] == "***"
|
|
|
|
|
|
class _PastGate(Exception):
|
|
"""Raised by the stubbed download manager to prove a request got past the gate."""
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_handler(monkeypatch):
|
|
"""Make the service valid and make the first call after the gate (get_download_manager) explode, so a
|
|
forbidden request raises APIError and the handler returns an allowed one as an error response."""
|
|
from unshackle.core.api import download_manager
|
|
|
|
monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: tag)
|
|
monkeypatch.setattr(handlers.Services, "load", lambda tag: object())
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise _PastGate()
|
|
|
|
monkeypatch.setattr(download_manager, "get_download_manager", boom)
|
|
return monkeypatch
|
|
|
|
|
|
async def test_cdm_override_forbidden_by_default(stub_handler):
|
|
stub_handler.setattr(handlers.config, "serve", {})
|
|
with pytest.raises(APIError) as ei:
|
|
await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "dev"})
|
|
assert ei.value.error_code == APIErrorCode.FORBIDDEN
|
|
|
|
|
|
async def test_cdm_override_allowed_when_enabled(stub_handler):
|
|
stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": True})
|
|
# passing the gate reaches the stubbed Services.load, whose error is caught and returned as a response
|
|
resp = await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "dev"})
|
|
assert isinstance(resp, web.Response)
|
|
|
|
|
|
async def test_cdm_override_allowlist_permits_only_named_device(stub_handler):
|
|
stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]})
|
|
assert isinstance(
|
|
await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "good"}), web.Response
|
|
)
|
|
with pytest.raises(APIError) as ei:
|
|
await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "other"})
|
|
assert ei.value.error_code == APIErrorCode.FORBIDDEN
|
|
|
|
|
|
async def test_credential_forbidden_by_default(stub_handler):
|
|
stub_handler.setattr(handlers.config, "serve", {})
|
|
with pytest.raises(APIError) as ei:
|
|
await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "credential": "u:p"})
|
|
assert ei.value.error_code == APIErrorCode.FORBIDDEN
|
|
|
|
|
|
async def test_credential_allowed_when_enabled(stub_handler):
|
|
stub_handler.setattr(handlers.config, "serve", {"allow_job_credentials": True})
|
|
resp = await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "credential": "u:p"})
|
|
assert isinstance(resp, web.Response)
|
|
|
|
|
|
def test_range_validation_accepts_hdr10p_and_alias():
|
|
# canonical "HDR10P" and back-compat "HDR10+" both pass; mixed casing too
|
|
assert handlers.validate_download_parameters({"range": ["HDR10P", "DV", "SDR"]}) is None
|
|
assert handlers.validate_download_parameters({"range": ["hdr10+"]}) is None
|
|
assert handlers.validate_download_parameters({"range": "HYBRID"}) is None
|
|
|
|
|
|
def test_range_validation_rejects_unknown_and_lists_hdr10p():
|
|
err = handlers.validate_download_parameters({"range": ["HDR99"]})
|
|
assert err and "HDR10P" in err and "HDR99" in err
|