Files
SmartUp/backend/test_auth_capture.py
T
liumangmang 4f9acdc99c feat(auth-capture): full cookie bundle extraction + richer refresh-auth
Problem: Meow upstream uses Cloudflare, which sets cf_clearance + session
cookies that must all be sent together. The old code only captured a single
session-named cookie via a whitelist, discarding cf_clearance entirely, and
wrote back only 'name=value' instead of the full cookie string.

Changes:

auth_capture_service.py:
  - Add _cookie_matches_hostname(): hostname suffix matching supporting
    dot-prefixed domains (.saki.lat matches api.saki.lat)
  - Add _build_cookie_bundle(): collects ALL cookies matching the current
    page's hostname, returns complete 'name1=v1; name2=v2' string
  - _curate_candidates(): new 'cookie_bundle' candidate type (type=0 in sort,
    highest priority), carries cookie_count + cookie_names in extra fields
  - extract_all(): obtain real-time page URL from session.page.url and pass
    to _curate_candidates so cookie domain filtering is accurate
  - Sort order: cookie_bundle > cookie > bearer_token/api_key > credential
  - Fix bug in original JWT dedup check (was assigning instead of checking)

custom_pages.py:
  - Add logging import + logger
  - _pick_best_candidate(): cookie preferred_auth_type now tries cookie_bundle
    first, then single cookie; bearer/api_key use existing type_map logic
  - RefreshAuthResponse: add optional 'warning' field
  - refresh_auth(): handle ctype='cookie_bundle' same as 'cookie'; always
    write full candidate.value as cookie_string (works for both types)
  - Post-write validation: attempt get_available_groups with new credentials;
    on failure, still commit (lenient mode) but set warning message explaining
    cf_clearance IP-binding as the likely cause; success logs at INFO level

Tests (test_auth_capture.py, 19 cases):
  - _cookie_matches_hostname: exact, dot-prefix subdomain, empty domain,
    different domain, evil-subdomain partial match rejection
  - _build_cookie_bundle: cf_clearance included, cross-domain excluded,
    single cookie, empty value excluded, no cookies
  - _curate_candidates: bundle ranks first, value is full string, bundle
    beats single session cookie, bearer wins when no cookies, empty case,
    cookie_count/cookie_names in extra, session fallback preserved,
    new_api_user propagation to bundle

All 46 tests pass.
2026-06-02 09:32:23 +08:00

