2524 lines
96 KiB
Python
2524 lines
96 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import socket
|
|
import threading
|
|
import unittest
|
|
from collections import deque
|
|
from contextlib import contextmanager
|
|
from importlib import import_module
|
|
from typing import Any, cast
|
|
from unittest.mock import Mock, patch
|
|
|
|
import websocket
|
|
|
|
from .docker_client import (
|
|
BINDING_VERSION_LABEL,
|
|
BROWSER_NETWORK_ROLE,
|
|
GATEWAY_MEMBER_LABEL,
|
|
MANAGED_LABEL,
|
|
NETWORK_EXIT_LABEL,
|
|
NETWORK_ID_LABEL,
|
|
NETWORK_ROLE_LABEL,
|
|
RESERVATION_LABEL,
|
|
RESERVATION_OWNER_LABEL,
|
|
RUNTIME_ID_LABEL,
|
|
AliasReservationManager,
|
|
DockerClient,
|
|
DockerError,
|
|
DockerResponse,
|
|
GenerationConflict,
|
|
NetworkSetupError,
|
|
TenantNetworkGeneration,
|
|
UnmanagedContainer,
|
|
split_image_ref,
|
|
tenant_network_name,
|
|
)
|
|
from .douyin import (
|
|
BrowserResponse,
|
|
CDPConnection,
|
|
DouyinBrowser,
|
|
DouyinError,
|
|
DouyinSubscription,
|
|
SubscriptionManager,
|
|
ack_expression,
|
|
action_expression,
|
|
detect_challenge,
|
|
im_expression,
|
|
install_expression,
|
|
is_douyin_url,
|
|
normalize_notice,
|
|
notice_ids,
|
|
wait_expression,
|
|
)
|
|
from .proxy import (
|
|
MemoryProxy,
|
|
ProxyExit,
|
|
ProxyRegistry,
|
|
_copy_until_close,
|
|
_dial_http_proxy,
|
|
_dial_socks4,
|
|
_dial_socks5,
|
|
_is_ipv4,
|
|
_parse_request,
|
|
_read_request,
|
|
_read_status,
|
|
_recv_exact,
|
|
)
|
|
|
|
gateway_module = import_module(f"{__package__}.gateway")
|
|
douyin_module = import_module(f"{__package__}.douyin")
|
|
proxy_module = import_module(f"{__package__}.proxy")
|
|
docker_client_module = import_module(f"{__package__}.docker_client")
|
|
Gateway = gateway_module.Gateway
|
|
RequestError = gateway_module.RequestError
|
|
browser_tmpfs = gateway_module.browser_tmpfs
|
|
decode_generation = gateway_module.decode_generation
|
|
json_bytes = gateway_module.json_bytes
|
|
load_config = gateway_module.load_config
|
|
split_listen_address = gateway_module.split_listen_address
|
|
valid_douyin_url = gateway_module.valid_douyin_url
|
|
validate_create = gateway_module.validate_create
|
|
parse_proxy_exit = gateway_module.parse_proxy_exit
|
|
validate_proxy_exit = gateway_module.validate_proxy_exit
|
|
validate_proxy_restore = gateway_module.validate_proxy_restore
|
|
valid_douyin_generation = gateway_module.valid_douyin_generation
|
|
valid_account_key_query = gateway_module.valid_account_key_query
|
|
numeric_cursor = gateway_module.numeric_cursor
|
|
proxy_port = gateway_module.proxy_port
|
|
has_control = gateway_module.has_control
|
|
|
|
|
|
class FakeSocket:
|
|
def __init__(self, messages: list[object]) -> None:
|
|
self.messages = list(messages)
|
|
self.sent: list[bytes] = []
|
|
self.timeout = 0.0
|
|
|
|
def send(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def recv(self) -> str:
|
|
if not self.messages:
|
|
raise TimeoutError("no more messages")
|
|
return json.dumps(self.messages.pop(0))
|
|
|
|
def settimeout(self, value: float) -> None:
|
|
self.timeout = value
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
|
|
class FakeConnection:
|
|
def __init__(self, values: list[object]) -> None:
|
|
self.values = list(values)
|
|
|
|
def evaluate(self, expression: str) -> object:
|
|
del expression
|
|
if not self.values:
|
|
raise DouyinError("no fake response")
|
|
return self.values.pop(0)
|
|
|
|
|
|
class FakeDocker:
|
|
def __init__(self, containers: list[dict] | None = None) -> None:
|
|
self.containers = containers or []
|
|
|
|
def container_network_address(self, container_id: str, network_id: str) -> str:
|
|
del container_id, network_id
|
|
return "192.0.2.10"
|
|
|
|
def request(
|
|
self, method: str, path: str, body: object | None = None
|
|
) -> DockerResponse:
|
|
del body
|
|
if method == "GET" and path.startswith("/containers/json"):
|
|
return DockerResponse(200, "OK", json.dumps(self.containers).encode())
|
|
return DockerResponse(404, "Not Found", b"")
|
|
|
|
|
|
class GatewayValidationTests(unittest.TestCase):
|
|
def test_create_and_generation_validation(self) -> None:
|
|
value = {
|
|
"alias": "safe-account",
|
|
"name": "Safe account",
|
|
"image": "creatorhub/browser:latest",
|
|
"cmd": ["about:blank"],
|
|
"volume": "creatorhub-safe-account",
|
|
"binding_version": 1,
|
|
"stopped": True,
|
|
}
|
|
validate_create(value)
|
|
self.assertEqual(value["network_exit"], ProxyExit("", "", 0))
|
|
self.assertEqual(value["network_exit_id"], "")
|
|
self.assertEqual(
|
|
decode_generation(
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "runtime-not-found",
|
|
"network_id": "network-id",
|
|
},
|
|
False,
|
|
False,
|
|
)["runtime_id"],
|
|
"runtime-not-found",
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_create(
|
|
{**value, "cmd": ["--proxy-server=http://x", "about:blank"]}
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
decode_generation(
|
|
{"binding_version": 1, "runtime_id": 4, "network_id": "n"}, True, True
|
|
)
|
|
|
|
def test_config_and_urls(self) -> None:
|
|
self.assertEqual(split_listen_address(":8081"), ("", 8081))
|
|
self.assertEqual(split_listen_address("[::1]:8081"), ("::1", 8081))
|
|
with self.assertRaises(ValueError):
|
|
split_listen_address("missing-port")
|
|
config = load_config(
|
|
{
|
|
"LISTEN_ADDR": ":8081",
|
|
"DOCKER_SOCKET": "/var/run/docker.sock",
|
|
"BROWSER_NETWORK": "creatorhub_browser",
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
}
|
|
)
|
|
self.assertEqual(config["listen"], ("", 8081))
|
|
external = load_config(
|
|
{
|
|
"LISTEN_ADDR": ":8081",
|
|
"DOCKER_SOCKET": "/var/run/docker.sock",
|
|
"BROWSER_NETWORK": "creatorhub_browser",
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
"BROWSER_CDP_URL": "http://127.0.0.1:9222",
|
|
"BROWSER_CDP_TARGET_ID": "target_1",
|
|
}
|
|
)
|
|
self.assertEqual(external["external_cdp"]["url"], "http://127.0.0.1:9222")
|
|
self.assertEqual(external["external_cdp"]["target_id"], "target_1")
|
|
with self.assertRaises(ValueError):
|
|
load_config(
|
|
{
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
"BROWSER_CDP_URL": "http://user:pass@127.0.0.1:9222",
|
|
}
|
|
)
|
|
tmp_mount = next(path for path in browser_tmpfs() if path.endswith("tmp"))
|
|
self.assertTrue(browser_tmpfs()[tmp_mount].startswith("rw,"))
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=2328120603967913"
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&sec_user_id=MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK"
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=1&count=20"
|
|
)
|
|
)
|
|
self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/"))
|
|
self.assertTrue(is_douyin_url("https://www.douyin.com/video/123"))
|
|
self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123"))
|
|
|
|
def test_external_cdp_is_virtual_and_generation_bound(self) -> None:
|
|
external = {
|
|
"url": "http://127.0.0.1:9222",
|
|
"target_id": "target_1",
|
|
"alias": "local-cdp",
|
|
"runtime_id": "0" * 64,
|
|
"network_id": "local-cdp",
|
|
"binding_version": 1,
|
|
}
|
|
gateway = Gateway(
|
|
cast(DockerClient, FakeDocker([])),
|
|
"creatorhub_browser",
|
|
"0123456789abcdef",
|
|
"gateway",
|
|
external_cdp=external,
|
|
)
|
|
self.assertEqual(gateway.list_browsers()[0]["state"], "external")
|
|
generation = {
|
|
"binding_version": 1,
|
|
"runtime_id": "0" * 64,
|
|
"network_id": "local-cdp",
|
|
}
|
|
gateway._require_douyin_generation("local-cdp", generation)
|
|
with self.assertRaises(RequestError):
|
|
gateway._require_douyin_generation(
|
|
"local-cdp", {**generation, "binding_version": 2}
|
|
)
|
|
|
|
def test_http_routes_and_body_validation(self) -> None:
|
|
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
|
|
gateway = Mock()
|
|
gateway.list_browsers.return_value = []
|
|
gateway.douyin_identity.return_value = {"uid": "123"}
|
|
gateway.douyin_login_qr.return_value = {
|
|
"content_type": "image/png",
|
|
"body_base64": "cG5n",
|
|
"qr_detected": True,
|
|
}
|
|
gateway.douyin_action.return_value = {"status": "succeeded"}
|
|
gateway.douyin_message_history.return_value = {"status": "succeeded"}
|
|
gateway.poll_douyin_events.return_value = []
|
|
server = Mock()
|
|
server.gateway = gateway
|
|
cast(Any, handler).server = server
|
|
cast(Any, handler).server_as_gateway = lambda: server
|
|
self.assertEqual(handler._route("GET", "/v1/browsers", {}, {}), [])
|
|
self.assertEqual(
|
|
handler._route("POST", "/v1/browsers", {}, {}),
|
|
(201, gateway.create.return_value),
|
|
)
|
|
handler._route(
|
|
"DELETE",
|
|
"/v1/browsers/safe",
|
|
{},
|
|
{"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""},
|
|
)
|
|
gateway.remove.assert_called_once_with(
|
|
"safe", {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""}
|
|
)
|
|
handler._route("POST", "/v1/browsers/safe/start", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/stop", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/proxy", {}, {})
|
|
with self.assertRaises(RequestError):
|
|
handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {})
|
|
self.assertEqual(
|
|
handler._route("POST", "/v1/browsers/safe/douyin/login-qr", {}, {}),
|
|
gateway.douyin_login_qr.return_value,
|
|
)
|
|
gateway.douyin_login_qr.assert_called_once_with("safe", {})
|
|
handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {})
|
|
self.assertEqual(
|
|
handler._route("POST", "/v1/browsers/safe/douyin/messages", {}, {}),
|
|
{"status": "succeeded"},
|
|
)
|
|
gateway.douyin_message_history.assert_called_once_with("safe", {})
|
|
self.assertEqual(
|
|
handler._route("GET", "/v1/browsers/safe/douyin/events", {}, {}), []
|
|
)
|
|
handler._route("POST", "/v1/browsers/safe/douyin/events", {}, {})
|
|
handler._route("DELETE", "/v1/browsers/safe/douyin/events", {}, {})
|
|
with self.assertRaises(RequestError):
|
|
handler._route("GET", "/v1/unknown", {}, {})
|
|
cast(Any, handler).headers = {"Content-Length": "7"}
|
|
cast(Any, handler).rfile = io.BytesIO(b'{"x":1}')
|
|
self.assertEqual(handler._body(), {"x": 1})
|
|
cast(Any, handler).headers = {}
|
|
with self.assertRaises(RequestError):
|
|
handler._body()
|
|
test_token = "x" * 16
|
|
cast(Any, handler).headers = {"Authorization": f"Bearer {test_token}"}
|
|
server.gateway.token = test_token
|
|
self.assertTrue(handler._authorized())
|
|
handler._respond = Mock()
|
|
handler._handle_exception("/v1", RequestError("bad", 400))
|
|
handler._handle_exception("/v1", ValueError("bad"))
|
|
self.assertEqual(handler._respond.call_count, 2)
|
|
|
|
def test_get_event_route_reads_generation_body(self) -> None:
|
|
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
|
|
cast(Any, handler).path = "/v1/browsers/safe/douyin/events?wait=1"
|
|
cast(Any, handler).headers = {"Content-Length": "67"}
|
|
cast(Any, handler).rfile = io.BytesIO(
|
|
b'{"binding_version":1,"runtime_id":"runtime","network_id":"network"}'
|
|
)
|
|
cast(Any, handler)._authorized = lambda: True
|
|
cast(Any, handler)._route = Mock(return_value=[])
|
|
cast(Any, handler)._respond = Mock()
|
|
handler._dispatch("GET")
|
|
cast(Any, handler)._route.assert_called_once_with(
|
|
"GET",
|
|
"/v1/browsers/safe/douyin/events",
|
|
{"wait": ["1"]},
|
|
{"binding_version": 1, "runtime_id": "runtime", "network_id": "network"},
|
|
)
|
|
|
|
def test_validation_boundaries(self) -> None:
|
|
self.assertEqual(proxy_port("http://docker-gateway:1234"), 1234)
|
|
self.assertTrue(has_control("bad\nvalue"))
|
|
exit_value = parse_proxy_exit(
|
|
{"protocol": "http", "host": "proxy", "port": 8080}
|
|
)
|
|
validate_proxy_exit(exit_value)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(
|
|
parse_proxy_exit({"protocol": "ftp", "host": "proxy", "port": 21})
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(ProxyExit("http", "proxy", 0))
|
|
generation = {
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "b" * 64,
|
|
"network_exit_id": "exit",
|
|
}
|
|
self.assertTrue(valid_douyin_generation(generation))
|
|
self.assertFalse(
|
|
valid_douyin_generation({**generation, "binding_version": True})
|
|
)
|
|
self.assertTrue(valid_account_key_query({"account": ["account"]}, "account"))
|
|
self.assertFalse(valid_account_key_query({"account": ["bad key"]}, "account"))
|
|
self.assertTrue(numeric_cursor(["0"]))
|
|
self.assertFalse(numeric_cursor(["-1"]))
|
|
restore = {
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "b" * 64,
|
|
"network_exit_id": "exit",
|
|
"network_exit": {"protocol": "http", "host": "proxy", "port": 8080},
|
|
}
|
|
validate_proxy_restore(restore, "safe")
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_restore({**restore, "network_exit_id": ""}, "safe")
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_restore({"network_exit_id": ""}, "safe")
|
|
with self.assertRaises(RequestError):
|
|
validate_create({"alias": "safe", "unknown": True})
|
|
with self.assertRaises(RequestError):
|
|
validate_create(
|
|
{
|
|
"alias": "safe",
|
|
"name": "Safe",
|
|
"image": "bad image",
|
|
"volume": "safe",
|
|
"binding_version": 1,
|
|
"cmd": ["about:blank"],
|
|
}
|
|
)
|
|
with self.assertRaises(ValueError):
|
|
load_config({"GATEWAY_TOKEN": "short"})
|
|
self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1"))
|
|
self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown"))
|
|
with self.assertRaises(RequestError):
|
|
decode_generation(
|
|
{"binding_version": 1, "runtime_id": "a" * 64}, True, True
|
|
)
|
|
|
|
def test_list_keeps_other_environments_when_one_network_is_missing(self) -> None:
|
|
class PartialDocker(FakeDocker):
|
|
def container_network_address(
|
|
self, container_id: str, network_id: str
|
|
) -> str:
|
|
if container_id == "broken":
|
|
raise GenerationConflict("network attachment disappeared")
|
|
return "192.0.2.10"
|
|
|
|
containers = [
|
|
{
|
|
"Id": "broken",
|
|
"Labels": {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "broken-account",
|
|
BINDING_VERSION_LABEL: "1",
|
|
NETWORK_EXIT_LABEL: "",
|
|
NETWORK_ID_LABEL: "b" * 64,
|
|
},
|
|
"State": "running",
|
|
"Status": "Up",
|
|
},
|
|
{
|
|
"Id": "healthy",
|
|
"Labels": {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "healthy-account",
|
|
BINDING_VERSION_LABEL: "1",
|
|
NETWORK_EXIT_LABEL: "",
|
|
NETWORK_ID_LABEL: "c" * 64,
|
|
},
|
|
"State": "running",
|
|
"Status": "Up",
|
|
},
|
|
]
|
|
result = Gateway(
|
|
cast(DockerClient, PartialDocker(containers)),
|
|
"creatorhub_browser",
|
|
"0123456789abcdef",
|
|
"gateway",
|
|
).list_browsers()
|
|
self.assertEqual(
|
|
[item["alias"] for item in result], ["broken-account", "healthy-account"]
|
|
)
|
|
self.assertEqual(result[0]["endpoint"], "")
|
|
self.assertIn("error", result[0])
|
|
self.assertEqual(result[1]["endpoint"], "http://192.0.2.10:9222")
|
|
|
|
def test_container_network_address(self) -> None:
|
|
docker = DockerClient("/var/run/docker.sock")
|
|
cast(Any, docker).request = lambda method, path: DockerResponse(
|
|
200,
|
|
"OK",
|
|
json.dumps(
|
|
{
|
|
"NetworkSettings": {
|
|
"Networks": {
|
|
"tenant": {
|
|
"NetworkID": "network",
|
|
"IPAddress": "192.0.2.20",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
).encode(),
|
|
)
|
|
self.assertEqual(
|
|
docker.container_network_address("container", "network"), "192.0.2.20"
|
|
)
|
|
cast(Any, docker).request = lambda method, path: DockerResponse(
|
|
200, "OK", b'{"NetworkSettings":{"Networks":{}}}'
|
|
)
|
|
with self.assertRaises(GenerationConflict):
|
|
docker.container_network_address("container", "network")
|
|
|
|
def test_list_browsers_and_json(self) -> None:
|
|
docker = FakeDocker(
|
|
[
|
|
{
|
|
"Id": "container-id",
|
|
"Labels": {
|
|
"io.creatorhub.managed": "true",
|
|
"io.creatorhub.runtime-id": "safe-account",
|
|
"io.creatorhub.binding-version": "2",
|
|
"io.creatorhub.network-exit-id": "",
|
|
"io.creatorhub.network-id": "b" * 64,
|
|
},
|
|
"State": "running",
|
|
"Status": "Up 1 second",
|
|
}
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker),
|
|
"creatorhub_browser",
|
|
"0123456789abcdef",
|
|
"gateway",
|
|
)
|
|
self.assertEqual(gateway.list_browsers()[0]["id"], "container-id")
|
|
self.assertEqual(
|
|
gateway.list_browsers()[0]["endpoint"], "http://192.0.2.10:9222"
|
|
)
|
|
self.assertEqual(
|
|
json_bytes({"text": "中文"}), b'{"text":"\xe4\xb8\xad\xe6\x96\x87"}'
|
|
)
|
|
|
|
|
|
class CDPTests(unittest.TestCase):
|
|
def test_connect_response_headers_are_drained(self) -> None:
|
|
connection = ChunkSocket(
|
|
[b"HTTP/1.1 200 Connection Established\r\n", b"X-Proxy: value\r\n\r\nTLS"]
|
|
)
|
|
self.assertEqual(_read_status(cast(socket.socket, connection)), 200)
|
|
|
|
def test_command_queues_events_for_wait_event(self) -> None:
|
|
socket_ = FakeSocket(
|
|
[
|
|
{
|
|
"method": "Page.frameNavigated",
|
|
"params": {"frame": {"id": "frame-1"}},
|
|
},
|
|
{"id": 1, "result": {}},
|
|
]
|
|
)
|
|
connection = CDPConnection(cast(websocket.WebSocket, socket_))
|
|
self.assertEqual(connection.command("Page.enable"), {})
|
|
event = connection.wait_event(
|
|
"Page.frameNavigated", lambda params: params["frame"]["id"] == "frame-1"
|
|
)
|
|
self.assertEqual(event["method"], "Page.frameNavigated")
|
|
self.assertTrue(socket_.sent)
|
|
|
|
def test_eof_status_is_a_failure(self) -> None:
|
|
class Closed:
|
|
def recv(self, size: int) -> bytes:
|
|
del size
|
|
return b""
|
|
|
|
with self.assertRaises(OSError):
|
|
_read_status(cast(socket.socket, Closed()))
|
|
|
|
def test_expression_markers_and_challenge(self) -> None:
|
|
expression = im_expression({"text": "hello EXPECTED_UID_VALUE"}, "123")
|
|
self.assertIn("hello EXPECTED_UID_VALUE", expression)
|
|
self.assertIn(
|
|
"https://www.douyin.com", action_expression({"action": "like_work"})
|
|
)
|
|
self.assertEqual(detect_challenge(429, "captcha"), "")
|
|
self.assertEqual(detect_challenge(412, ""), "captcha")
|
|
|
|
def test_notification_details_normalize_safe_targets(self) -> None:
|
|
notice = {
|
|
"nid_str": "9007199254740993",
|
|
"user_id": "99491952055",
|
|
"create_time": 1700000000,
|
|
"aweme_id": "123456",
|
|
"comment": {
|
|
"from_user": [{"uid": "7654321"}],
|
|
"comment": {"cid_str": "987654", "user": {"uid": "7654321"}},
|
|
},
|
|
}
|
|
self.assertEqual(
|
|
normalize_notice(notice),
|
|
{
|
|
"event_key": "9007199254740993",
|
|
"event_type": "comment",
|
|
"interactor_uid": "7654321",
|
|
"comment_id": "987654",
|
|
"work_id": "123456",
|
|
"platform_event_at": "2023-11-14T22:13:20+00:00",
|
|
},
|
|
)
|
|
favorite = {"nid_str": "7", "favorite": {"from_user": [{"uid": "1"}]}}
|
|
self.assertIsNone(normalize_notice(favorite))
|
|
|
|
def test_notification_detail_retries_partial_response(self) -> None:
|
|
details = [
|
|
{"nid_str": "1", "user_id": "123"},
|
|
{"nid_str": "2", "user_id": "123"},
|
|
]
|
|
payload = json.dumps({"status_code": 0, "notice_list_v2": details})
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.connection = cast(
|
|
CDPConnection,
|
|
FakeConnection(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{"status_code": 0, "notice_list_v2": details[:1]}
|
|
),
|
|
}
|
|
),
|
|
json.dumps({"status": 200, "body": payload}),
|
|
]
|
|
),
|
|
)
|
|
subscription._get_connection = lambda: subscription.connection
|
|
with patch.object(douyin_module.time, "sleep"):
|
|
self.assertEqual(len(subscription._details(["1", "2"])), 2)
|
|
|
|
def test_notification_detail_rejects_unexpected_id(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.connection = cast(
|
|
CDPConnection,
|
|
FakeConnection(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"notice_list_v2": [
|
|
{"nid_str": "9", "user_id": "123"}
|
|
],
|
|
}
|
|
),
|
|
}
|
|
)
|
|
]
|
|
),
|
|
)
|
|
subscription._get_connection = lambda: subscription.connection
|
|
with self.assertRaises(DouyinError):
|
|
subscription._details(["1"])
|
|
|
|
|
|
class BrowserCDP:
|
|
def __init__(self, values: list[object]) -> None:
|
|
self.values = list(values)
|
|
self.commands: list[tuple[str, dict | None]] = []
|
|
self.events: list[str] = []
|
|
self.expressions: list[str] = []
|
|
self.closed = False
|
|
|
|
def command(self, method: str, params: dict | None = None) -> dict:
|
|
self.commands.append((method, params))
|
|
if method == "Page.navigate":
|
|
return {"frameId": "frame-1"}
|
|
return {}
|
|
|
|
def wait_event(self, method: str, predicate: object, timeout: float = 15.0) -> dict:
|
|
del predicate, timeout
|
|
self.events.append(method)
|
|
return {"method": method}
|
|
|
|
def evaluate(self, expression: str) -> object:
|
|
self.expressions.append(expression)
|
|
if not self.values:
|
|
raise DouyinError("fake CDP value exhausted")
|
|
return self.values.pop(0)
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class FakeHTTPResponse:
|
|
def __init__(self, status: int, body: bytes) -> None:
|
|
self.status = status
|
|
self.body = body
|
|
|
|
def read(self, limit: int = -1) -> bytes:
|
|
del limit
|
|
return self.body
|
|
|
|
|
|
class FakeHTTPConnection:
|
|
def __init__(self, response: FakeHTTPResponse) -> None:
|
|
self.response = response
|
|
self.requested: list[tuple[str, str]] = []
|
|
self.closed = False
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
body: bytes | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> None:
|
|
del body, headers
|
|
self.requested.append((method, path))
|
|
|
|
def getresponse(self) -> FakeHTTPResponse:
|
|
return self.response
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class BrowserTests(unittest.TestCase):
|
|
def _with_connection(self, browser: DouyinBrowser, connection: BrowserCDP) -> None:
|
|
@contextmanager
|
|
def bound(alias: str):
|
|
del alias
|
|
yield connection
|
|
|
|
cast(Any, browser).connection = bound
|
|
|
|
def test_browser_fetch_uses_manually_logged_session(self) -> None:
|
|
cdp = BrowserCDP(
|
|
[
|
|
"https://www.douyin.com",
|
|
{"status": 200, "body": "{}", "too_large": False},
|
|
]
|
|
)
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cdp)
|
|
response = browser.get(
|
|
"safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383"
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertNotIn("Network.setCookies", [method for method, _ in cdp.commands])
|
|
|
|
def test_login_qr_captures_a_browser_screen_without_credentials(self) -> None:
|
|
screenshot = base64.b64encode(b"png-bytes").decode("ascii")
|
|
cdp = Mock()
|
|
cdp.evaluate.side_effect = [
|
|
True,
|
|
{"origin": "https://www.douyin.com", "qr_detected": True},
|
|
]
|
|
cdp.command.side_effect = [
|
|
{"frameId": "frame-1"},
|
|
{"data": screenshot},
|
|
]
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cast(BrowserCDP, cdp))
|
|
with patch.object(douyin_module.time, "sleep"):
|
|
response = browser.login_qr("safe")
|
|
self.assertEqual(response.content_type, "image/png")
|
|
self.assertEqual(response.body_base64, screenshot)
|
|
self.assertTrue(response.qr_detected)
|
|
self.assertEqual(
|
|
[call.args[0] for call in cdp.command.call_args_list],
|
|
["Page.navigate", "Page.captureScreenshot"],
|
|
)
|
|
self.assertFalse(any("cookie" in expression.lower() for expression in cdp.evaluate.call_args.args))
|
|
|
|
def test_connect_selects_configured_target_from_large_mixed_list(self) -> None:
|
|
targets = [
|
|
{"type": "service", "url": "http://127.0.0.1:9222/json"} for _ in range(40)
|
|
]
|
|
targets.extend(
|
|
[
|
|
{
|
|
"id": "other",
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/video/1",
|
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
|
|
},
|
|
{
|
|
"id": "selected",
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/user/self",
|
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/2",
|
|
},
|
|
]
|
|
)
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
patch.object(
|
|
douyin_module.websocket,
|
|
"create_connection",
|
|
return_value=FakeSocket([]),
|
|
),
|
|
):
|
|
connection = DouyinBrowser(
|
|
lambda alias: "http://127.0.0.1:9222", target_id="selected"
|
|
)._connect("safe")
|
|
self.assertIsInstance(connection, CDPConnection)
|
|
|
|
def test_media_download_is_browser_mediated_and_bounded(self) -> None:
|
|
payload = base64.b64encode(b"video-bytes").decode("ascii")
|
|
cdp = BrowserCDP(
|
|
[
|
|
{"url": "https://www.douyin.com/video/123", "readyState": "complete"},
|
|
{"status": 200, "content_type": "video/mp4", "body": payload},
|
|
]
|
|
)
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cdp)
|
|
response = browser.get_media("safe", "https://www.douyin.com/video/123")
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(response.content_type, "video/mp4")
|
|
self.assertEqual(base64.b64decode(response.body_base64), b"video-bytes")
|
|
self.assertIn("Page.navigate", [method for method, _ in cdp.commands])
|
|
self.assertIn("querySelectorAll", cdp.expressions[-1])
|
|
self.assertIn("uuu_265.mp4", cdp.expressions[-1])
|
|
|
|
def test_connect_identity_and_actions(self) -> None:
|
|
target = [
|
|
{
|
|
"type": "page",
|
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
|
|
}
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
patch.object(
|
|
douyin_module.websocket,
|
|
"create_connection",
|
|
return_value=FakeSocket([]),
|
|
),
|
|
):
|
|
connection = DouyinBrowser(lambda alias: "http://127.0.0.1:9222")._connect(
|
|
"safe"
|
|
)
|
|
self.assertIsInstance(connection, CDPConnection)
|
|
self.assertEqual(http.requested[0], ("GET", "/json/list"))
|
|
expression = install_expression("safe", "123")
|
|
self.assertIn("safe", expression)
|
|
self.assertIn("old?.dispose?.()", expression)
|
|
self.assertEqual(
|
|
notice_ids(
|
|
{
|
|
"service": 20313,
|
|
"payload": json.dumps(
|
|
{"notices": [{"notice_id_str": "1", "effect_groups": [960]}]}
|
|
),
|
|
}
|
|
),
|
|
["1"],
|
|
)
|
|
self.assertEqual(
|
|
notice_ids(
|
|
{
|
|
"service": 20003,
|
|
"payload": json.dumps({"notice_type": 45, "notice_id_str": "2"}),
|
|
}
|
|
),
|
|
["2"],
|
|
)
|
|
|
|
browser = DouyinBrowser()
|
|
cast(Any, browser).identity = lambda alias, expected: {"uid": expected}
|
|
cast(Any, browser)._evaluate = lambda *args: {
|
|
"status": "succeeded",
|
|
"action": "followed",
|
|
}
|
|
preview = browser.action(
|
|
"safe", "123", "dm", "456", text="hello", confirm=False
|
|
)
|
|
self.assertEqual(preview["action"], "preview")
|
|
self.assertEqual(
|
|
browser.action("safe", "123", "like_work", work_id="789", confirm=False)[
|
|
"action"
|
|
],
|
|
"preview",
|
|
)
|
|
with self.assertRaises(DouyinError):
|
|
browser.action("safe", "123", "dm", "456", text=" ", confirm=False)
|
|
with self.assertRaises(DouyinError):
|
|
browser.action("safe", "123", "follow", "bad", confirm=False)
|
|
|
|
def test_browser_queue_is_retained_until_ack(self) -> None:
|
|
wait = wait_expression("alpha")
|
|
self.assertIn("delivered", wait)
|
|
self.assertNotIn("splice(0)", wait)
|
|
ack = ack_expression("__creatorhub_notice_sub_alpha", ["browser-1"])
|
|
self.assertIn("browser-1", ack)
|
|
self.assertIn("__creatorhub_notice_sub_alpha", ack)
|
|
self.assertIn(
|
|
"crypto.randomUUID", douyin_module.install_expression("alpha", "123")
|
|
)
|
|
|
|
def test_listener_start_retries_until_runtime_ready(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.alias = "safe"
|
|
subscription.uid = "123"
|
|
subscription.stopped = threading.Event()
|
|
subscription.stopped.wait = Mock(return_value=False)
|
|
subscription._open_listener = Mock(
|
|
side_effect=[
|
|
DouyinError("SDK_NOT_READY"),
|
|
(Mock(), "2026-09-14T18:00:00+00:00"),
|
|
]
|
|
)
|
|
with patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0]):
|
|
connection, boundary = subscription._open_listener_until_ready()
|
|
self.assertEqual(boundary, "2026-09-14T18:00:00+00:00")
|
|
self.assertIsNotNone(connection)
|
|
self.assertEqual(subscription._open_listener.call_count, 2)
|
|
subscription.stopped.wait.assert_called_once_with(0.5)
|
|
|
|
def test_subscription_receipts_are_replayed_until_ack(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.queue = deque()
|
|
subscription.condition = threading.Condition()
|
|
subscription.stopped = threading.Event()
|
|
subscription._put({"kind": "notice", "notice": {"event_key": "1"}})
|
|
first = subscription.poll(10, 0)
|
|
second = subscription.poll(10, 0)
|
|
self.assertEqual(first, second)
|
|
self.assertTrue(first[0]["notice"]["gateway_received_at"].endswith("+00:00"))
|
|
subscription.ack([first[0]["delivery_id"]])
|
|
self.assertEqual(subscription.poll(10, 0), [])
|
|
|
|
def test_subscription_connection_failure_requests_recovery(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.alias = "safe"
|
|
subscription.browser = Mock()
|
|
subscription.browser._connect.side_effect = DouyinError("browser unavailable")
|
|
subscription._put = Mock()
|
|
subscription._request_recovery = Mock()
|
|
subscription._browser_inflight_lock = threading.Lock()
|
|
subscription._browser_inflight = set()
|
|
subscription._process_push_async({"delivery_id": "delivery-1"}, 0, False)
|
|
subscription._request_recovery.assert_called_once_with()
|
|
|
|
def test_subscription_detail_failure_does_not_discard_siblings(self) -> None:
|
|
bad = json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"notice_list_v2": [{"nid_str": "9", "user_id": "123"}],
|
|
}
|
|
),
|
|
}
|
|
)
|
|
good = json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"notice_list_v2": [
|
|
{
|
|
"nid_str": "2",
|
|
"user_id": "123",
|
|
"follow": {"from_user": [{"uid": "7"}]},
|
|
}
|
|
],
|
|
}
|
|
),
|
|
}
|
|
)
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.queue = deque()
|
|
subscription.condition = threading.Condition()
|
|
subscription.stopped = threading.Event()
|
|
subscription.connection = cast(CDPConnection, FakeConnection([bad, bad, good]))
|
|
subscription._get_connection = lambda: subscription.connection
|
|
subscription._handle(
|
|
{
|
|
"kind": "push",
|
|
"service": 20313,
|
|
"payload": json.dumps(
|
|
{
|
|
"notices": [
|
|
{"notice_id_str": "1", "effect_groups": [960]},
|
|
{"notice_id_str": "2", "effect_groups": [960]},
|
|
]
|
|
}
|
|
),
|
|
}
|
|
)
|
|
events = subscription.poll(10, 0)
|
|
self.assertEqual(events[0]["kind"], "error")
|
|
self.assertEqual(events[0]["event_key"], "1")
|
|
self.assertEqual(events[1]["notice"]["event_key"], "2")
|
|
|
|
def test_subscription_manager_and_queue(self) -> None:
|
|
event = {"kind": "open"}
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.queue = deque()
|
|
subscription.condition = __import__("threading").Condition()
|
|
subscription.stopped = __import__("threading").Event()
|
|
subscription._handle(event)
|
|
first = subscription.poll(10, 0)
|
|
self.assertEqual(first[0]["kind"], "open")
|
|
self.assertTrue(first[0]["delivery_id"])
|
|
subscription.queue = deque([{} for _ in range(1000)])
|
|
subscription._put({"kind": "new"})
|
|
self.assertEqual(subscription.queue[0]["kind"], "error")
|
|
subscription.stopped.set()
|
|
overflow = subscription.poll(10, 0)
|
|
self.assertEqual(overflow[0]["kind"], "error")
|
|
self.assertEqual(overflow[0]["reason"], "notification queue overflow")
|
|
self.assertTrue(overflow[0]["delivery_id"])
|
|
self.assertEqual(len(overflow), 10)
|
|
self.assertEqual(len(subscription.queue), 1000)
|
|
subscription.ack([overflow[0]["delivery_id"]])
|
|
self.assertEqual(len(subscription.queue), 999)
|
|
self.assertEqual(subscription.queue[0], {})
|
|
|
|
browser = DouyinBrowser()
|
|
fake = Mock()
|
|
fake.poll.return_value = [{"kind": "notice"}]
|
|
with patch.object(douyin_module, "DouyinSubscription", return_value=fake):
|
|
manager = SubscriptionManager(browser)
|
|
self.assertTrue(manager.start("safe", "123")["connected"])
|
|
self.assertEqual(manager.poll("safe", 1, 0), [{"kind": "notice"}])
|
|
manager.stop("safe")
|
|
manager.close()
|
|
with self.assertRaises(DouyinError):
|
|
manager.poll("safe", 1, 0)
|
|
|
|
|
|
class ProxyTests(unittest.TestCase):
|
|
def test_chunked_request_body_is_decoded_and_forwarded_with_length(self) -> None:
|
|
request = ChunkSocket(
|
|
[
|
|
(
|
|
b"POST http://example.test/a HTTP/1.1\r\nHost: example.test\r\n"
|
|
b"Transfer-Encoding: chunked\r\n\r\n2\r\nab\r\n3;part=x\r\ncde\r\n0\r\nX-Trailer: yes\r\n\r\n"
|
|
)
|
|
]
|
|
)
|
|
head, body = _read_request(cast(socket.socket, request))
|
|
self.assertEqual(body, b"abcde")
|
|
proxy = MemoryProxy.__new__(MemoryProxy)
|
|
proxy.exit = ProxyExit("socks5", "proxy", 1080)
|
|
upstream = ChunkSocket([b""])
|
|
proxy.dial = lambda target, timeout=20.0: cast(socket.socket, upstream)
|
|
with patch.object(proxy_module, "_copy_until_close"):
|
|
proxy.forward_http(
|
|
cast(socket.socket, ChunkSocket([])),
|
|
"POST",
|
|
"http://example.test/a",
|
|
_parse_request(head)[2],
|
|
body,
|
|
)
|
|
sent = upstream.sent[0].decode("iso-8859-1")
|
|
self.assertIn("Content-Length: 5", sent)
|
|
self.assertNotIn("Transfer-Encoding:", sent)
|
|
|
|
def test_registry_generation_and_shutdown(self) -> None:
|
|
registry = ProxyRegistry()
|
|
url, undo = registry.configure(
|
|
"safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network-1"
|
|
)
|
|
port = int(url.rsplit(":", 1)[1])
|
|
self.assertFalse(registry.ready("safe", port, "container-1", "network-1"))
|
|
self.assertTrue(registry.bind("safe", 1, url, "container-1", "network-1"))
|
|
self.assertTrue(registry.ready("safe", port, "container-1", "network-1"))
|
|
self.assertFalse(registry.remove("safe", 1, "container-2", "network-1"))
|
|
undo()
|
|
registry.close()
|
|
|
|
def test_memory_proxy_can_close(self) -> None:
|
|
proxy = MemoryProxy(
|
|
"safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network"
|
|
)
|
|
self.assertGreater(proxy.listener.getsockname()[1], 0)
|
|
proxy.close()
|
|
|
|
def test_proxy_request_parser_and_copy_helpers(self) -> None:
|
|
request = ChunkSocket(
|
|
[
|
|
b"POST http://example.test/a HTTP/1.1\r\nContent-Length: 3\r\nHost: example.test\r\n\r\nabc"
|
|
]
|
|
)
|
|
head, body = _read_request(cast(socket.socket, request))
|
|
self.assertEqual(body, b"abc")
|
|
method, target, headers = _parse_request(head)
|
|
self.assertEqual((method, target), ("POST", "http://example.test/a"))
|
|
self.assertEqual(headers[0], ("Content-Length", "3"))
|
|
source = ChunkSocket([b"one", b"", b"ignored"])
|
|
destination = ChunkSocket([])
|
|
_copy_until_close(cast(socket.socket, source), cast(socket.socket, destination))
|
|
self.assertEqual(b"".join(destination.sent), b"one")
|
|
self.assertEqual(
|
|
_recv_exact(cast(socket.socket, ChunkSocket([b"ab", b"cd"])), 4), b"abcd"
|
|
)
|
|
self.assertTrue(_is_ipv4("127.0.0.1"))
|
|
self.assertFalse(_is_ipv4("host.example"))
|
|
with self.assertRaises(ValueError):
|
|
_parse_request(b"BROKEN\r\n\r\n")
|
|
|
|
def test_connect_payload_in_same_read_is_preserved(self) -> None:
|
|
class GreedySocket(ChunkSocket):
|
|
def recv(self, size: int) -> bytes:
|
|
del size
|
|
return self.chunks.pop(0) if self.chunks else b""
|
|
|
|
upstream = GreedySocket([b"HTTP/1.1 200 OK\r\nX-Test: yes\r\n\r\nTLS"])
|
|
with patch.object(proxy_module, "_open_host", return_value=upstream):
|
|
result = _dial_http_proxy(
|
|
ProxyExit("http", "proxy", 8080), "target:443", 1.0
|
|
)
|
|
self.assertEqual(_recv_exact(cast(socket.socket, result), 3), b"TLS")
|
|
result.close()
|
|
|
|
def test_http_and_socks_handshakes(self) -> None:
|
|
http_socket = ChunkSocket([b"HTTP/1.1 200 Connection Established\r\n\r\n"])
|
|
with patch.object(proxy_module, "_open_host", return_value=http_socket):
|
|
result = _dial_http_proxy(
|
|
ProxyExit("http", "proxy", 8080, "u", "c"), "target:443", 1.0
|
|
)
|
|
self.assertIs(result, http_socket)
|
|
self.assertIn(b"Proxy-Authorization: Basic dTpj", http_socket.sent[0])
|
|
|
|
socks4_socket = ChunkSocket([b"\x00\x5a\x00\x00\x00\x00\x00\x00"])
|
|
with patch.object(proxy_module, "_open_host", return_value=socks4_socket):
|
|
self.assertIs(
|
|
_dial_socks4(ProxyExit("socks4", "proxy", 1080), "127.0.0.1:80", 1.0),
|
|
socks4_socket,
|
|
)
|
|
socks5_socket = ChunkSocket(
|
|
[b"\x05\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
|
|
)
|
|
with patch.object(proxy_module, "_open_host", return_value=socks5_socket):
|
|
self.assertIs(
|
|
_dial_socks5(ProxyExit("socks5", "proxy", 1080), "127.0.0.1:80", 1.0),
|
|
socks5_socket,
|
|
)
|
|
with self.assertRaises(OSError):
|
|
_dial_socks5(ProxyExit("socks5", "proxy", 1080), "host:0", 1.0)
|
|
|
|
def test_proxy_forward_and_tunnel_paths(self) -> None:
|
|
proxy = MemoryProxy.__new__(MemoryProxy)
|
|
proxy.exit = ProxyExit("socks5", "proxy", 1080)
|
|
upstream = ChunkSocket([b"reply", b""])
|
|
client = ChunkSocket([])
|
|
cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
|
|
socket.socket, upstream
|
|
)
|
|
with patch.object(proxy_module, "_copy_until_close") as copy:
|
|
proxy.forward_http(
|
|
cast(socket.socket, client),
|
|
"GET",
|
|
"http://example.test/path?q=1",
|
|
[("Host", "example.test")],
|
|
b"",
|
|
)
|
|
copy.assert_called_once()
|
|
self.assertIn(b"GET /path?q=1 HTTP/1.1", upstream.sent[0])
|
|
proxy.exit = ProxyExit("http", "proxy", 8080)
|
|
upstream = ChunkSocket([b""])
|
|
cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
|
|
socket.socket, upstream
|
|
)
|
|
with (
|
|
patch.object(proxy_module, "_open_host", return_value=upstream),
|
|
patch.object(proxy_module, "_copy_until_close"),
|
|
):
|
|
proxy.forward_http(
|
|
cast(socket.socket, client), "GET", "http://example.test/", [], b""
|
|
)
|
|
self.assertIn(b"GET http://example.test/ HTTP/1.1", upstream.sent[0])
|
|
|
|
|
|
class ChunkSocket:
|
|
def __init__(self, chunks: list[bytes]) -> None:
|
|
self.chunks = list(chunks)
|
|
self.sent: list[bytes] = []
|
|
|
|
def recv(self, size: int) -> bytes:
|
|
if not self.chunks:
|
|
return b""
|
|
chunk = self.chunks.pop(0)
|
|
if len(chunk) <= size:
|
|
return chunk
|
|
self.chunks.insert(0, chunk[size:])
|
|
return chunk[:size]
|
|
|
|
def sendall(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def send(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def settimeout(self, value: float | None) -> None:
|
|
del value
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
|
|
class DockerClientTests(unittest.TestCase):
|
|
def test_digest_pull_preserves_digest_in_docker_query(self) -> None:
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(200, "OK", b""),
|
|
]
|
|
)
|
|
client.pull_if_missing("registry.example/repo@sha256:" + "a" * 64)
|
|
self.assertIn(
|
|
"fromImage=registry.example%2Frepo%40sha256%3A" + "a" * 64,
|
|
client.calls[1][1],
|
|
)
|
|
|
|
def test_image_ref_and_expected_statuses(self) -> None:
|
|
self.assertEqual(
|
|
split_image_ref("registry.example/repo:tag"),
|
|
("registry.example/repo", "tag"),
|
|
)
|
|
self.assertEqual(split_image_ref("repo@sha256:abc"), ("repo", "sha256:abc"))
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(204, "No Content", b""),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(500, "Error", b"failure"),
|
|
]
|
|
)
|
|
client.expect("POST", "/ok")
|
|
with self.assertRaises(FileNotFoundError):
|
|
client.expect("POST", "/missing")
|
|
with self.assertRaises(DockerError):
|
|
client.expect("POST", "/error")
|
|
self.assertEqual(tenant_network_name("creatorhub", "safe"), "creatorhub-safe")
|
|
|
|
def test_pull_and_managed_container_response_validation(self) -> None:
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(200, "OK", b"{}"),
|
|
]
|
|
)
|
|
client.pull_if_missing("repo:tag")
|
|
self.assertIn("/images/repo%3Atag/json", client.calls[0][1])
|
|
managed = {
|
|
"Id": "container-id",
|
|
"Config": {"Labels": {MANAGED_LABEL: "true", RUNTIME_ID_LABEL: "safe"}},
|
|
"NetworkSettings": {"Networks": {"tenant": {"NetworkID": "network-id"}}},
|
|
}
|
|
client = ScriptedDocker(
|
|
[DockerResponse(200, "OK", json.dumps(managed).encode())]
|
|
)
|
|
self.assertEqual(client.managed_container_state("safe")[0], "container-id")
|
|
unmanaged = {
|
|
**managed,
|
|
"Config": {"Labels": {MANAGED_LABEL: "false", RUNTIME_ID_LABEL: "safe"}},
|
|
}
|
|
client = ScriptedDocker(
|
|
[DockerResponse(200, "OK", json.dumps(unmanaged).encode())]
|
|
)
|
|
with self.assertRaises(UnmanagedContainer):
|
|
client.managed_container("safe")
|
|
|
|
def test_existing_network_generation_and_disconnect(self) -> None:
|
|
network = {
|
|
"Id": "network-id",
|
|
"Name": "creatorhub-safe",
|
|
"Driver": "bridge",
|
|
"Internal": False,
|
|
"Attachable": False,
|
|
"Ingress": False,
|
|
"Labels": {
|
|
MANAGED_LABEL: "true",
|
|
NETWORK_ROLE_LABEL: BROWSER_NETWORK_ROLE,
|
|
RUNTIME_ID_LABEL: "safe",
|
|
BINDING_VERSION_LABEL: "1",
|
|
},
|
|
"Containers": {
|
|
"gateway-id": {"Name": "gateway", "IPv4Address": "10.0.0.2/24"},
|
|
"runtime-id": {"Name": "runtime", "IPv4Address": "10.0.0.3/24"},
|
|
},
|
|
}
|
|
gateway = {
|
|
"Id": "gateway-id",
|
|
"Config": {"Labels": {GATEWAY_MEMBER_LABEL: "true"}},
|
|
}
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(network).encode()),
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
]
|
|
)
|
|
generation, addresses, exists = client.inspect_tenant_network(
|
|
"creatorhub", "safe", 1, "runtime-id", "gateway", "network-id"
|
|
)
|
|
self.assertTrue(exists)
|
|
self.assertTrue(generation.runtime_attached)
|
|
self.assertEqual(addresses["gateway-id"], "10.0.0.2/24")
|
|
self.assertEqual(generation.self_member, "gateway-id")
|
|
|
|
after = {
|
|
**network,
|
|
"Containers": {"gateway-id": network["Containers"]["gateway-id"]},
|
|
}
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(network).encode()),
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
DockerResponse(200, "OK", json.dumps(network).encode()),
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
DockerResponse(200, "OK", b""),
|
|
DockerResponse(200, "OK", json.dumps(after).encode()),
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
]
|
|
)
|
|
current, _, _ = client.inspect_tenant_network(
|
|
"creatorhub", "safe", 1, "runtime-id", "gateway", "network-id"
|
|
)
|
|
updated = client.disconnect_member(
|
|
"creatorhub", "safe", 1, "runtime-id", current, "runtime-id", "gateway"
|
|
)
|
|
self.assertFalse(updated.runtime_attached)
|
|
|
|
def test_new_network_is_generation_fenced(self) -> None:
|
|
empty = {
|
|
"Id": "network-id",
|
|
"Name": "creatorhub-safe",
|
|
"Driver": "bridge",
|
|
"Internal": False,
|
|
"Attachable": False,
|
|
"Ingress": False,
|
|
"Labels": {
|
|
MANAGED_LABEL: "true",
|
|
NETWORK_ROLE_LABEL: BROWSER_NETWORK_ROLE,
|
|
RUNTIME_ID_LABEL: "safe",
|
|
BINDING_VERSION_LABEL: "1",
|
|
},
|
|
"Containers": {},
|
|
}
|
|
connected = {
|
|
**empty,
|
|
"Containers": {
|
|
"runtime-id": {"Name": "runtime", "IPv4Address": "10.0.0.3/24"},
|
|
"gateway-id": {"Name": "gateway", "IPv4Address": "10.0.0.2/24"},
|
|
},
|
|
}
|
|
gateway = {
|
|
"Id": "gateway-id",
|
|
"Config": {"Labels": {GATEWAY_MEMBER_LABEL: "true"}},
|
|
}
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(201, "Created", b'{"Id":"network-id"}'),
|
|
DockerResponse(200, "OK", json.dumps(empty).encode()),
|
|
DockerResponse(200, "OK", b""),
|
|
DockerResponse(200, "OK", b""),
|
|
DockerResponse(200, "OK", json.dumps(connected).encode()),
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
]
|
|
)
|
|
generation, bind_host = client.ensure_tenant_network(
|
|
"creatorhub", "safe", "gateway", 1, "runtime-id"
|
|
)
|
|
self.assertTrue(generation.created)
|
|
self.assertEqual(bind_host, "10.0.0.2")
|
|
|
|
def test_alias_reservation_releases_only_its_generation(self) -> None:
|
|
gateway = {
|
|
"Id": "gateway-id",
|
|
"Image": "creatorhub/gateway:latest",
|
|
"Config": {"Labels": {MANAGED_LABEL: "true", GATEWAY_MEMBER_LABEL: "true"}},
|
|
}
|
|
reservation = {
|
|
"Id": "reservation-id",
|
|
"Config": {
|
|
"Labels": {
|
|
"io.creatorhub.alias-reservation": "true",
|
|
"io.creatorhub.reservation-generation": "reservation-generation",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
}
|
|
},
|
|
}
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
DockerResponse(201, "Created", b'{"Id":"reservation-id"}'),
|
|
DockerResponse(200, "OK", json.dumps(reservation).encode()),
|
|
DockerResponse(200, "OK", json.dumps(reservation).encode()),
|
|
DockerResponse(204, "No Content", b""),
|
|
DockerResponse(404, "Not Found", b""),
|
|
]
|
|
)
|
|
with patch.object(
|
|
docker_client_module,
|
|
"random_reservation_generation",
|
|
return_value="reservation-generation",
|
|
):
|
|
release = AliasReservationManager(client, "gateway").acquire("safe")
|
|
release()
|
|
self.assertTrue(any(call[0] == "DELETE" for call in client.calls))
|
|
|
|
def test_stale_alias_reservation_is_reclaimed(self) -> None:
|
|
gateway = {
|
|
"Id": "gateway-id",
|
|
"Image": "creatorhub/gateway:latest",
|
|
"Config": {"Labels": {MANAGED_LABEL: "true", GATEWAY_MEMBER_LABEL: "true"}},
|
|
}
|
|
stale = {
|
|
"Id": "stale-reservation-id",
|
|
"Config": {
|
|
"Labels": {
|
|
RESERVATION_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
RESERVATION_OWNER_LABEL: "old-gateway",
|
|
}
|
|
},
|
|
}
|
|
current = {
|
|
"Id": "current-reservation-id",
|
|
"Config": {
|
|
"Labels": {
|
|
RESERVATION_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
RESERVATION_OWNER_LABEL: "gateway",
|
|
"io.creatorhub.reservation-generation": "current-generation",
|
|
}
|
|
},
|
|
}
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(gateway).encode()),
|
|
DockerResponse(409, "Conflict", b""),
|
|
DockerResponse(200, "OK", json.dumps(stale).encode()),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(204, "No Content", b""),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(201, "Created", b'{"Id":"current-reservation-id"}'),
|
|
DockerResponse(200, "OK", json.dumps(current).encode()),
|
|
DockerResponse(200, "OK", json.dumps(current).encode()),
|
|
DockerResponse(204, "No Content", b""),
|
|
DockerResponse(404, "Not Found", b""),
|
|
]
|
|
)
|
|
with patch.object(
|
|
docker_client_module,
|
|
"random_reservation_generation",
|
|
return_value="current-generation",
|
|
):
|
|
release = AliasReservationManager(client, "gateway").acquire("safe")
|
|
release()
|
|
self.assertEqual(
|
|
client.calls[4][1],
|
|
"/containers/stale-reservation-id?force=1&v=0",
|
|
)
|
|
self.assertEqual(client.calls[6][0], "POST")
|
|
|
|
|
|
class ScriptedDocker(DockerClient):
|
|
def __init__(self, responses: list[DockerResponse]) -> None:
|
|
super().__init__("/dev/null")
|
|
self.responses = list(responses)
|
|
self.calls: list[tuple[str, str, object | None]] = []
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: object | None = None,
|
|
timeout: float = 30.0,
|
|
body_limit: int = 16 * 1024 * 1024,
|
|
) -> DockerResponse:
|
|
del timeout, body_limit
|
|
self.calls.append((method, path, payload))
|
|
if not self.responses:
|
|
raise AssertionError(f"unexpected Docker call: {method} {path}")
|
|
return self.responses.pop(0)
|
|
|
|
|
|
class ReservationStub:
|
|
def __init__(self) -> None:
|
|
self.released = 0
|
|
|
|
def acquire(self, alias: str):
|
|
del alias
|
|
|
|
def release() -> None:
|
|
self.released += 1
|
|
|
|
return release
|
|
|
|
|
|
class LockingReservationStub(ReservationStub):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.lock = threading.Lock()
|
|
|
|
def acquire(self, alias: str):
|
|
del alias
|
|
self.lock.acquire()
|
|
|
|
def release() -> None:
|
|
self.released += 1
|
|
self.lock.release()
|
|
|
|
return release
|
|
|
|
|
|
class LifecycleDocker(ScriptedDocker):
|
|
def ensure_tenant_network(
|
|
self, *args: object, **kwargs: object
|
|
) -> tuple[TenantNetworkGeneration, str]:
|
|
del args, kwargs
|
|
return TenantNetworkGeneration(
|
|
id="network-id", self_member="gateway-id"
|
|
), "10.0.0.2"
|
|
|
|
def inspect_tenant_network(
|
|
self, *args: object, **kwargs: object
|
|
) -> tuple[TenantNetworkGeneration, dict[str, str], bool]:
|
|
del args, kwargs
|
|
return (
|
|
TenantNetworkGeneration(
|
|
id="network-id",
|
|
name="creatorhub-safe",
|
|
self_member="gateway-id",
|
|
gateway_members=["gateway-id"],
|
|
runtime_attached=True,
|
|
),
|
|
{"gateway-id": "10.0.0.2/24"},
|
|
True,
|
|
)
|
|
|
|
|
|
class GatewayLifecycleTests(unittest.TestCase):
|
|
def test_server_tracks_daemon_request_threads_and_timeout(self) -> None:
|
|
server = gateway_module.GatewayHTTPServer(("127.0.0.1", 0), Mock())
|
|
try:
|
|
self.assertTrue(server.daemon_threads)
|
|
self.assertEqual(gateway_module.GatewayHandler.protocol_version, "HTTP/1.1")
|
|
finally:
|
|
server.server_close()
|
|
|
|
def test_network_setup_error_keeps_created_generation(self) -> None:
|
|
client = ScriptedDocker(
|
|
[
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(201, "Created", b'{"Id":"network-id"}'),
|
|
DockerResponse(500, "Error", b"inspect failed"),
|
|
]
|
|
)
|
|
with self.assertRaises(docker_client_module.NetworkSetupError) as caught:
|
|
client.ensure_tenant_network("creatorhub", "safe", "gateway", 1, "runtime")
|
|
self.assertEqual(caught.exception.generation.id, "network-id")
|
|
self.assertTrue(caught.exception.generation.created)
|
|
|
|
def test_network_cleanup_failure_returns_pending_contract(self) -> None:
|
|
docker = ScriptedDocker(
|
|
[
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(500, "Error", b"temporary"),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
with self.assertRaises(RequestError) as caught:
|
|
gateway.remove(
|
|
"safe",
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "runtime-not-found",
|
|
"network_id": "network-id",
|
|
},
|
|
)
|
|
self.assertEqual(caught.exception.status, 202)
|
|
self.assertEqual(caught.exception.network_id, "network-id")
|
|
self.assertEqual(str(caught.exception), "runtime_cleanup_pending")
|
|
|
|
def test_timed_out_action_retains_alias_ownership(self) -> None:
|
|
gateway = Gateway(
|
|
cast(DockerClient, Mock()), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway._claim_action("safe")
|
|
gateway._retain_action_ownership("safe")
|
|
with self.assertRaises(RequestError):
|
|
gateway._claim_action("safe")
|
|
|
|
def _input(self, stopped: bool = True) -> dict:
|
|
return {
|
|
"alias": "safe",
|
|
"name": "Safe",
|
|
"image": "creatorhub/browser:latest",
|
|
"cmd": ["about:blank"],
|
|
"volume": "creatorhub-safe",
|
|
"binding_version": 1,
|
|
"network_exit_id": "",
|
|
"stopped": stopped,
|
|
}
|
|
|
|
def test_concurrent_create_cannot_delete_the_winner(self) -> None:
|
|
class ConcurrentDocker(DockerClient):
|
|
def __init__(self) -> None:
|
|
super().__init__("/dev/null")
|
|
self.created = False
|
|
self.calls: list[tuple[str, str]] = []
|
|
|
|
def managed_container(self, alias: str) -> tuple[str, dict[str, str]]:
|
|
if not self.created:
|
|
raise FileNotFoundError(alias)
|
|
return "a" * 64, {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: alias,
|
|
BINDING_VERSION_LABEL: "1",
|
|
NETWORK_ID_LABEL: "",
|
|
}
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: object | None = None,
|
|
timeout: float = 30.0,
|
|
body_limit: int = 16 * 1024 * 1024,
|
|
) -> DockerResponse:
|
|
del payload, timeout, body_limit
|
|
self.calls.append((method, path))
|
|
if method == "GET" and path.startswith("/images/"):
|
|
return DockerResponse(200, "OK", b"{}")
|
|
if method == "POST" and path.startswith("/containers/create"):
|
|
if self.created:
|
|
return DockerResponse(409, "Conflict", b"")
|
|
self.created = True
|
|
return DockerResponse(
|
|
201, "Created", b'{"Id":"' + b"b" * 64 + b'"}'
|
|
)
|
|
raise AssertionError(f"unexpected Docker call: {method} {path}")
|
|
|
|
docker = ConcurrentDocker()
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, LockingReservationStub())
|
|
results: list[object] = []
|
|
|
|
def create() -> None:
|
|
try:
|
|
results.append(gateway.create(self._input()))
|
|
except (
|
|
AssertionError,
|
|
DockerError,
|
|
OSError,
|
|
RequestError,
|
|
ValueError,
|
|
) as exc:
|
|
results.append(exc)
|
|
|
|
threads = [threading.Thread(target=create) for _ in range(2)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
self.assertEqual(sum(isinstance(item, dict) for item in results), 1)
|
|
self.assertEqual(sum(isinstance(item, RequestError) for item in results), 1)
|
|
self.assertFalse(any(method == "DELETE" for method, _ in docker.calls))
|
|
|
|
def test_create_stopped_pulls_image_and_keeps_container(self) -> None:
|
|
docker = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", b"{}"),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(201, "Created", b'{"Id":"container-id"}'),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
reservations = ReservationStub()
|
|
gateway.reservations = cast(AliasReservationManager, reservations)
|
|
result = gateway.create(self._input())
|
|
self.assertEqual(result["id"], "container-id")
|
|
self.assertEqual(reservations.released, 1)
|
|
self.assertEqual(docker.calls[2][0], "POST")
|
|
|
|
def test_running_create_starts_container_and_network(self) -> None:
|
|
docker = LifecycleDocker(
|
|
[
|
|
DockerResponse(200, "OK", b"{}"),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(201, "Created", b'{"Id":"container-id"}'),
|
|
DockerResponse(204, "No Content", b""),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
result = gateway.create(self._input(False))
|
|
self.assertEqual(result["network_id"], "network-id")
|
|
self.assertEqual(docker.calls[-1][0], "POST")
|
|
|
|
def test_change_state_and_remove_are_generation_fenced(self) -> None:
|
|
labels = {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
BINDING_VERSION_LABEL: "1",
|
|
NETWORK_ID_LABEL: "network-id",
|
|
}
|
|
inspected = {
|
|
"Id": "a" * 64,
|
|
"Config": {"Labels": labels},
|
|
"NetworkSettings": {"Networks": {}},
|
|
}
|
|
docker = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(inspected).encode()),
|
|
DockerResponse(204, "No Content", b""),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
gateway.change_state(
|
|
"safe",
|
|
"stop",
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "network-id",
|
|
},
|
|
)
|
|
self.assertEqual(docker.calls[-1][0], "POST")
|
|
|
|
docker = LifecycleDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(inspected).encode()),
|
|
DockerResponse(204, "No Content", b""),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
gateway._remove_network = lambda *args, **kwargs: None
|
|
gateway.remove(
|
|
"safe",
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "network-id",
|
|
},
|
|
)
|
|
self.assertEqual(docker.calls[-1][0], "DELETE")
|
|
|
|
def test_remove_stopped_direct_container_without_network(self) -> None:
|
|
labels = {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
BINDING_VERSION_LABEL: "1",
|
|
NETWORK_ID_LABEL: "",
|
|
}
|
|
inspected = {
|
|
"Id": "a" * 64,
|
|
"Config": {"Labels": labels},
|
|
"NetworkSettings": {"Networks": {}},
|
|
}
|
|
docker = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", json.dumps(inspected).encode()),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(204, "No Content", b""),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
gateway.remove(
|
|
"safe",
|
|
{"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""},
|
|
)
|
|
self.assertEqual(docker.calls[-1][0], "DELETE")
|
|
|
|
def test_remove_rejects_runtime_replacement(self) -> None:
|
|
labels = {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
BINDING_VERSION_LABEL: "1",
|
|
NETWORK_ID_LABEL: "",
|
|
}
|
|
inspected = {
|
|
"Id": "a" * 64,
|
|
"Config": {"Labels": labels},
|
|
"NetworkSettings": {"Networks": {}},
|
|
}
|
|
docker = ScriptedDocker(
|
|
[DockerResponse(200, "OK", json.dumps(inspected).encode())]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
with self.assertRaises(RequestError):
|
|
gateway.remove(
|
|
"safe",
|
|
{"binding_version": 1, "runtime_id": "b" * 64, "network_id": ""},
|
|
)
|
|
|
|
def test_create_failure_reconciles_unknown_container(self) -> None:
|
|
docker = ScriptedDocker(
|
|
[
|
|
DockerResponse(200, "OK", b"{}"),
|
|
DockerResponse(404, "Not Found", b""),
|
|
DockerResponse(500, "Error", b"failed"),
|
|
DockerResponse(404, "Not Found", b""),
|
|
]
|
|
)
|
|
gateway = Gateway(
|
|
cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
|
|
)
|
|
gateway.reservations = cast(AliasReservationManager, ReservationStub())
|
|
with self.assertRaises(RequestError):
|
|
gateway.create(self._input())
|
|
|
|
|
|
class AdditionalGatewayCoverageTests(unittest.TestCase):
|
|
def test_douyin_connect_rejects_bad_discovery(self) -> None:
|
|
browser = DouyinBrowser(lambda _: "https://browser:9222")
|
|
with self.assertRaises(DouyinError):
|
|
browser._connect("safe")
|
|
cases = [
|
|
(500, b"{}"),
|
|
(200, b"{}"),
|
|
(200, json.dumps([{"type": "service"}]).encode()),
|
|
(
|
|
200,
|
|
json.dumps(
|
|
[{"type": "page", "url": "https://www.douyin.com/1"}]
|
|
).encode(),
|
|
),
|
|
(
|
|
200,
|
|
json.dumps(
|
|
[
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "http://browser/devtools/page/1",
|
|
}
|
|
]
|
|
).encode(),
|
|
),
|
|
]
|
|
for status, body in cases:
|
|
with self.subTest(status=status, body=body):
|
|
http = FakeHTTPConnection(FakeHTTPResponse(status, body))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
browser._connect("safe")
|
|
targets = [
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
|
|
},
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/2",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/2",
|
|
},
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
browser._connect("safe")
|
|
|
|
def test_douyin_fetch_identity_and_confirmed_actions(self) -> None:
|
|
browser = DouyinBrowser()
|
|
|
|
def bind(connection: BrowserCDP) -> None:
|
|
BrowserTests()._with_connection(browser, connection)
|
|
|
|
for result in (
|
|
{"too_large": True},
|
|
{"status": 302, "body": ""},
|
|
{"status": 200, "body": 1},
|
|
):
|
|
bind(BrowserCDP(["https://www.douyin.com", result]))
|
|
with self.assertRaises(DouyinError):
|
|
browser.get("safe", "https://www.douyin.com/a")
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(200, "not-json")
|
|
with self.assertRaises(DouyinError):
|
|
browser.identity("safe")
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(
|
|
200, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
|
|
)
|
|
self.assertEqual(browser.identity("safe")["uid"], "1")
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(
|
|
200,
|
|
json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"extra": {"now": 1789401629000},
|
|
"user": {"uid": "1", "sec_uid": "sec"},
|
|
}
|
|
),
|
|
)
|
|
self.assertTrue(browser.identity("safe")["platform_now"].startswith("2026-"))
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(
|
|
403, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
|
|
)
|
|
with self.assertRaises(DouyinError):
|
|
browser.identity("safe")
|
|
cast(Any, browser).identity = lambda alias, expected_uid=None: {
|
|
"uid": expected_uid or "1"
|
|
}
|
|
cast(Any, browser)._evaluate = lambda alias, expression: {
|
|
"status": 200,
|
|
"action": "sent",
|
|
}
|
|
bind(BrowserCDP(["https://www.douyin.com", {}]))
|
|
self.assertEqual(
|
|
browser.action("safe", "1", "follow", "2", confirm=True)["action"], "sent"
|
|
)
|
|
bind(BrowserCDP(["https://www.douyin.com", {}]))
|
|
self.assertEqual(
|
|
browser.action("safe", "1", "dm", "2", text="hello", confirm=True)[
|
|
"status"
|
|
],
|
|
200,
|
|
)
|
|
cast(Any, browser)._evaluate = lambda alias, expression: "bad"
|
|
bind(BrowserCDP(["https://www.douyin.com", "bad"]))
|
|
with self.assertRaises(DouyinError):
|
|
browser.action("safe", "1", "follow", "2", confirm=True)
|
|
|
|
def test_gateway_and_proxy_validation_edges(self) -> None:
|
|
self.assertEqual(
|
|
parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, ""
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(
|
|
parse_proxy_exit({"protocol": "http", "host": "", "port": 80})
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(
|
|
parse_proxy_exit({"protocol": "http", "host": "proxy", "port": 0})
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_restore(
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "n",
|
|
"network_exit_id": "x",
|
|
},
|
|
"bad alias",
|
|
)
|
|
self.assertFalse(valid_account_key_query({"key": ["bad key"]}, "key"))
|
|
|
|
def test_proxy_rejected_handshakes_and_docker_errors(self) -> None:
|
|
bad_http = ChunkSocket([b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"])
|
|
with (
|
|
patch.object(proxy_module, "_open_host", return_value=bad_http),
|
|
self.assertRaises(OSError),
|
|
):
|
|
_dial_http_proxy(ProxyExit("http", "proxy", 8080), "target:443", 1.0)
|
|
bad_socks5 = ChunkSocket([b"\x05\x02"])
|
|
with (
|
|
patch.object(proxy_module, "_open_host", return_value=bad_socks5),
|
|
self.assertRaises(OSError),
|
|
):
|
|
_dial_socks5(
|
|
ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0
|
|
)
|
|
client = DockerClient("/not/a/socket")
|
|
with (
|
|
patch.object(
|
|
docker_client_module, "UnixHTTPConnection", side_effect=OSError("down")
|
|
),
|
|
self.assertRaises(DockerError),
|
|
):
|
|
client.request("GET", "/version", timeout=0.01)
|
|
with (
|
|
patch.object(
|
|
client, "request", return_value=DockerResponse(500, "bad", b"x")
|
|
),
|
|
self.assertRaises(DockerError),
|
|
):
|
|
client.expect("POST", "/x")
|
|
with (
|
|
patch.object(
|
|
client, "request", return_value=DockerResponse(404, "missing", b"")
|
|
),
|
|
self.assertRaises(FileNotFoundError),
|
|
):
|
|
client.expect("DELETE", "/gone")
|
|
|
|
def test_docker_client_transport_and_metadata_edges(self) -> None:
|
|
client = DockerClient("/unused")
|
|
response = FakeHTTPResponse(200, b"{}")
|
|
cast(Any, response).reason = "OK"
|
|
connection = FakeHTTPConnection(response)
|
|
with patch.object(
|
|
docker_client_module, "UnixHTTPConnection", return_value=connection
|
|
):
|
|
result = client.request("POST", "/version", {"ok": True})
|
|
self.assertEqual(result.status, 200)
|
|
self.assertEqual(connection.requested, [("POST", "/v1.43/version")])
|
|
huge = FakeHTTPResponse(200, b"12345")
|
|
cast(Any, huge).reason = "OK"
|
|
with (
|
|
patch.object(
|
|
docker_client_module,
|
|
"UnixHTTPConnection",
|
|
return_value=FakeHTTPConnection(huge),
|
|
),
|
|
self.assertRaises(DockerError),
|
|
):
|
|
client.request("GET", "/version", body_limit=4)
|
|
labels = {
|
|
MANAGED_LABEL: "true",
|
|
RUNTIME_ID_LABEL: "safe",
|
|
GATEWAY_MEMBER_LABEL: "true",
|
|
}
|
|
inspected = {
|
|
"Id": "a" * 64,
|
|
"Config": {"Labels": labels},
|
|
"NetworkSettings": {
|
|
"Networks": {"n": {"NetworkID": "network", "IPAddress": "198.51.100.5"}}
|
|
},
|
|
}
|
|
with patch.object(
|
|
client,
|
|
"request",
|
|
return_value=DockerResponse(200, "OK", json.dumps(inspected).encode()),
|
|
):
|
|
self.assertTrue(client.trusted_gateway_member("gateway"))
|
|
self.assertEqual(
|
|
client.container_network_address("a" * 64, "network"), "198.51.100.5"
|
|
)
|
|
bad = DockerResponse(200, "OK", b"[]")
|
|
with patch.object(client, "request", return_value=bad):
|
|
self.assertFalse(client.trusted_gateway_member("gateway"))
|
|
with self.assertRaises(DockerError):
|
|
client.managed_container_state("safe")
|
|
|
|
def test_client_connection_pull_and_inspect_edges(self) -> None:
|
|
class ConnectedSocket:
|
|
def __init__(self) -> None:
|
|
self.timeout = None
|
|
self.path = ""
|
|
|
|
def settimeout(self, value: float) -> None:
|
|
self.timeout = value
|
|
|
|
def connect(self, path: str) -> None:
|
|
self.path = path
|
|
|
|
connected = ConnectedSocket()
|
|
with patch.object(
|
|
docker_client_module.socket, "socket", return_value=connected
|
|
):
|
|
connection = docker_client_module.UnixHTTPConnection(
|
|
"/run/docker.sock", 1.0
|
|
)
|
|
connection.connect()
|
|
self.assertEqual(connected.path, "/run/docker.sock")
|
|
client = DockerClient("/unused")
|
|
with patch.object(
|
|
client,
|
|
"request",
|
|
side_effect=[
|
|
DockerResponse(404, "missing", b""),
|
|
DockerResponse(200, "OK", b"{}"),
|
|
],
|
|
) as request:
|
|
client.pull_if_missing("registry.example/repo:tag")
|
|
self.assertIn("tag=tag", request.call_args_list[1].args[1])
|
|
with (
|
|
patch.object(
|
|
client, "request", return_value=DockerResponse(500, "bad", b"no")
|
|
),
|
|
self.assertRaises(DockerError),
|
|
):
|
|
client.pull_if_missing("registry.example/repo:tag")
|
|
invalid = DockerResponse(
|
|
200,
|
|
"OK",
|
|
json.dumps(
|
|
{
|
|
"Id": "a" * 64,
|
|
"Config": {"Labels": []},
|
|
"NetworkSettings": {"Networks": {}},
|
|
}
|
|
).encode(),
|
|
)
|
|
with (
|
|
patch.object(client, "request", return_value=invalid),
|
|
self.assertRaises(DockerError),
|
|
):
|
|
client.managed_container_state("safe")
|
|
|
|
def test_cdp_error_and_proxy_auth_paths(self) -> None:
|
|
socket_ = FakeSocket([{"id": 1, "result": {"result": {"value": {"ok": True}}}}])
|
|
connection = CDPConnection(cast(websocket.WebSocket, socket_))
|
|
self.assertEqual(connection.evaluate("1"), {"ok": True})
|
|
socket_ = FakeSocket([{"id": 1, "result": {"result": {}}}])
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(
|
|
cast(websocket.WebSocket, FakeSocket([{"id": 1, "error": {}}]))
|
|
).command("Page.enable")
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(
|
|
cast(websocket.WebSocket, FakeSocket([{"id": 1, "result": []}]))
|
|
).command("Page.enable")
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(cast(websocket.WebSocket, FakeSocket([]))).wait_event(
|
|
"Page.loadEventFired", lambda _: True, timeout=0.01
|
|
)
|
|
socket_ = FakeSocket([{"id": 1, "result": {"exceptionDetails": {}}}])
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
|
|
target = [
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
|
|
}
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
patch.object(
|
|
douyin_module.websocket,
|
|
"create_connection",
|
|
side_effect=OSError("down"),
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
DouyinBrowser(lambda _: "http://browser:9222")._connect("safe")
|
|
socks5 = ChunkSocket(
|
|
[b"\x05\x02", b"\x01\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
|
|
)
|
|
with patch.object(proxy_module, "_open_host", return_value=socks5):
|
|
self.assertIs(
|
|
_dial_socks5(
|
|
ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0
|
|
),
|
|
socks5,
|
|
)
|
|
|
|
def test_direct_message_notice_and_uncertain_post_contract(self) -> None:
|
|
notice = normalize_notice(
|
|
{
|
|
"dm": {
|
|
"message_id": "123456",
|
|
"from_user": {"uid": "456789"},
|
|
"text": "hello",
|
|
},
|
|
"create_time": 1700000000,
|
|
}
|
|
)
|
|
self.assertIsNotNone(notice)
|
|
assert notice is not None
|
|
self.assertEqual(notice["event_type"], "dm")
|
|
self.assertEqual(notice["interactor_uid"], "456789")
|
|
self.assertEqual(notice["message_type"], "text")
|
|
self.assertEqual(notice["message_text"], "hello")
|
|
non_text = normalize_notice(
|
|
{
|
|
"dm": {
|
|
"message_id": "123457",
|
|
"from_user": {"uid": "456789"},
|
|
"message_type": "image",
|
|
"image_url": "https://example.invalid/image",
|
|
}
|
|
}
|
|
)
|
|
assert non_text is not None
|
|
self.assertEqual(non_text["message_type"], "image")
|
|
self.assertEqual(non_text["message_text"], "")
|
|
script = action_expression({"alias": "safe", "action": "follow", "target": "2"})
|
|
self.assertIn("POST_UNCERTAIN", script)
|
|
self.assertIn("BUSINESS_REJECTED", script)
|
|
|
|
def test_cdp_timeout_terminates_page_evaluation(self) -> None:
|
|
class TimeoutSocket(FakeSocket):
|
|
def __init__(self) -> None:
|
|
super().__init__([])
|
|
self.receives = 0
|
|
|
|
def recv(self) -> str:
|
|
self.receives += 1
|
|
if self.receives == 1:
|
|
raise TimeoutError("deadline")
|
|
return json.dumps({"id": 2, "result": {}})
|
|
|
|
socket_ = TimeoutSocket()
|
|
connection = CDPConnection(cast(websocket.WebSocket, socket_))
|
|
with (
|
|
patch.object(douyin_module, "CONTROL_TIMEOUT", 0.001),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
connection.command("Runtime.evaluate")
|
|
sent = [json.loads(item) for item in socket_.sent]
|
|
self.assertEqual(sent[1]["method"], "Runtime.terminateExecution")
|
|
|
|
def test_network_create_reconciliation_preserves_observed_generation(self) -> None:
|
|
client = DockerClient("/unused")
|
|
observed = TenantNetworkGeneration(name="creatorhub-safe", id="observed")
|
|
generation = TenantNetworkGeneration(name="creatorhub-safe")
|
|
with (
|
|
patch.object(
|
|
client, "inspect_tenant_network", return_value=(observed, [], True)
|
|
),
|
|
self.assertRaises(NetworkSetupError) as caught,
|
|
):
|
|
client._finish_network_create(
|
|
"creatorhub",
|
|
"safe",
|
|
"gateway",
|
|
1,
|
|
"runtime",
|
|
DockerResponse(201, "Created", b"{}"),
|
|
generation,
|
|
)
|
|
self.assertEqual(caught.exception.generation.id, "observed")
|
|
|
|
with patch.object(
|
|
client, "inspect_tenant_network", return_value=(observed, [], True)
|
|
):
|
|
result = client._finish_network_create(
|
|
"creatorhub",
|
|
"safe",
|
|
"gateway",
|
|
1,
|
|
"runtime",
|
|
DockerResponse(201, "Created", b'{"Id":"created"}'),
|
|
TenantNetworkGeneration(name="creatorhub-safe"),
|
|
)
|
|
self.assertEqual(result.id, "observed")
|
|
|
|
def test_configured_target_and_media_wait_edge_cases(self) -> None:
|
|
targets = [
|
|
{
|
|
"type": "page",
|
|
"id": "other",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
|
|
}
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
self.assertRaisesRegex(DouyinError, "configured browser page target"),
|
|
):
|
|
DouyinBrowser(
|
|
lambda _: "http://browser:9222", target_id="missing"
|
|
)._connect("safe")
|
|
|
|
cdp = Mock()
|
|
cdp.command.return_value = {"frameId": "frame-1"}
|
|
cdp.evaluate.side_effect = [
|
|
DouyinError("page is still loading"),
|
|
{"url": "https://www.douyin.com/video/123", "readyState": "complete"},
|
|
{"error": "media_source_unavailable"},
|
|
]
|
|
browser = DouyinBrowser()
|
|
BrowserTests()._with_connection(browser, cast(BrowserCDP, cdp))
|
|
with (
|
|
patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0, 2.0]),
|
|
patch.object(douyin_module.time, "sleep"),
|
|
self.assertRaisesRegex(DouyinError, "media_source_unavailable"),
|
|
):
|
|
browser.get_media("safe", "https://www.douyin.com/video/123")
|
|
|
|
def test_identity_and_message_history_reject_invalid_values(self) -> None:
|
|
browser = DouyinBrowser()
|
|
for server_now in (0, float("nan")):
|
|
cast(Any, browser).get = lambda alias, target, now=server_now: (
|
|
BrowserResponse(
|
|
200,
|
|
json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"extra": {"now": now},
|
|
"user": {"uid": "123", "sec_uid": "sec"},
|
|
}
|
|
),
|
|
)
|
|
)
|
|
with (
|
|
self.subTest(server_now=server_now),
|
|
self.assertRaisesRegex(DouyinError, "platform clock"),
|
|
):
|
|
browser.identity("safe")
|
|
|
|
identity = Mock(return_value={"uid": "123"})
|
|
evaluate = Mock(return_value={"status": "succeeded", "messages": []})
|
|
cast(Any, browser).identity = identity
|
|
cast(Any, browser)._evaluate = evaluate
|
|
result = browser.message_history("safe", "123", "456", 20)
|
|
self.assertEqual(result["status"], "succeeded")
|
|
identity.assert_called_once_with("safe", "123")
|
|
self.assertIn("456", evaluate.call_args.args[1])
|
|
for expected_uid, target_uid, limit in (
|
|
("bad", "456", 20),
|
|
("123", "bad", 20),
|
|
("123", "456", 0),
|
|
("123", "456", 201),
|
|
):
|
|
with (
|
|
self.subTest(
|
|
expected_uid=expected_uid, target_uid=target_uid, limit=limit
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
browser.message_history("safe", expected_uid, target_uid, limit)
|
|
evaluate.return_value = "bad"
|
|
with self.assertRaisesRegex(DouyinError, "message history response"):
|
|
browser.message_history("safe", "123", "456")
|
|
|
|
def test_listener_open_and_recovery_boundaries(self) -> None:
|
|
browser = Mock()
|
|
browser.identity.return_value = {
|
|
"uid": "123",
|
|
"platform_now": "2026-09-14T00:00:00+00:00",
|
|
}
|
|
connection = BrowserCDP([json.dumps({"connected": True})])
|
|
browser._connect.return_value = connection
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.browser = browser
|
|
subscription.alias = "safe"
|
|
subscription.uid = "123"
|
|
subscription.key = "__creatorhub_notice_sub_safe"
|
|
opened, boundary_at = subscription._open_listener()
|
|
self.assertIs(opened, connection)
|
|
self.assertEqual(boundary_at, "2026-09-14T00:00:00+00:00")
|
|
|
|
constructor_connection = BrowserCDP([json.dumps({"connected": True})])
|
|
browser._connect.return_value = constructor_connection
|
|
with patch.object(threading.Thread, "start"):
|
|
constructed = DouyinSubscription(browser, "safe", "123")
|
|
self.assertEqual(constructed.queue[0]["kind"], "baseline")
|
|
constructed.stopped.set()
|
|
constructed._detail_pool.shutdown(wait=True, cancel_futures=True)
|
|
|
|
broken = BrowserCDP([json.dumps({"connected": True}), "{}"])
|
|
browser._connect.return_value = broken
|
|
browser.identity.return_value = {"uid": "123"}
|
|
with self.assertRaisesRegex(DouyinError, "event boundary"):
|
|
subscription._open_listener()
|
|
self.assertTrue(broken.closed)
|
|
|
|
old = Mock()
|
|
old.evaluate.side_effect = DouyinError("stale listener")
|
|
new = Mock()
|
|
subscription.connection = old
|
|
subscription._connection_lock = threading.RLock()
|
|
subscription._epoch = 0
|
|
subscription._initial_boundary_pending = False
|
|
subscription.stopped = threading.Event()
|
|
subscription.stopped.wait = Mock(return_value=False)
|
|
subscription._open_listener = Mock(
|
|
return_value=(new, "2026-09-14T00:00:01+00:00")
|
|
)
|
|
subscription._put = Mock()
|
|
subscription._recover()
|
|
old.close.assert_called_once_with()
|
|
self.assertIs(subscription.connection, new)
|
|
self.assertEqual(subscription._epoch, 1)
|
|
self.assertEqual(
|
|
[call.args[0]["kind"] for call in subscription._put.call_args_list],
|
|
["reconnected", "baseline"],
|
|
)
|
|
|
|
def test_external_message_history_and_generation_fences(self) -> None:
|
|
external = {
|
|
"url": "http://127.0.0.1:9222",
|
|
"target_id": "target_1",
|
|
"alias": "local-cdp",
|
|
"runtime_id": "0" * 64,
|
|
"network_id": "local-cdp",
|
|
"binding_version": 1,
|
|
}
|
|
gateway = Gateway(
|
|
cast(DockerClient, FakeDocker([])),
|
|
"creatorhub_browser",
|
|
"0123456789abcdef",
|
|
"gateway",
|
|
external_cdp=external,
|
|
)
|
|
self.assertEqual(gateway._browser_endpoint("local-cdp"), external["url"])
|
|
with self.assertRaises(GenerationConflict):
|
|
gateway._browser_endpoint("other")
|
|
with gateway._alias_lock("local-cdp"):
|
|
pass
|
|
|
|
browser = Mock()
|
|
browser.message_history.return_value = {
|
|
"status": "succeeded",
|
|
"history_source": "im_sdk_pull",
|
|
"messages": [],
|
|
}
|
|
gateway.browser = browser
|
|
generation = {
|
|
"binding_version": 1,
|
|
"runtime_id": "0" * 64,
|
|
"network_id": "local-cdp",
|
|
"expected_uid": "123",
|
|
"target_uid": "456",
|
|
"limit": 20,
|
|
}
|
|
self.assertEqual(
|
|
gateway.douyin_message_history("local-cdp", generation)["status"],
|
|
"succeeded",
|
|
)
|
|
browser.message_history.assert_called_once_with("local-cdp", "123", "456", 20)
|
|
with self.assertRaises(RequestError):
|
|
gateway.douyin_message_history(
|
|
"local-cdp", {**generation, "target_uid": "123"}
|
|
)
|
|
browser.message_history.side_effect = DouyinError("offline")
|
|
with self.assertRaises(RequestError):
|
|
gateway.douyin_message_history("local-cdp", generation)
|
|
|
|
def test_douyin_api_urls_and_external_config_edges(self) -> None:
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com"
|
|
f"{gateway_module.DOUYIN_WORKS_PATH}"
|
|
"?aid=6383&device_platform=webapp&sec_user_id=sec&count=20&max_cursor=0"
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com"
|
|
f"{gateway_module.DOUYIN_COMMENTS_PATH}"
|
|
"?aid=6383&device_platform=webapp&aweme_id=123&count=20&cursor=0"
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com"
|
|
f"{gateway_module.DOUYIN_WORKS_PATH}"
|
|
"?aid=6383&device_platform=webapp&sec_user_id=sec&count=10&max_cursor=0"
|
|
)
|
|
)
|
|
self.assertTrue(gateway_module.valid_cdp_url("http://127.0.0.1:9222/"))
|
|
for raw in (
|
|
"https://127.0.0.1:9222",
|
|
"http://127.0.0.1:9222/devtools",
|
|
"http://127.0.0.1:9222?x=1",
|
|
"http://user:pass@127.0.0.1:9222",
|
|
"http://127.0.0.1:not-a-port",
|
|
):
|
|
with self.subTest(raw=raw):
|
|
self.assertFalse(gateway_module.valid_cdp_url(raw))
|
|
base = {
|
|
"LISTEN_ADDR": ":8081",
|
|
"DOCKER_SOCKET": "/var/run/docker.sock",
|
|
"BROWSER_NETWORK": "creatorhub_browser",
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
"BROWSER_CDP_URL": "http://127.0.0.1:9222",
|
|
}
|
|
with self.assertRaises(ValueError):
|
|
load_config({**base, "BROWSER_CDP_BINDING_VERSION": "not-an-int"})
|
|
with self.assertRaises(ValueError):
|
|
load_config({**base, "BROWSER_CDP_BINDING_VERSION": "0"})
|
|
with self.assertRaises(ValueError):
|
|
load_config({**base, "BROWSER_CDP_ALIAS": "bad alias"})
|
|
|
|
|
|
class DouyinReleaseRemediationTests(unittest.TestCase):
|
|
def test_browser_action_marker_is_persisted_in_profile_storage(self) -> None:
|
|
browser = DouyinBrowser()
|
|
browser._evaluate = Mock(side_effect=[{"runtime_id": "old"}, True, True])
|
|
self.assertEqual(browser.action_ownership("safe"), {"runtime_id": "old"})
|
|
browser.set_action_ownership(
|
|
"safe", {"runtime_id": "new", "operation_id": "op-1"}
|
|
)
|
|
browser.clear_action_ownership("safe", "op-1")
|
|
self.assertEqual(browser._evaluate.call_count, 3)
|
|
self.assertIn(
|
|
"__creatorhub_action_ownership_v1",
|
|
browser._evaluate.call_args_list[1].args[1],
|
|
)
|
|
|
|
def test_gateway_action_claim_rejects_marker_from_any_runtime(self) -> None:
|
|
gateway = Gateway.__new__(Gateway)
|
|
gateway._action_ownership_lock = threading.Lock()
|
|
gateway._uncertain_actions = {}
|
|
gateway.browser = Mock()
|
|
gateway.browser.action_ownership.return_value = {"runtime_id": "old-runtime"}
|
|
with self.assertRaises(RequestError):
|
|
gateway._claim_action("safe", "new-runtime", "op-1")
|
|
gateway.browser.clear_action_ownership.assert_not_called()
|
|
gateway.browser.set_action_ownership.assert_not_called()
|
|
|
|
def test_notice_keeps_raw_gateway_receipt_time(self) -> None:
|
|
notice = normalize_notice(
|
|
{"dm": {"message_id": "1", "from_user": {"uid": "2"}, "text": "hi"}},
|
|
"2026-09-15T00:00:00+00:00",
|
|
)
|
|
assert notice is not None
|
|
self.assertEqual(notice["gateway_received_at"], "2026-09-15T00:00:00+00:00")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|