Ctrl K
ComunidadGuías…

tests/test_downloads_pikpak.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""PikPak 云下载:假传输上的登录、刷新轮换、人机验证、离线提交与对账。

断言落在请求形状与凭据去向上:refresh token 每次刷新都写回,密码只在勾选时保存,
要人机验证时把验证页交出来而不是吞掉。
"""
from __future__ import annotations

import json
import unittest
from urllib.parse import parse_qs, urlsplit

import httpx

from peach import downloads as dl
from peach import downloads_pikpak as pk

HASH = "c9e15763f722f23e98a29decdfae341b98d53056"
MAGNET = dl.parse_magnet(f"magnet:?xt=urn:btih:{HASH}&dn=ABC-123")
TARGET = "/云下载"


class FakePikPak:
    def __init__(self):
        self.requests: list[httpx.Request] = []
        self.captcha_url = ""
        self.refresh_count = 0
        self.expired_once = False
        self.tasks: list[dict] = []
        self.signin_error = None

    def __call__(self, request: httpx.Request) -> httpx.Response:
        self.requests.append(request)
        url = urlsplit(str(request.url))
        body = json.loads(request.content) if request.content else {}
        route = (request.method, url.netloc, url.path)
        if route[2] == "/v1/shield/captcha/init":
            if self.captcha_url:
                return httpx.Response(200, json={"url": self.captcha_url})
            return httpx.Response(200, json={"captcha_token": f"ct-{body['action']}"})
        if route[2] == "/v1/auth/signin":
            if self.signin_error:
                return httpx.Response(400, json=self.signin_error)
            return httpx.Response(200, json=self._tokens())
        if route[2] == "/v1/auth/token":
            if body.get("refresh_token") != f"rt{self.refresh_count}":
                return httpx.Response(400, json={"error": "invalid_grant", "error_code": 4126})
            return httpx.Response(200, json=self._tokens())
        if self.expired_once:
            self.expired_once = False
            return httpx.Response(401, json={"error": "unauthenticated", "error_code": 16})
        if route == ("GET", "api-drive.mypikpak.com", "/drive/v1/files"):
            parent = parse_qs(url.query).get("parent_id", [""])[0]
            files = {"": [{"id": "f1", "name": "云下载", "kind": "drive#folder"}],
                     "f1": [{"id": "x", "name": "ABC-123", "kind": "drive#folder"}]}
            return httpx.Response(200, json={"files": files.get(parent, []), "next_page_token": ""})
        if route == ("POST", "api-drive.mypikpak.com", "/drive/v1/files"):
            task = {"id": "t1", "file_name": "ABC-123", "phase": "PHASE_TYPE_RUNNING",
                    "progress": 0, "params": {"url": body["url"]["url"]}}
            self.tasks.append(task)
            return httpx.Response(200, json={"task": task})
        if route == ("GET", "api-drive.mypikpak.com", "/drive/v1/tasks"):
            return httpx.Response(200, json={"tasks": self.tasks, "next_page_token": ""})
        if route == ("DELETE", "api-drive.mypikpak.com", "/drive/v1/tasks"):
            ids = parse_qs(url.query)["task_ids"]
            self.tasks = [task for task in self.tasks if task["id"] not in ids]
            return httpx.Response(200, json={})
        return httpx.Response(404, json={"error": "not_found"})

    def _tokens(self):
        self.refresh_count += 1
        return {"access_token": f"at{self.refresh_count}", "refresh_token": f"rt{self.refresh_count}",
                "expires_in": 7200, "sub": "user-1"}

    def paths(self) -> list[str]:
        return [f"{request.method} {request.url.path}" for request in self.requests]


class _Case(unittest.TestCase):
    def setUp(self):
        self.fake = FakePikPak()
        self.transport = httpx.MockTransport(self.fake)
        self.stored: dict = {}

    def persist(self, values):
        self.stored = dict(values)

    def login(self, remember=False):
        return pk.login(lambda: self.stored, "me@example.com", "pw", remember=remember,
                        persist=self.persist, transport=self.transport)

    def provider(self):
        return pk.PikPakProvider(lambda: dict(self.stored), persist=self.persist,
                                 transport=self.transport)


class LoginTests(_Case):
    def test_signing_in_keeps_tokens_and_drops_the_password_unless_asked(self):
        self.assertEqual(self.login(), {"username": "me@example.com", "remember": False})
        self.assertEqual(self.stored["refresh_token"], "rt1")
        self.assertNotIn("password", self.stored)
        self.assertEqual(len(self.stored["device_id"]), 32)
        signin = json.loads(self.fake.requests[1].content)
        self.assertEqual((signin["username"], signin["captcha_token"]),
                         ("me@example.com", "ct-POST:https://user.mypikpak.com/v1/auth/signin"))
        self.login(remember=True)
        self.assertEqual(self.stored["password"], "pw")
        self.assertEqual(pk.account(self.stored),
                         {"logged_in": True, "username": "me@example.com", "remember": True,
                          "method": "password"})

    def test_a_captcha_hands_back_the_page_and_keeps_the_device(self):
        self.fake.captcha_url = "https://user.mypikpak.com/captcha?x=1"
        with self.assertRaises(pk.CaptchaRequired) as caught:
            self.login()
        self.assertEqual(caught.exception.url, self.fake.captcha_url)
        device = self.stored["device_id"]
        self.assertNotIn("refresh_token", self.stored)
        self.fake.captcha_url = ""
        self.login()
        self.assertEqual(self.stored["device_id"], device)

    def test_a_wrong_password_is_a_configuration_failure(self):
        self.fake.signin_error = {"error": "invalid_account_or_password", "error_code": 4022,
                                  "error_description": "账号或密码错误"}
        with self.assertRaises(dl.DownloadError) as caught:
            self.login()
        self.assertEqual(caught.exception.failure, "config")
        self.assertIn("登录失败:账号或密码错误", caught.exception.detail)

    def test_the_captcha_signature_matches_the_reference_algorithm(self):
        self.assertEqual(pk.captcha_sign("abc", "123"), "1.3b6c3d5e08b41cc77d291c082ba26e7d")


class DriveTests(_Case):
    def setUp(self):
        super().setUp()
        self.login()

    def task(self, **overrides) -> dl.DownloadTask:
        return dl.DownloadTask(1, HASH, "pikpak", MAGNET.uri, "ABC-123", TARGET, dl.SUBMITTED,
                               **overrides)

    def test_a_submission_resolves_the_folder_and_carries_a_captcha_token(self):
        status = self.provider().submit(MAGNET, TARGET)
        self.assertEqual((status.state, status.remote_id, status.adopted), (dl.RUNNING, "t1", False))
        post = [r for r in self.fake.requests if r.method == "POST" and r.url.path == "/drive/v1/files"][0]
        self.assertEqual(post.headers["X-Captcha-Token"], "ct-POST:/drive/v1/files")
        body = json.loads(post.content)
        self.assertEqual((body["parent_id"], body["url"]["url"], body["upload_type"]),
                         ("f1", MAGNET.uri, "UPLOAD_TYPE_URL"))

    def test_the_same_magnet_is_adopted_from_the_task_list(self):
        self.provider().submit(MAGNET, TARGET)
        status = self.provider().submit(MAGNET, TARGET)
        self.assertTrue(status.adopted)
        self.assertEqual(len(self.fake.tasks), 1)

    def test_a_missing_folder_is_reported(self):
        with self.assertRaises(dl.DownloadError) as caught:
            self.provider().submit(MAGNET, "/没有")
        self.assertEqual(caught.exception.failure, "config")

    def test_an_expired_access_token_is_refreshed_and_the_new_refresh_token_saved(self):
        self.fake.expired_once = True
        self.assertEqual(self.provider().status(self.task()).state, dl.MISSING)
        self.assertEqual(self.stored["refresh_token"], "rt2")
        self.assertIn("POST /v1/auth/token", self.fake.paths())

    def test_a_dead_refresh_token_without_a_saved_password_asks_to_sign_in_again(self):
        self.stored["refresh_token"] = "stale"
        self.stored["expires_at"] = "0"
        with self.assertRaises(dl.DownloadError) as caught:
            self.provider().status(self.task())
        self.assertEqual(caught.exception.failure, "config")
        self.assertIn("重新登录", caught.exception.detail)

    def test_a_dead_refresh_token_with_a_saved_password_signs_in_again(self):
        self.login(remember=True)
        self.stored["refresh_token"] = "stale"
        self.stored["expires_at"] = "0"
        self.provider().status(self.task())
        self.assertTrue(self.stored["refresh_token"].startswith("rt"))
        self.assertEqual(self.stored["password"], "pw")

    def test_phases_map_onto_remote_states(self):
        self.provider().submit(MAGNET, TARGET)
        self.fake.tasks[0].update(phase="PHASE_TYPE_ERROR", message="no seeds")
        status = self.provider().status(self.task(remote_id="t1"))
        self.assertEqual((status.state, status.message), (dl.ERROR, "no seeds"))
        self.fake.tasks[0].update(phase="PHASE_TYPE_COMPLETE")
        self.assertEqual(self.provider().status(self.task()).state, dl.DONE)
        self.fake.tasks[0].update(phase="SOMETHING_NEW")
        with self.assertRaises(dl.DownloadError) as caught:
            self.provider().status(self.task())
        self.assertIn("接口可能已经变了", caught.exception.detail)

    def test_landed_lists_the_target_folder_and_cancel_keeps_files(self):
        self.assertTrue(self.provider().landed(self.task()))
        self.provider().submit(MAGNET, TARGET)
        self.provider().cancel(self.task(remote_id="t1"))
        delete = [r for r in self.fake.requests if r.method == "DELETE"][0]
        self.assertEqual(parse_qs(delete.url.query.decode())["delete_files"], ["false"])
        self.assertEqual(self.fake.tasks, [])

    def test_without_a_login_nothing_is_sent(self):
        self.stored = {}
        sent = len(self.fake.requests)
        with self.assertRaises(dl.DownloadError):
            self.provider().submit(MAGNET, TARGET)
        self.assertEqual(len(self.fake.requests), sent)


class WebClientTests(_Case):
    """浏览器登录读到的 token 由网页端 client 签发:刷新、captcha/init 与签名都按网页端常量走。"""

    def setUp(self):
        super().setUp()
        self.fake.refresh_count = 1
        self.stored = {"refresh_token": "rt1", "access_token": "at1", "expires_at": "0", "user_id": "user-1",
                       "device_id": "webdevice", "client_id": pk.WEB.client_id}

    def task(self) -> dl.DownloadTask:
        return dl.DownloadTask(1, HASH, "pikpak", MAGNET.uri, "ABC-123", TARGET, dl.SUBMITTED)

    def bodies(self, path: str) -> list[dict]:
        return [json.loads(r.content) for r in self.fake.requests if r.url.path == path]

    def test_the_web_signature_matches_the_page_script(self):
        """期望值用网页脚本里那段 `reduce` 的写法在 Node 里算出:md5 链、固定时间戳、网页端 15 条盐。"""
        self.assertEqual(pk.captcha_sign("abc", pk.WEB.sign_timestamp, pk.WEB),
                         "1.e07351bd60bb92ce14b49dcf7273008f")

    def test_web_tokens_refresh_with_the_web_client_and_no_secret(self):
        self.assertEqual(self.provider().status(self.task()).state, dl.MISSING)
        refresh = [r for r in self.fake.requests if r.url.path == "/v1/auth/token"][0]
        body = json.loads(refresh.content)
        self.assertEqual(body["client_id"], "YUMx5nI8ZU8Ap8pm")
        self.assertNotIn("client_secret", body)
        self.assertEqual((refresh.headers["x-client-id"], refresh.headers["x-device-id"]),
                         ("YUMx5nI8ZU8Ap8pm", "webdevice"))
        self.assertEqual((self.stored["refresh_token"], self.stored["client_id"]), ("rt2", pk.WEB.client_id))

    def test_a_web_submission_signs_the_captcha_with_web_constants(self):
        self.stored["expires_at"] = "9999999999"
        self.provider().submit(MAGNET, TARGET)
        captcha = self.bodies("/v1/shield/captcha/init")[0]
        self.assertEqual(captcha["client_id"], pk.WEB.client_id)
        self.assertEqual(captcha["meta"]["timestamp"], "1790733736477")
        self.assertEqual((captcha["meta"]["client_version"], captcha["meta"]["package_name"]),
                         ("2.0.0", "mypikpak.com"))
        self.assertEqual(captcha["meta"]["captcha_sign"], pk.captcha_sign("webdevice", "1790733736477", pk.WEB))

    def test_a_rejected_web_refresh_names_the_reason_and_asks_for_the_browser(self):
        self.stored["refresh_token"] = "stale"
        with self.assertRaises(dl.DownloadError) as caught:
            self.provider().status(self.task())
        self.assertEqual(caught.exception.failure, "config")
        self.assertIn("用浏览器登录", caught.exception.detail)
        self.assertIn("invalid_grant", caught.exception.detail)
        self.assertNotIn("stale", caught.exception.detail)

    def test_a_rejected_web_signature_says_the_constants_may_have_changed(self):
        self.stored["expires_at"] = "9999999999"

        def handler(request: httpx.Request) -> httpx.Response:
            if request.url.path == "/v1/shield/captcha/init":
                return httpx.Response(400, json={"error": "invalid_argument", "error_description": "captcha_sign"})
            return self.fake(request)
        provider = pk.PikPakProvider(lambda: dict(self.stored), persist=self.persist,
                                     transport=httpx.MockTransport(handler))
        with self.assertRaises(dl.DownloadError) as caught:
            provider.submit(MAGNET, TARGET)
        self.assertIn("网页端的签名常量可能已经更新", caught.exception.detail)

    def test_an_unknown_client_refreshes_but_refuses_to_sign(self):
        self.stored.update(client_id="SomeNewClient", expires_at="9999999999")
        with self.assertRaises(dl.DownloadError) as caught:
            self.provider().submit(MAGNET, TARGET)
        self.assertIn("SomeNewClient", caught.exception.detail)
        self.assertNotIn("/v1/shield/captcha/init", [r.url.path for r in self.fake.requests])
        self.stored["expires_at"] = "0"
        self.provider().status(self.task())
        self.assertEqual(self.bodies("/v1/auth/token")[0]["client_id"], "SomeNewClient")

    def test_the_account_says_how_it_was_signed_in(self):
        self.assertEqual(pk.account(self.stored)["method"], "browser")
        self.assertEqual(pk.account({"refresh_token": "x"})["method"], "password")
        self.login()
        self.assertEqual(self.stored["client_id"], pk.ANDROID.client_id)
        self.assertEqual(pk.account(self.stored)["method"], "password")


if __name__ == "__main__":
    unittest.main()