213 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
auth_capture_service 单元测试
覆盖场景:
- 完整 cookie bundle(含 cf_clearance、session
- domain 后缀匹配(.saki.lat → api.saki.lat
- 仅 1 个 cookie 也生成 bundle
- 无 cookies 时 bearer_token 排第一
- bundle 优先级高于单 session cookie
- 现有 bearer / api_key 提取路径不受影响
"""
from __future__ import annotations
import pytest
from app.services.auth_capture_service import (
_build_cookie_bundle,
_cookie_matches_hostname,
_curate_candidates,
)
# ── _cookie_matches_hostname ──────────────────────────────────────────────────
def test_exact_hostname_match():
assert _cookie_matches_hostname("saki.lat", "saki.lat")
def test_dot_prefix_subdomain_match():
"""`.saki.lat` 应该匹配 `api.saki.lat`。"""
assert _cookie_matches_hostname(".saki.lat", "api.saki.lat")
def test_dot_prefix_exact_match():
"""`saki.lat` 仍应匹配 `saki.lat`(无前缀点)。"""
assert _cookie_matches_hostname(".saki.lat", "saki.lat")
def test_no_domain_matches_all():
"""空 domain 视为不限制。"""
assert _cookie_matches_hostname("", "anything.example.com")
def test_different_domain_no_match():
assert not _cookie_matches_hostname(".example.com", "saki.lat")
def test_partial_suffix_no_match():
"""evil-saki.lat 不应匹配 .saki.lat。"""
assert not _cookie_matches_hostname(".saki.lat", "evil-saki.lat")
# ── _build_cookie_bundle ──────────────────────────────────────────────────────
def _make_cookies(*items):
"""构造 cookie 列表,items 是 (name, value, domain) 元组。"""
return [
{"name": n, "value": v, "domain": d, "httpOnly": False, "secure": True}
for n, v, d in items
]
def test_bundle_includes_cf_clearance():
cookies = _make_cookies(
("cf_clearance", "abc123", ".saki.lat"),
("session", "sess456", ".saki.lat"),
("csrfToken", "csrf789", ".saki.lat"),
)
cookie_str, names = _build_cookie_bundle(cookies, "https://api.saki.lat/login")
assert "cf_clearance=abc123" in cookie_str
assert "session=sess456" in cookie_str
assert "csrfToken=csrf789" in cookie_str
assert "cf_clearance" in names
assert "session" in names
def test_bundle_excludes_unrelated_domain():
cookies = _make_cookies(
("session", "mine", ".saki.lat"),
("other_session", "theirs", ".example.com"),
)
cookie_str, names = _build_cookie_bundle(cookies, "https://api.saki.lat/login")
assert "mine" in cookie_str
assert "theirs" not in cookie_str
assert "other_session" not in names
def test_single_cookie_generates_bundle():
cookies = _make_cookies(("session", "onlyme", ".saki.lat"))
cookie_str, names = _build_cookie_bundle(cookies, "https://saki.lat/login")
assert cookie_str == "session=onlyme"
assert names == ["session"]
def test_empty_value_cookie_excluded():
cookies = _make_cookies(
("session", "valid", ".saki.lat"),
("empty_cookie", "", ".saki.lat"),
)
cookie_str, names = _build_cookie_bundle(cookies, "https://saki.lat/")
assert "empty_cookie" not in names
assert "empty_cookie" not in cookie_str
def test_no_cookies_returns_empty_bundle():
cookie_str, names = _build_cookie_bundle([], "https://saki.lat/")
assert cookie_str == ""
assert names == []
# ── _curate_candidates ────────────────────────────────────────────────────────
def _base_candidates(cookies, page_url="", auth_headers=None):
return _curate_candidates(
cookies=cookies,
local_storage={},
session_storage={},
auth_headers=auth_headers or [],
new_api_user="",
page_url=page_url,
)
def test_cookie_bundle_ranks_first():
"""有 cookies 时,cookie_bundle 候选应排在第一。"""
cookies = _make_cookies(
("cf_clearance", "cf_val", ".saki.lat"),
("session", "sess_val", ".saki.lat"),
)
candidates = _base_candidates(cookies, page_url="https://api.saki.lat/login")
assert len(candidates) > 0
assert candidates[0]["type"] == "cookie_bundle"
def test_cookie_bundle_value_is_full_string():
"""cookie_bundle 的 value 应是完整 cookie 字符串。"""
cookies = _make_cookies(
("cf_clearance", "cf_val", ".saki.lat"),
("session", "sess_val", ".saki.lat"),
)
candidates = _base_candidates(cookies, page_url="https://api.saki.lat/")
bundle = next(c for c in candidates if c["type"] == "cookie_bundle")
assert "cf_clearance=cf_val" in bundle["value"]
assert "session=sess_val" in bundle["value"]
def test_bundle_prioritized_over_single_session_cookie():
"""cookie_bundle 候选应排在单 session cookie 候选之前。"""
cookies = _make_cookies(
("session", "sess_val", ".saki.lat"),
("cf_clearance", "cf_val", ".saki.lat"),
)
candidates = _base_candidates(cookies, page_url="https://api.saki.lat/")
types = [c["type"] for c in candidates]
bundle_idx = types.index("cookie_bundle")
cookie_idx = types.index("cookie") if "cookie" in types else len(types)
assert bundle_idx < cookie_idx
def test_bearer_token_wins_when_no_cookies():
"""无 cookies 时,bearer_token 应排在第一(如果有 auth_headers)。"""
auth_headers = [
{"type": "authorization", "value": "eyJ.abc.def", "url": "https://api.saki.lat/api/v1/groups"}
]
candidates = _base_candidates([], page_url="https://api.saki.lat/", auth_headers=auth_headers)
assert len(candidates) > 0
assert candidates[0]["type"] == "bearer_token"
def test_no_cookies_no_headers_returns_empty():
"""无 cookies、无 storage、无 headers → 候选列表为空。"""
candidates = _base_candidates([], page_url="https://api.saki.lat/")
assert candidates == []
def test_bundle_extra_contains_cookie_count_and_names():
"""cookie_bundle 候选的 extra 应包含 cookie_count 和 cookie_names。"""
cookies = _make_cookies(
("cf_clearance", "cf_val", ".saki.lat"),
("session", "sess_val", ".saki.lat"),
)
candidates = _base_candidates(cookies, page_url="https://api.saki.lat/")
bundle = next(c for c in candidates if c["type"] == "cookie_bundle")
assert bundle.get("cookie_count") == 2
assert set(bundle.get("cookie_names", [])) == {"cf_clearance", "session"}
def test_single_session_cookie_still_present_as_fallback():
"""session cookie 应同时出现在候选列表中(作为 fallback)。"""
cookies = _make_cookies(
("session", "sess_val", ".saki.lat"),
("cf_clearance", "cf_val", ".saki.lat"),
)
candidates = _base_candidates(cookies, page_url="https://api.saki.lat/")
types = [c["type"] for c in candidates]
assert "cookie_bundle" in types
assert "cookie" in types
def test_new_api_user_propagated_to_bundle():
"""new_api_user 应写入 cookie_bundle 的 extra 字段。"""
cookies = _make_cookies(("session", "s", ".saki.lat"))
candidates = _curate_candidates(
cookies=cookies,
local_storage={"uid": "42"},
session_storage={},
auth_headers=[],
new_api_user="42",
page_url="https://saki.lat/",
)
bundle = next(c for c in candidates if c["type"] == "cookie_bundle")
assert bundle.get("new_api_user") == "42"