Add game data capture vertical slice
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.routing import Mount
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.web.app import (
|
||||
CaptureRecord,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
MediaDescriptor,
|
||||
MutationOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import new_bootstrap_token
|
||||
|
||||
_CAPTURE_ID = "capture-00000000000000000000000000000001"
|
||||
_LABEL: dict[str, object] = {
|
||||
"family": "weapon",
|
||||
"view": "detail",
|
||||
"gear_slot": None,
|
||||
"record_types": ["weapon", "weapon_talent"],
|
||||
"confidence": 1.0,
|
||||
"abstention_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeControl:
|
||||
armed: bool = False
|
||||
starts: int = 0
|
||||
stops: int = 0
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(self.armed, 0, 64)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
if self.armed:
|
||||
return "already_armed"
|
||||
self.starts += 1
|
||||
self.armed = True
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
if not self.armed:
|
||||
return "already_stopped"
|
||||
self.stops += 1
|
||||
self.armed = False
|
||||
return "stopped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCorpus:
|
||||
records: dict[str, CaptureRecord] = field(
|
||||
default_factory=lambda: {
|
||||
_CAPTURE_ID: CaptureRecord(_CAPTURE_ID, "stored", "2026-07-31T00:00:00Z", None)
|
||||
}
|
||||
)
|
||||
calls: list[str] = field(default_factory=list)
|
||||
media: MediaDescriptor | None = None
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
self.calls.append("list")
|
||||
return tuple(self.records.values())[:limit]
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
self.calls.append("get")
|
||||
return self.records.get(capture_id)
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
self.calls.append("media")
|
||||
if capture_id not in self.records:
|
||||
return None
|
||||
if self.media is not None:
|
||||
return self.media
|
||||
return MediaDescriptor("image/png", 4, [b"data"])
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
self.calls.append("label")
|
||||
if capture_id not in self.records:
|
||||
return "not_found"
|
||||
old = self.records[capture_id]
|
||||
self.records[capture_id] = CaptureRecord(
|
||||
old.capture_id, old.state, old.received_at, dict(classification)
|
||||
)
|
||||
return "updated"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
self.calls.append("reject")
|
||||
return "updated" if capture_id in self.records else "not_found"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
self.calls.append("delete")
|
||||
return "retained" if capture_id in self.records else "not_found"
|
||||
|
||||
|
||||
def _client(control: FakeControl, corpus: FakeCorpus) -> tuple[TestClient, str]:
|
||||
token = new_bootstrap_token()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=control,
|
||||
corpus=corpus,
|
||||
bootstrap_token=token,
|
||||
)
|
||||
return TestClient(app, base_url="http://127.0.0.1:8765"), token
|
||||
|
||||
|
||||
def _bootstrap(client: TestClient, token: str) -> str:
|
||||
response = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return str(response.json()["csrf_token"])
|
||||
|
||||
|
||||
def _mutation_headers(csrf: str) -> dict[str, str]:
|
||||
return {"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf}
|
||||
|
||||
|
||||
def test_factory_and_authenticated_gets_never_arm_capture() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
|
||||
assert control.starts == 0
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
csrf = _bootstrap(client, token)
|
||||
assert client.get("/api/session").status_code == 200
|
||||
assert client.get("/api/captures").status_code == 200
|
||||
assert client.get(f"/api/captures/{_CAPTURE_ID}").status_code == 200
|
||||
assert control.starts == 0
|
||||
assert csrf
|
||||
|
||||
|
||||
def test_static_contract_is_package_local_and_does_not_accept_a_filesystem_root() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=control,
|
||||
corpus=corpus,
|
||||
bootstrap_token=new_bootstrap_token(),
|
||||
)
|
||||
static_mount = next(
|
||||
route for route in app.routes if isinstance(route, Mount) and route.path == "/static"
|
||||
)
|
||||
|
||||
assert isinstance(static_mount.app, StaticFiles)
|
||||
assert static_mount.app.follow_symlink is False
|
||||
assert static_mount.app.directory is not None
|
||||
assert Path(static_mount.app.directory).name == "static"
|
||||
assert not Path(
|
||||
static_mount.app.get_path(
|
||||
cast(Scope, {"path": "/static/../../etc/passwd", "root_path": ""})
|
||||
)
|
||||
).is_absolute()
|
||||
|
||||
|
||||
def test_bootstrap_cookie_is_host_only_http_only_and_never_echoes_bootstrap_token() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
|
||||
response = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert token not in response.text
|
||||
cookie = response.headers["set-cookie"].lower()
|
||||
assert "httponly" in cookie
|
||||
assert "samesite=strict" in cookie
|
||||
assert "domain=" not in cookie
|
||||
assert token not in cookie
|
||||
assert response.json().keys() == {"csrf_token"}
|
||||
|
||||
|
||||
def test_start_is_explicit_csrf_protected_idempotent_and_rotates_session() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
csrf = _bootstrap(client, token)
|
||||
old_session = str(client.cookies["capture_session"])
|
||||
|
||||
response = client.post(
|
||||
"/api/session/start", headers=_mutation_headers(csrf), json={"confirmation": "START"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["result"] == "started"
|
||||
assert control.starts == 1
|
||||
next_csrf = str(response.json()["csrf_token"])
|
||||
stale = client.post(
|
||||
"/api/session/start", headers=_mutation_headers(csrf), json={"confirmation": "START"}
|
||||
)
|
||||
assert stale.status_code == 403
|
||||
assert control.starts == 1
|
||||
expired_session = client.post(
|
||||
"/api/session/start",
|
||||
headers={**_mutation_headers(next_csrf), "Cookie": f"capture_session={old_session}"},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
assert expired_session.status_code == 401
|
||||
assert control.starts == 1
|
||||
duplicate = client.post(
|
||||
"/api/session/start", headers=_mutation_headers(next_csrf), json={"confirmation": "START"}
|
||||
)
|
||||
assert duplicate.status_code == 200
|
||||
assert duplicate.json()["result"] == "already_armed"
|
||||
assert control.starts == 1
|
||||
|
||||
|
||||
def test_label_reject_and_retention_deletion_use_closed_confirmed_contracts() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
csrf = _bootstrap(client, token)
|
||||
|
||||
labeled = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/label", headers=_mutation_headers(csrf), json=_LABEL
|
||||
)
|
||||
assert labeled.status_code == 200
|
||||
csrf = str(labeled.json()["csrf_token"])
|
||||
detail = client.get(f"/api/captures/{_CAPTURE_ID}")
|
||||
assert detail.json()["capture"]["classification"] == _LABEL
|
||||
rejected = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/reject",
|
||||
headers=_mutation_headers(csrf),
|
||||
json={"confirmation": "REJECT"},
|
||||
)
|
||||
assert rejected.status_code == 200
|
||||
csrf = str(rejected.json()["csrf_token"])
|
||||
denied = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/delete",
|
||||
headers=_mutation_headers(csrf),
|
||||
json={"confirmation": "delete"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
assert "delete" not in corpus.calls
|
||||
retained = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/delete",
|
||||
headers=_mutation_headers(csrf),
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
assert retained.status_code == 409
|
||||
assert retained.json() == {"error": "retained"}
|
||||
assert corpus.calls.count("label") == 1
|
||||
assert corpus.calls.count("reject") == 1
|
||||
assert corpus.calls.count("delete") == 1
|
||||
|
||||
|
||||
def test_opaque_media_route_has_private_nosniff_response_and_no_path_parameter() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
_bootstrap(client, token)
|
||||
|
||||
response = client.get(f"/api/captures/{_CAPTURE_ID}/media")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"data"
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
assert response.headers["cache-control"] == "private, no-store"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert client.get("/api/captures/../../etc/passwd/media").status_code == 404
|
||||
assert corpus.calls.count("media") == 1
|
||||
|
||||
|
||||
class RaisingChunks:
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
raise RuntimeError("raw-secret /private/capture/path")
|
||||
|
||||
|
||||
def test_malformed_media_streams_fail_before_success_headers_or_content() -> None:
|
||||
descriptors = (
|
||||
MediaDescriptor("image/png", 4, [b"abc"]),
|
||||
MediaDescriptor("image/png", 3, [b"abcd"]),
|
||||
MediaDescriptor("image/png", 3, [b""]),
|
||||
MediaDescriptor("image/png", 3, cast(Iterable[bytes], ["bad"])),
|
||||
MediaDescriptor("image/png", 3, RaisingChunks()),
|
||||
)
|
||||
for descriptor in descriptors:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus(media=descriptor)
|
||||
client, token = _client(control, corpus)
|
||||
_bootstrap(client, token)
|
||||
|
||||
response = client.get(f"/api/captures/{_CAPTURE_ID}/media")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "service_unavailable"}
|
||||
assert "raw-secret" not in response.text
|
||||
assert "/private/capture/path" not in response.text
|
||||
|
||||
|
||||
class FailingCorpus(FakeCorpus):
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
raise RuntimeError("raw-secret /private/capture/path")
|
||||
|
||||
|
||||
def test_port_exceptions_are_sanitized_without_an_uncaught_client_error() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FailingCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
_bootstrap(client, token)
|
||||
|
||||
response = client.get("/api/captures")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "service_unavailable"}
|
||||
assert "raw-secret" not in response.text
|
||||
assert "/private/capture/path" not in response.text
|
||||
|
||||
|
||||
def test_taxonomy_payload_rejects_unknown_and_ambiguous_values_before_corpus_mutation() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
csrf = _bootstrap(client, token)
|
||||
unsupported = dict(_LABEL)
|
||||
unsupported["view"] = "not-a-view"
|
||||
unsupported["record_types"] = ["gear"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/label", headers=_mutation_headers(csrf), json=unsupported
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "label" not in corpus.calls
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.web.app import (
|
||||
CaptureCorpusPort,
|
||||
CaptureRecord,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
MediaDescriptor,
|
||||
MutationOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import (
|
||||
LocalAuthority,
|
||||
LocalRequestLimitsMiddleware,
|
||||
RequestLimits,
|
||||
new_bootstrap_token,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Control:
|
||||
start_calls: int = 0
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(False, 0, 1)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
self.start_calls += 1
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
return "already_stopped"
|
||||
|
||||
|
||||
class Corpus(CaptureCorpusPort):
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
return ()
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
return None
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
return None
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
|
||||
def _app(
|
||||
control: Control, *, now: Callable[[], float] | None = None, max_body: int = 16 * 1024
|
||||
) -> tuple[FastAPI, str]:
|
||||
token = new_bootstrap_token()
|
||||
config = WebConfig(ServerConfig(), request_limits=RequestLimits(max_body_bytes=max_body))
|
||||
if now is None:
|
||||
app = create_app(
|
||||
config=config,
|
||||
capture_control=control,
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=token,
|
||||
)
|
||||
else:
|
||||
app = create_app(
|
||||
config=config,
|
||||
capture_control=control,
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=token,
|
||||
now=now,
|
||||
)
|
||||
return app, token
|
||||
|
||||
|
||||
def _bootstrap(client: TestClient, token: str) -> str:
|
||||
result = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
assert result.status_code == 200
|
||||
return str(result.json()["csrf_token"])
|
||||
|
||||
|
||||
def test_host_origin_and_csrf_fail_before_start_side_effect() -> None:
|
||||
control = Control()
|
||||
app, token = _app(control)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
csrf = _bootstrap(client, token)
|
||||
|
||||
bad_host = client.post(
|
||||
"/api/session/start",
|
||||
headers={"Host": "evil.test:8765", "Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
missing_origin = client.post(
|
||||
"/api/session/start", headers={"X-CSRF-Token": csrf}, json={"confirmation": "START"}
|
||||
)
|
||||
cross_origin = client.post(
|
||||
"/api/session/start",
|
||||
headers={"Origin": "http://evil.test", "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
bad_csrf = client.post(
|
||||
"/api/session/start",
|
||||
headers={"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": "x" * 43},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
|
||||
assert bad_host.status_code == 400
|
||||
assert missing_origin.status_code == 403
|
||||
assert cross_origin.status_code == 403
|
||||
assert bad_csrf.status_code == 403
|
||||
assert control.start_calls == 0
|
||||
|
||||
|
||||
def test_session_expiry_and_process_restart_invalidate_cookie() -> None:
|
||||
control = Control()
|
||||
clock = Clock()
|
||||
app, token = _app(control, now=clock)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
_bootstrap(client, token)
|
||||
assert client.get("/api/session").status_code == 200
|
||||
clock.value = 901
|
||||
assert client.get("/api/session").status_code == 401
|
||||
|
||||
new_app, _ = _app(control)
|
||||
restarted = TestClient(new_app, base_url="http://127.0.0.1:8765")
|
||||
restarted.cookies.update(client.cookies)
|
||||
assert restarted.get("/api/session").status_code == 401
|
||||
|
||||
|
||||
def test_duplicate_json_and_form_payloads_fail_before_start_side_effect() -> None:
|
||||
control = Control()
|
||||
app, token = _app(control)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
csrf = _bootstrap(client, token)
|
||||
headers = {"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf}
|
||||
|
||||
duplicate_json = client.post(
|
||||
"/api/session/start",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
content=b'{"confirmation":"START","confirmation":"START"}',
|
||||
)
|
||||
form = client.post(
|
||||
"/api/session/start",
|
||||
headers={**headers, "Content-Type": "application/x-www-form-urlencoded"},
|
||||
content=b"confirmation=START&confirmation=START",
|
||||
)
|
||||
|
||||
assert duplicate_json.status_code == 403
|
||||
assert form.status_code == 403
|
||||
assert control.start_calls == 0
|
||||
|
||||
|
||||
def test_ipv6_host_requires_bracketed_exact_authority() -> None:
|
||||
control = Control()
|
||||
token = new_bootstrap_token()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig(host="::1", port=8765)),
|
||||
capture_control=control,
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=token,
|
||||
)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
|
||||
accepted = client.get("/health", headers={"Host": "[::1]:8765"})
|
||||
rejected = client.get("/health", headers={"Host": "::1:8765"})
|
||||
|
||||
assert accepted.status_code == 200
|
||||
assert rejected.status_code == 400
|
||||
|
||||
|
||||
def test_all_success_and_error_responses_have_local_only_security_headers() -> None:
|
||||
control = Control()
|
||||
app, _ = _app(control)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
responses = (
|
||||
client.get("/health"),
|
||||
client.get("/health", headers={"Host": "evil.test:8765"}),
|
||||
client.get("/api/session"),
|
||||
)
|
||||
for response in responses:
|
||||
csp = response.headers["content-security-policy"]
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "style-src 'self'" in csp
|
||||
assert "font-src 'self'" in csp
|
||||
assert "img-src 'self'" in csp
|
||||
assert "media-src 'self'" in csp
|
||||
assert "connect-src 'self'" in csp
|
||||
assert "object-src 'none'" in csp
|
||||
assert "base-uri 'none'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert response.headers["referrer-policy"] == "no-referrer"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
|
||||
|
||||
async def _call_raw(app: ASGIApp, scope: Scope, messages: list[Message]) -> list[Message]:
|
||||
sent: list[Message] = []
|
||||
|
||||
async def receive() -> Message:
|
||||
return messages.pop(0)
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
|
||||
await app(scope, receive, send)
|
||||
return sent
|
||||
|
||||
|
||||
def _scope(path: str, headers: list[tuple[bytes, bytes]], *, method: str = "POST") -> Scope:
|
||||
return {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": method,
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"headers": headers,
|
||||
"client": ("127.0.0.1", 50000),
|
||||
"server": ("127.0.0.1", 8765),
|
||||
}
|
||||
|
||||
|
||||
def test_declared_streamed_duplicate_and_disconnect_requests_fail_before_start() -> None:
|
||||
control = Control()
|
||||
app, _ = _app(control, max_body=32)
|
||||
base_headers = [(b"host", b"127.0.0.1:8765"), (b"content-type", b"application/json")]
|
||||
|
||||
declared = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope("/api/session/start", [*base_headers, (b"content-length", b"33")]),
|
||||
[{"type": "http.request", "body": b"", "more_body": False}],
|
||||
)
|
||||
)
|
||||
duplicated = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope("/api/session/start", [*base_headers, (b"host", b"127.0.0.1:8765")]),
|
||||
[{"type": "http.request", "body": b"{}", "more_body": False}],
|
||||
)
|
||||
)
|
||||
streamed = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope(
|
||||
"/api/bootstrap",
|
||||
[*base_headers, (b"origin", b"http://127.0.0.1:8765")],
|
||||
),
|
||||
[
|
||||
{"type": "http.request", "body": b'{"token":"', "more_body": True},
|
||||
{"type": "http.request", "body": b"x" * 24, "more_body": False},
|
||||
],
|
||||
)
|
||||
)
|
||||
disconnected = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope(
|
||||
"/api/bootstrap",
|
||||
[*base_headers, (b"origin", b"http://127.0.0.1:8765")],
|
||||
),
|
||||
[{"type": "http.disconnect"}],
|
||||
)
|
||||
)
|
||||
|
||||
assert declared[0]["status"] == 413
|
||||
assert duplicated[0]["status"] == 400
|
||||
assert streamed[0]["status"] == 413
|
||||
assert disconnected[0]["status"] == 400
|
||||
assert control.start_calls == 0
|
||||
|
||||
|
||||
def test_post_completion_disconnect_is_normal_for_streaming_response_lifecycle() -> None:
|
||||
async def streaming_lifecycle(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await receive()
|
||||
assert (await receive())["type"] == "http.disconnect"
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
guarded = LocalRequestLimitsMiddleware(
|
||||
streaming_lifecycle,
|
||||
authority=LocalAuthority("127.0.0.1", 8765),
|
||||
limits=RequestLimits(),
|
||||
)
|
||||
sent = asyncio.run(
|
||||
_call_raw(
|
||||
guarded,
|
||||
_scope("/stream", [(b"host", b"127.0.0.1:8765")], method="GET"),
|
||||
[
|
||||
{"type": "http.request", "body": b"", "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert sent[0]["status"] == 200
|
||||
Reference in New Issue
Block a user