Compare commits
10
Commits
9bc64699cb
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd943fe65 | ||
|
|
28527a0914 | ||
|
|
6fe2594c09 | ||
|
|
6a35775cca | ||
|
|
d84ed8ff2a | ||
|
|
755c26865c | ||
|
|
e7242a89f7 | ||
|
|
3311866431 | ||
|
|
8544ea30f4 | ||
|
|
7056e012a7 |
+5
-11
@@ -72,19 +72,13 @@ artifacts:
|
||||
- type: container-http
|
||||
name_template: "jarca-dashboard-{run_id}"
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
- streamlit
|
||||
- run
|
||||
- dashboard/app.py
|
||||
- --server.address
|
||||
- uvicorn
|
||||
- dashboard.server:app
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --server.port
|
||||
- --port
|
||||
- "8501"
|
||||
- --server.headless
|
||||
- "true"
|
||||
- --browser.gatherUsageStats
|
||||
- "false"
|
||||
- --proxy-headers
|
||||
container_port: 8501
|
||||
path: /_stcore/health
|
||||
expected_status: 200
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
@@ -91,16 +92,20 @@ async def login(request: Request) -> Response:
|
||||
if content_type != "application/x-www-form-urlencoded":
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return PlainTextResponse("Bad Request", status_code=400)
|
||||
body = await request.body()
|
||||
if len(body) > _MAX_LOGIN_BODY_BYTES:
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return PlainTextResponse("Bad Request", status_code=400)
|
||||
body = bytearray()
|
||||
async for chunk in request.stream():
|
||||
if len(body) + len(chunk) > _MAX_LOGIN_BODY_BYTES:
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return PlainTextResponse("Bad Request", status_code=400)
|
||||
body.extend(chunk)
|
||||
try:
|
||||
values = parse_qs(
|
||||
body.decode("utf-8"),
|
||||
bytes(body).decode("utf-8"),
|
||||
keep_blank_values=True,
|
||||
strict_parsing=True,
|
||||
max_num_fields=3,
|
||||
encoding="utf-8",
|
||||
errors="strict",
|
||||
)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
@@ -121,7 +126,9 @@ async def login(request: Request) -> Response:
|
||||
if not authentication.verify_credentials(
|
||||
settings.credentials, submitted_id, submitted_password
|
||||
):
|
||||
authentication.enforce_failed_login_delay(started_at=started_at)
|
||||
await asyncio.to_thread(
|
||||
authentication.enforce_failed_login_delay, started_at=started_at
|
||||
)
|
||||
authentication.log_auth_event("login_failed", correlation_id)
|
||||
return _redirect("/?auth_error=invalid")
|
||||
|
||||
|
||||
@@ -113,8 +113,12 @@ def verify_credentials(
|
||||
) -> bool:
|
||||
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
|
||||
return False
|
||||
id_matches = hmac.compare_digest(submitted_id, credentials.admin_id)
|
||||
password_matches = hmac.compare_digest(submitted_password, credentials.password)
|
||||
id_matches = hmac.compare_digest(
|
||||
submitted_id.encode("utf-8"), credentials.admin_id.encode("utf-8")
|
||||
)
|
||||
password_matches = hmac.compare_digest(
|
||||
submitted_password.encode("utf-8"), credentials.password.encode("utf-8")
|
||||
)
|
||||
return id_matches and password_matches
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: init-db
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- python
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
mountPath: /tmp
|
||||
containers:
|
||||
- name: mcp
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: JARCA_LEDGER_DB_PATH
|
||||
@@ -104,7 +104,7 @@ spec:
|
||||
- name: mcp-tmp
|
||||
mountPath: /tmp
|
||||
- name: dashboard
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- uvicorn
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schema": "jarca-ledger.review-evidence/v1",
|
||||
"issue": 6,
|
||||
"pull_request": 7,
|
||||
"source_review": 51,
|
||||
"finding": "D6-23",
|
||||
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
|
||||
"verified_at_utc": "2026-10-03T00:10:43Z",
|
||||
"source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b",
|
||||
"source_tree_clean_before_evidence_write": true,
|
||||
"environment": {
|
||||
"os": "Linux 6.18.34+rpt-rpi-v8",
|
||||
"architecture": "aarch64",
|
||||
"python": "3.13.5",
|
||||
"starlette": "1.3.1",
|
||||
"httpx": "0.28.1",
|
||||
"streamlit": "1.58.0",
|
||||
"uvicorn": "0.49.0",
|
||||
"itsdangerous": "2.2.0",
|
||||
"credentials": "synthetic test-only values; not recorded",
|
||||
"signing_secret": "synthetic test-only value; not recorded"
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"purpose": "Focused route, hardened cookie, event-loop offload, and failed-delay helper verification",
|
||||
"command": "uv run --extra dashboard --extra dev pytest -q tests/test_dashboard_auth_server.py tests/test_dashboard_authentication.py",
|
||||
"exit_code": 0,
|
||||
"result": "29 passed; one Starlette/httpx deprecation warning",
|
||||
"relevant_output": "............................. [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html"
|
||||
},
|
||||
{
|
||||
"purpose": "Real-delay concurrent failed login plus successful login and hardened cookie runtime probe",
|
||||
"command": "uv run --extra dashboard python - <<'PY'\nimport asyncio\nimport json\nimport os\nimport secrets\nimport time\n\nfrom starlette.requests import Request\n\nfrom dashboard import authentication\nfrom dashboard.auth_server import login\n\nadmin_id = \"synthetic-\" + secrets.token_hex(8)\nadmin_password = secrets.token_hex(16)\nos.environ[authentication.ADMIN_ID_ENV] = admin_id\nos.environ[authentication.ADMIN_PASSWORD_ENV] = admin_password\nos.environ[authentication.SIGNING_SECRET_ENV] = secrets.token_hex(32)\n\n\ndef request(password: str) -> Request:\n body = f\"admin_id={admin_id}&admin_password={password}\".encode()\n sent = False\n\n async def receive():\n nonlocal sent\n if sent:\n return {\"type\": \"http.disconnect\"}\n sent = True\n return {\"type\": \"http.request\", \"body\": body, \"more_body\": False}\n\n scope = {\n \"type\": \"http\",\n \"method\": \"POST\",\n \"scheme\": \"https\",\n \"path\": \"/api/auth/login\",\n \"raw_path\": b\"/api/auth/login\",\n \"query_string\": b\"\",\n \"headers\": [\n (b\"host\", b\"testserver\"),\n (b\"origin\", b\"https://testserver\"),\n (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n ],\n \"client\": (\"testclient\", 50000),\n \"server\": (\"testserver\", 443),\n }\n return Request(scope, receive)\n\n\nasync def main() -> None:\n wrong_password = secrets.token_hex(16)\n started = time.monotonic()\n failures = [asyncio.create_task(login(request(wrong_password))) for _ in range(2)]\n await asyncio.sleep(0.1)\n probe_elapsed = time.monotonic() - started\n success_started = time.monotonic()\n success = await login(request(admin_password))\n success_elapsed = time.monotonic() - success_started\n failed = await asyncio.gather(*failures)\n total_elapsed = time.monotonic() - started\n cookie = success.headers.get(\"set-cookie\", \"\")\n assert success.status_code == 303\n assert success.headers[\"location\"] == \"/\"\n assert all(marker in cookie for marker in [\"HttpOnly\", \"Secure\", \"SameSite=lax\", \"Path=/\", \"Max-Age=86400\"])\n assert all(response.status_code == 303 for response in failed)\n assert all(\"set-cookie\" not in response.headers for response in failed)\n assert probe_elapsed < 0.5\n assert success_elapsed < 0.5\n assert 0.95 <= total_elapsed < 1.75\n print(json.dumps({\n \"success\": True,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": success.status_code,\n \"successful_login_cookie_hardened\": True,\n \"failed_login_statuses\": [response.status_code for response in failed],\n \"failed_login_cookie_absent\": True,\n \"event_loop_probe_elapsed_seconds\": round(probe_elapsed, 6),\n \"successful_login_elapsed_seconds\": round(success_elapsed, 6),\n \"concurrent_failed_logins_total_elapsed_seconds\": round(total_elapsed, 6),\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": False,\n }, indent=2))\n\n\nasyncio.run(main())\nPY",
|
||||
"exit_code": 0,
|
||||
"result": {
|
||||
"success": true,
|
||||
"source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b",
|
||||
"successful_login_status": 303,
|
||||
"successful_login_cookie_hardened": true,
|
||||
"failed_login_statuses": [
|
||||
303,
|
||||
303
|
||||
],
|
||||
"failed_login_cookie_absent": true,
|
||||
"event_loop_probe_elapsed_seconds": 0.10056,
|
||||
"successful_login_elapsed_seconds": 0.001267,
|
||||
"concurrent_failed_logins_total_elapsed_seconds": 1.00614,
|
||||
"minimum_failed_login_delay_seconds": 1.0,
|
||||
"credentials_and_cookie_values_recorded": false
|
||||
},
|
||||
"relevant_output": "{\n \"success\": true,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": 303,\n \"successful_login_cookie_hardened\": true,\n \"failed_login_statuses\": [\n 303,\n 303\n ],\n \"failed_login_cookie_absent\": true,\n \"event_loop_probe_elapsed_seconds\": 0.10056,\n \"successful_login_elapsed_seconds\": 0.001267,\n \"concurrent_failed_logins_total_elapsed_seconds\": 1.00614,\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": false\n}"
|
||||
}
|
||||
],
|
||||
"hashes_sha256": {
|
||||
"dashboard/auth_server.py": "4b991fbe942b573f8be270538b7e9df81bca9ffed3a8982f09db0d95d26e66d1",
|
||||
"dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596",
|
||||
"tests/test_dashboard_auth_server.py": "8b96421af776b613867a8a5ab5dd962033b39d1cc616ba4e91cf3c4de63a1f0d",
|
||||
"tests/test_dashboard_authentication.py": "1ca7ecc4d4db55c674aa2934d665b121476ff5fa2e1f74071090a67e815af676",
|
||||
"pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
|
||||
},
|
||||
"redaction": {
|
||||
"credentials_recorded": false,
|
||||
"signing_secret_recorded": false,
|
||||
"token_or_cookie_value_recorded": false,
|
||||
"request_body_recorded": false,
|
||||
"raw_set_cookie_recorded": false
|
||||
},
|
||||
"notes": [
|
||||
"This artifact is an evidence-only descendant of source_revision; the application and test paths hashed above were not modified before these commands ran.",
|
||||
"The runtime probe exercised the changed streaming login path with synthetic credentials, confirmed hardened 24-hour cookie issuance, retained the real one-second failed-login delay, and showed the event loop and a successful login remained responsive while two failed requests waited.",
|
||||
"This D6-23 evidence does not replace or claim a fresh browser run; D6-22 remains supported by docs/evidence/issue-6-persistent-auth-https.json and the Review #51 change-impact analysis."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"id": "f003-unicode-success",
|
||||
"pointers": [
|
||||
"commands[0].argv",
|
||||
"commands[0].exit_code",
|
||||
"commands[0].output",
|
||||
"commands[0].started_at_utc",
|
||||
"commands[0].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "f003-generic-delayed-failure",
|
||||
"pointers": [
|
||||
"commands[0].argv",
|
||||
"commands[0].exit_code",
|
||||
"commands[0].output",
|
||||
"commands[0].started_at_utc",
|
||||
"commands[0].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "f003-malformed-utf8-400-no-cookie",
|
||||
"pointers": [
|
||||
"commands[0].argv",
|
||||
"commands[0].exit_code",
|
||||
"commands[0].output",
|
||||
"commands[0].started_at_utc",
|
||||
"commands[0].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "full-regression",
|
||||
"pointers": [
|
||||
"commands[1].argv",
|
||||
"commands[1].exit_code",
|
||||
"commands[1].output",
|
||||
"commands[1].started_at_utc",
|
||||
"commands[1].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static-and-source-integrity",
|
||||
"pointers": [
|
||||
"commands[2].argv",
|
||||
"commands[2].exit_code",
|
||||
"commands[2].output",
|
||||
"commands[3].argv",
|
||||
"commands[3].exit_code",
|
||||
"commands[3].output",
|
||||
"commands[4].argv",
|
||||
"commands[4].exit_code",
|
||||
"commands[4].output"
|
||||
]
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"argv": [
|
||||
".venv/bin/python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"-o",
|
||||
"addopts=",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"--basetemp=/tmp/jarca-f003-evidence-focused",
|
||||
"tests/test_dashboard_auth_server.py",
|
||||
"tests/test_dashboard_authentication.py"
|
||||
],
|
||||
"duration_seconds": 11.436193,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:55:21.516159Z",
|
||||
"id": "focused-tests",
|
||||
"output": "................................ [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n32 passed, 1 warning in 7.81s\n",
|
||||
"purpose": "F-003 Unicode success, delayed generic mismatch, malformed percent-encoded UTF-8, and credential helper verification",
|
||||
"started_at_utc": "2026-10-04T03:55:10.080020Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
".venv/bin/python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"-o",
|
||||
"addopts=",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"--basetemp=/tmp/jarca-f003-evidence-full"
|
||||
],
|
||||
"duration_seconds": 54.086422,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.670519Z",
|
||||
"id": "full-tests",
|
||||
"output": "........................................................................ [ 59%]\n...................ss........................ss... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n118 passed, 4 skipped, 1 warning in 49.12s\n",
|
||||
"purpose": "Complete repository regression suite",
|
||||
"started_at_utc": "2026-10-04T03:55:21.584157Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
".venv/bin/ruff",
|
||||
"check",
|
||||
"--no-cache",
|
||||
"dashboard/authentication.py",
|
||||
"dashboard/auth_server.py",
|
||||
"tests/test_dashboard_auth_server.py",
|
||||
"tests/test_dashboard_authentication.py"
|
||||
],
|
||||
"duration_seconds": 0.025974,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.770278Z",
|
||||
"id": "ruff",
|
||||
"output": "All checks passed!\n",
|
||||
"purpose": "Static lint for the exact F-003 source and test allowlist",
|
||||
"started_at_utc": "2026-10-04T03:56:15.744356Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
"uv",
|
||||
"lock",
|
||||
"--check",
|
||||
"--offline"
|
||||
],
|
||||
"duration_seconds": 0.059792,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.890498Z",
|
||||
"id": "lock-check",
|
||||
"output": "Resolved 72 packages in 3ms\n",
|
||||
"purpose": "Offline dependency lock consistency",
|
||||
"started_at_utc": "2026-10-04T03:56:15.830762Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad^",
|
||||
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad"
|
||||
],
|
||||
"duration_seconds": 0.033395,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.992735Z",
|
||||
"id": "source-diff-check",
|
||||
"output": "",
|
||||
"purpose": "Whitespace validation for the exact source commit",
|
||||
"started_at_utc": "2026-10-04T03:56:15.959398Z"
|
||||
}
|
||||
],
|
||||
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
|
||||
"environment": {
|
||||
"architecture": "aarch64",
|
||||
"child_environment_keys": [
|
||||
"HOME",
|
||||
"NO_COLOR",
|
||||
"PATH",
|
||||
"PYTHONDONTWRITEBYTECODE"
|
||||
],
|
||||
"credentials": "synthetic test-only values supplied by tests; not recorded",
|
||||
"dependencies": {
|
||||
"httpx": "0.28.1",
|
||||
"itsdangerous": "2.2.0",
|
||||
"pytest": "9.1.1",
|
||||
"starlette": "1.3.1",
|
||||
"streamlit": "1.58.0",
|
||||
"uvicorn": "0.49.0"
|
||||
},
|
||||
"os": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41",
|
||||
"python": "3.13.5",
|
||||
"signing_secret": "synthetic test-only value supplied by tests; not recorded"
|
||||
},
|
||||
"finding": "F-003",
|
||||
"issue": 6,
|
||||
"limitations": [
|
||||
"The focused adapter tests use Starlette TestClient rather than a newly executed external browser or deployed Compose/Kubernetes environment.",
|
||||
"The existing Starlette/httpx deprecation warning is retained and is unrelated to F-003."
|
||||
],
|
||||
"notes": [
|
||||
"This evidence-only descendant binds verification to source_revision; the listed source bytes and Git blob OIDs were unchanged before, during, and after every command.",
|
||||
"No command used credentials, signing secrets, authorization headers, raw cookies, or shell interpretation in argv."
|
||||
],
|
||||
"pull_request": 7,
|
||||
"redaction": {
|
||||
"credentials_recorded": false,
|
||||
"raw_set_cookie_recorded": false,
|
||||
"request_body_recorded": false,
|
||||
"signing_secret_recorded": false,
|
||||
"token_or_cookie_value_recorded": false
|
||||
},
|
||||
"schema": "jarca-ledger.review-evidence/v2",
|
||||
"source_review": 53,
|
||||
"source_revision": "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad",
|
||||
"source_tree_clean_before_evidence_write": true,
|
||||
"sources": {
|
||||
"dashboard/auth_server.py": {
|
||||
"git_blob_oid": "0546d582805f495c86dcdbb45510431f7699ecfd",
|
||||
"sha256": "94675feffbc0807a632d51a9feea8aa4d8834a37de2d8f32c50aa273d88ddab3"
|
||||
},
|
||||
"dashboard/authentication.py": {
|
||||
"git_blob_oid": "006d3c95661b64924a89aa952ff531095d4684db",
|
||||
"sha256": "260c3419040a883a76ced3fd1a1ff43f86763e608448fff57078c30a54c74213"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"git_blob_oid": "2892966feaaa8dfa07bf330997afc8315364a672",
|
||||
"sha256": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
|
||||
},
|
||||
"tests/test_dashboard_auth_server.py": {
|
||||
"git_blob_oid": "0508f54f74185fc4fda341fcdd2574cbadb0d203",
|
||||
"sha256": "6b226b8d0d207a84f788b090de67fe6ddf9e7c8e05218ce18d97aa4badac95f5"
|
||||
},
|
||||
"tests/test_dashboard_authentication.py": {
|
||||
"git_blob_oid": "4d16765c4ab64826144d4880ec27b489a02bc53d",
|
||||
"sha256": "d6758e91b2e275cb22f55929ce0bf4a775feecb23f720fa05151da1ebb4da036"
|
||||
},
|
||||
"uv.lock": {
|
||||
"git_blob_oid": "c92cadb5bf8ae12e603fa956177c5d7f12e58535",
|
||||
"sha256": "9582546bc7c97a25b5d40d66ee814203ae46c13e7263942d02008403abd33c73"
|
||||
}
|
||||
},
|
||||
"verified_at_utc": "2026-10-04T03:56:15.992735Z"
|
||||
}
|
||||
@@ -66,6 +66,8 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea
|
||||
| `Set-Cookie` | response header | `cookie` | success | hardened auth cookie, redacted value | persistent auth | 값은 민감 |
|
||||
| `Location` | response header | `string` | success/failure | `/` or generic error path | browser navigation | 비민감 |
|
||||
|
||||
발급 token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti` claim만 가진다. `sub=dashboard-admin`은 관리자 ID가 아닌 고정 비민감 role이고, token 값과 서명은 민감하므로 browser의 hardened cookie에만 저장한다. 성공한 login 시각에 `iat`와 `exp`를 한 번 고정하며 기본 TTL은 24시간, `remember_me=on`이면 30일이다. 활동, 일반 요청, rerun 또는 인증 복원은 token을 재발급하거나 두 시각을 연장하지 않는다.
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
@@ -148,7 +150,7 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `auth_cookie` | request cookie | `string` | success path | absent | signed token | browser proof | 민감 |
|
||||
| `auth_cookie` | request cookie | `string` | success path | absent | current configuration으로 서명을 검증할 수 있고 claim이 정확히 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`인 token | browser proof | 값과 서명은 민감; `sub`는 비민감 |
|
||||
|
||||
#### 출력
|
||||
|
||||
@@ -162,6 +164,8 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea
|
||||
|---|---|---|---|---|---|
|
||||
| `unauthenticated` | authentication | absent/invalid/expired/config failure | login 가능 | cleanup then show login | no protected work |
|
||||
|
||||
관리자 ID, password 또는 signing secret 변경은 server-only signing key binding을 바꾸므로 기존 token을 모두 무효화하고 cleanup으로 전이한다. DB session, token registry와 per-token revocation은 만들지 않는다.
|
||||
|
||||
## 호환성
|
||||
|
||||
- 유지할 기존 호출 방식: authenticated `page` query parameter, page dispatch, gateway와 MCP behavior
|
||||
@@ -170,6 +174,7 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea
|
||||
- 재시도 계약: credential 실패는 최소 1초 지연; origin/request 오류는 수정 후 재시도
|
||||
- 중복 처리 계약: login은 새 token 발급 가능; cleanup/logout deletion은 idempotent
|
||||
- 순서 보장: config → cookie verify → cleanup if invalid → DB/page dispatch only if valid
|
||||
- 만료 보장: login 성공 시 고정한 `iat`와 `exp`는 활동·일반 요청·rerun·복원에서 변경하거나 연장하지 않음
|
||||
|
||||
## 예시
|
||||
|
||||
|
||||
@@ -266,6 +266,26 @@ credential, secret, token, cookie가 UI·URL·log·session state에 나타나지
|
||||
|
||||
비민감 correlation ID 외 인증 비밀 상태가 없다.
|
||||
|
||||
### REQ-001.03-AC02 — 두 credential의 constant-time 비교
|
||||
|
||||
**판정 기준**
|
||||
|
||||
제출 ID와 password는 각각 `hmac.compare_digest`로 비교하며, 한 필드가 일치하지 않아도 다른 필드의 비교를 생략하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_authentication.py::test_verify_credentials_uses_constant_time_comparison_for_both_fields`에서 성공 및 두 필드가 모두 잘못된 경우에 두 비교의 호출 순서와 인자를 계측한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit
|
||||
- 백엔드: pytest monkeypatch
|
||||
- 케이스: 정상 credential, 잘못된 ID와 password
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
일반 동등 비교나 한 필드 실패에 따른 short-circuit를 사용하지 않는다.
|
||||
|
||||
### REQ-001.04-AC01 — 실패 지연과 일반 오류
|
||||
|
||||
**판정 기준**
|
||||
@@ -286,6 +306,26 @@ credential, secret, token, cookie가 UI·URL·log·session state에 나타나지
|
||||
|
||||
cookie·DB·gateway 상태가 변하지 않는다.
|
||||
|
||||
### REQ-001.04-AC02 — 성공 지연·잠금 없음
|
||||
|
||||
**판정 기준**
|
||||
|
||||
정상 credential은 실패 지연 함수를 사용하지 않고 인증되며, 실패 횟수나 잠금 만료 상태를 생성하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`dashboard/auth_server.py`의 login 경로와 authentication source를 검사하여 `enforce_failed_login_delay`가 credential 실패 분기에만 호출되고 failure counter·lock state가 없음을 확인한다. `tests/test_dashboard_authentication.py`의 `test_failed_login_delay_waits_only_for_remaining_minimum_duration` 및 `test_failed_login_delay_does_not_sleep_after_minimum_duration`, 그리고 `tests/test_dashboard_auth_server.py::test_successful_login_sets_hardened_24_hour_cookie`를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit/integration/static
|
||||
- 백엔드: pytest, source inspection
|
||||
- 케이스: 정상 credential, 실패 지연 helper, 반복 실패 후 재시도
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
애플리케이션 failure counter와 lock state를 유지하지 않는다.
|
||||
|
||||
### REQ-001.05-AC01 — Absolute TTL과 고정 subject
|
||||
|
||||
**판정 기준**
|
||||
@@ -406,6 +446,46 @@ README와 Kubernetes 문서에 HTTPS, proxy allowlist, rate limit, secret 주입
|
||||
|
||||
실제 credential·secret 값이 문서와 manifest에 없다.
|
||||
|
||||
### REQ-001.10-AC02 — 인증 후 기존 페이지 호환성
|
||||
|
||||
**판정 기준**
|
||||
|
||||
유효한 Dashboard 인증 cookie로 인증하면 기존 page query parameter를 유지한 채 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산 핵심 페이지가 예외 없이 예상 내용을 렌더링한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`와 `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: regression integration
|
||||
- 백엔드: Streamlit AppTest, synthetic SQLite
|
||||
- 케이스: 인증된 핵심 page query와 기존 gateway
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 SQLite schema, page query와 gateway contract를 변경하지 않는다.
|
||||
|
||||
### REQ-001.10-AC03 — MCP 인증 범위 분리
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Dashboard cookie 인증은 MCP endpoint의 인증을 의미하지 않으며 MCP server 진입점, transport와 port를 변경하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
Base-to-head diff에서 MCP 인증·진입점·transport·port를 담당하는 `jarca_ledger_mcp/server.py`, `jarca_ledger_mcp/adapters/mcp/**`, `tests/test_mcp_http_runtime.py`의 불변성을 확인하고 기존 stdio, streamable HTTP와 invalid transport 회귀 test를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: static/regression
|
||||
- 백엔드: Git diff, pytest
|
||||
- 케이스: MCP source, stdio, streamable HTTP, invalid transport
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Dashboard와 MCP 인증 경계를 결합하지 않으며 MCP 인증 상태를 변경하지 않는다.
|
||||
|
||||
### REQ-001.11-AC01 — 안전한 관찰성
|
||||
|
||||
**판정 기준**
|
||||
|
||||
@@ -36,8 +36,8 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
|
||||
| `login-01` | auth server | POST `/api/auth/login` | validate scheme/origin/body | request only | none | `login-02` | `login-reject` |
|
||||
| `login-02` | authentication | validated form | load three settings, fail closed | server memory | secret provider/environment | `login-03` | `config-fail` |
|
||||
| `login-03` | authentication | configured settings | constant-time compare ID/password | no persistent write | none | `login-04` | `login-fail` |
|
||||
| `login-04` | authentication | valid credential | issue signed claims with `sub=dashboard-admin` | server memory | none | `login-05` | `login-fail` |
|
||||
| `login-05` | auth server | signed token | set hardened cookie and redirect | browser cookie write | response | `gate-01` | `login-fail` |
|
||||
| `login-04` | authentication | valid credential | issue exactly `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`; fix `iat`/`exp` at login success for default 24 hours or `remember_me=on` 30 days | server memory | none | `login-05` | `login-fail` |
|
||||
| `login-05` | auth server | signed token | set hardened cookie with selected absolute expiry and redirect; later activity never reissues or extends it | browser cookie write | response | `gate-01` | `login-fail` |
|
||||
|
||||
### restore — Existing cookie restoration
|
||||
|
||||
@@ -49,7 +49,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
|
||||
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `gate-01` | dashboard app | page startup | call authentication before DB path/page dispatch | no DB access | none | `gate-02` | `unauthenticated` |
|
||||
| `gate-02` | authentication | current config and cookie | verify signature, exact claims, fixed role subject, TTL and rotation | no session write | none | `dashboard-01` | `cleanup-01` |
|
||||
| `gate-02` | authentication | current config and cookie | verify signature, exact `v`/`sub=dashboard-admin`/`iat`/`exp`/`jti` claims, current admin-ID/password/signing-secret binding and absolute `exp`; never refresh or extend on restoration, rerun or activity | no session or token write | none | `dashboard-01` | `cleanup-01` |
|
||||
| `dashboard-01` | dashboard app | accepted token | resolve DB path and dispatch requested page | existing SQLite reads/writes | gateway/page code | `authenticated` | `dashboard-fail` |
|
||||
|
||||
### cleanup — Invalid cookie cleanup
|
||||
@@ -82,7 +82,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
|
||||
|---|---|---|---|---|---|---|
|
||||
| `login-02` | missing/empty/read-error/short secret | yes | fix deployment config | no cookie | operator injects secret | `config-fail` |
|
||||
| `login-03` | wrong ID/password | yes | retry after generic error and 1s delay | no lock | external rate limit | `login-fail` |
|
||||
| `gate-02` | invalid/expired/tampered/claim/rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` |
|
||||
| `gate-02` | expired, malformed/tampered claims, or admin-ID/password/signing-secret rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` |
|
||||
| `cleanup-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `cleanup-reject` |
|
||||
| `logout-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `logout-reject` |
|
||||
|
||||
@@ -92,6 +92,8 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
|
||||
- 잠금·직렬화 단위: none for authentication; existing SQLite rules remain for financial writes
|
||||
- 순서 보장: config/cookie gate precedes DB path, gateway and page dispatch
|
||||
- 충돌 검출·처리: token is stateless; password/signing-secret/admin-ID rotation invalidates old claims
|
||||
- 만료 불변조건: login 성공 시 고정한 `iat`와 `exp`는 복원·rerun·활동 동안 바뀌지 않으며 token을 재발급하거나 만료를 연장하지 않음
|
||||
- rotation 불변조건: admin ID, password 또는 signing secret 변경은 이전 설정으로 발급한 모든 token을 무효화하고 `cleanup-01`로 전이
|
||||
|
||||
## 관찰 가능성
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Jarca Ledger MCP server."""
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.1.3"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jarca-ledger-mcp"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI wrapper for the Streamlit dashboard HTTP smoke gate.
|
||||
"""CI wrapper for the production Dashboard ASGI HTTP smoke gate.
|
||||
|
||||
Creates a synthetic fixture DB, starts Streamlit against it, waits for HTTP 200,
|
||||
and always attempts process cleanup. Output is intentionally status-only.
|
||||
Creates a synthetic fixture DB, starts the authenticated Streamlit ASGI wrapper,
|
||||
waits for HTTP 200, and always attempts process cleanup. Output is intentionally
|
||||
status-only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,7 +27,7 @@ from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run Streamlit dashboard HTTP smoke against a synthetic CI fixture DB.")
|
||||
parser = argparse.ArgumentParser(description="Run the Dashboard ASGI HTTP smoke against a synthetic CI fixture DB.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind")
|
||||
parser.add_argument("--port", type=int, default=18501, help="Deterministic smoke port")
|
||||
parser.add_argument("--timeout", type=int, default=45, help="Readiness timeout in seconds")
|
||||
@@ -75,21 +76,21 @@ def main() -> int:
|
||||
fixture = create_ci_fixture_db(fixture_path)
|
||||
env = os.environ.copy()
|
||||
env["JARCA_LEDGER_DB_PATH"] = str(fixture_path)
|
||||
env["JARCA_DASHBOARD_ADMIN_ID"] = "a"
|
||||
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = "p"
|
||||
env["JARCA_DASHBOARD_SIGNING_SECRET"] = "s" * 32
|
||||
env["FORWARDED_ALLOW_IPS"] = "127.0.0.1"
|
||||
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"streamlit",
|
||||
"run",
|
||||
"dashboard/app.py",
|
||||
"--server.address",
|
||||
"uvicorn",
|
||||
"dashboard.server:app",
|
||||
"--host",
|
||||
args.host,
|
||||
"--server.port",
|
||||
"--port",
|
||||
str(args.port),
|
||||
"--server.headless",
|
||||
"true",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
"--proxy-headers",
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from http.cookies import SimpleCookie
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from dashboard import authentication
|
||||
from dashboard.auth_server import create_auth_routes
|
||||
from dashboard.auth_server import create_auth_routes, login
|
||||
|
||||
|
||||
ADMIN_ID = "admin"
|
||||
@@ -83,6 +87,50 @@ def test_remember_me_sets_30_day_cookie(client: TestClient) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_unicode_credentials_can_login_through_adapter(client: TestClient, monkeypatch) -> None:
|
||||
unicode_id = "管理员"
|
||||
unicode_password = "päss🔐"
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": unicode_id, "admin_password": unicode_password},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/"
|
||||
assert f"{authentication.AUTH_COOKIE_NAME}=" in response.headers["set-cookie"]
|
||||
|
||||
|
||||
def test_unicode_credential_mismatch_is_generic_and_delayed(
|
||||
client: TestClient, monkeypatch
|
||||
) -> None:
|
||||
import time as real_time
|
||||
|
||||
unicode_id = "管理员"
|
||||
unicode_password = "päss🔐"
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
|
||||
monkeypatch.setattr(authentication.time, "sleep", real_time.sleep)
|
||||
|
||||
started = real_time.monotonic()
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": unicode_id, "admin_password": "неверный🔒"},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
elapsed = real_time.monotonic() - started
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=invalid"
|
||||
assert "set-cookie" not in response.headers
|
||||
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS * 0.9
|
||||
|
||||
|
||||
def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
@@ -100,6 +148,23 @@ def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
||||
assert ADMIN_ID not in response.headers["location"]
|
||||
|
||||
|
||||
def test_login_rejects_malformed_percent_encoded_utf8_without_cookie(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
content=b"admin_id=%FF&admin_password=wrong",
|
||||
headers={
|
||||
"origin": "https://testserver",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None:
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
|
||||
|
||||
@@ -218,3 +283,104 @@ def test_cookie_token_round_trip(client: TestClient) -> None:
|
||||
assert authentication.verify_persistent_token(
|
||||
authentication.load_auth_settings(), token
|
||||
) is not None
|
||||
|
||||
|
||||
def _streaming_login_request(
|
||||
chunks: list[bytes],
|
||||
) -> tuple[Request, Callable[[], int]]:
|
||||
messages = [
|
||||
{
|
||||
"type": "http.request",
|
||||
"body": chunk,
|
||||
"more_body": index < len(chunks) - 1,
|
||||
}
|
||||
for index, chunk in enumerate(chunks)
|
||||
]
|
||||
calls = 0
|
||||
|
||||
async def receive():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return messages.pop(0)
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"scheme": "https",
|
||||
"path": "/api/auth/login",
|
||||
"raw_path": b"/api/auth/login",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"origin", b"https://testserver"),
|
||||
(b"content-type", b"application/x-www-form-urlencoded"),
|
||||
],
|
||||
"client": ("testclient", 50000),
|
||||
"server": ("testserver", 443),
|
||||
}
|
||||
return Request(scope, receive), lambda: calls
|
||||
|
||||
|
||||
def test_login_offloads_failed_login_delay_from_event_loop(monkeypatch) -> None:
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
probe_ran = threading.Event()
|
||||
probe_before_release = threading.Event()
|
||||
|
||||
def blocking_delay(*, started_at):
|
||||
entered.set()
|
||||
if probe_ran.wait(timeout=1):
|
||||
probe_before_release.set()
|
||||
release.set()
|
||||
|
||||
monkeypatch.setattr(authentication, "enforce_failed_login_delay", blocking_delay)
|
||||
|
||||
async def exercise() -> None:
|
||||
request, _ = _streaming_login_request([b"admin_id=admin&admin_password=wrong"])
|
||||
task = asyncio.create_task(login(request))
|
||||
|
||||
async def probe() -> None:
|
||||
probe_ran.set()
|
||||
|
||||
asyncio.create_task(probe())
|
||||
assert await asyncio.to_thread(entered.wait, 1)
|
||||
release.set()
|
||||
response = await task
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=invalid"
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert probe_before_release.is_set()
|
||||
|
||||
|
||||
def test_login_rejects_exactly_one_byte_over_streaming_limit(monkeypatch) -> None:
|
||||
def unexpected_verification(*_args, **_kwargs):
|
||||
raise AssertionError("oversized body reached credential verification")
|
||||
|
||||
monkeypatch.setattr(authentication, "verify_credentials", unexpected_verification)
|
||||
request, calls = _streaming_login_request([b"x" * 4097])
|
||||
response = asyncio.run(login(request))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "set-cookie" not in response.headers
|
||||
assert calls() == 1
|
||||
|
||||
|
||||
def test_login_accepts_body_at_streaming_limit() -> None:
|
||||
prefix = b"admin_id=admin&admin_password="
|
||||
body = prefix + (b"x" * (4096 - len(prefix)))
|
||||
assert len(body) == 4096
|
||||
request, calls = _streaming_login_request([body])
|
||||
response = asyncio.run(login(request))
|
||||
|
||||
assert response.status_code == 303
|
||||
assert calls() == 1
|
||||
|
||||
|
||||
def test_login_stops_reading_stream_after_limit_is_exceeded() -> None:
|
||||
request, calls = _streaming_login_request([b"x" * 4096, b"y", b"z"])
|
||||
response = asyncio.run(login(request))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert calls() == 2
|
||||
|
||||
@@ -63,20 +63,26 @@ def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read():
|
||||
|
||||
def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch):
|
||||
credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD)
|
||||
comparisons: list[tuple[str, str]] = []
|
||||
comparisons: list[tuple[bytes, bytes]] = []
|
||||
|
||||
def record_compare(left: str, right: str) -> bool:
|
||||
def record_compare(left: bytes, right: bytes) -> bool:
|
||||
comparisons.append((left, right))
|
||||
return left == right
|
||||
|
||||
monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare)
|
||||
|
||||
assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True
|
||||
assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)]
|
||||
assert comparisons == [
|
||||
(ADMIN_ID.encode("utf-8"), ADMIN_ID.encode("utf-8")),
|
||||
(ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")),
|
||||
]
|
||||
|
||||
comparisons.clear()
|
||||
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
|
||||
assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)]
|
||||
assert comparisons == [
|
||||
(b"wrong-id", ADMIN_ID.encode("utf-8")),
|
||||
(b"wrong-password", ADMIN_PASSWORD.encode("utf-8")),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_login_delay_waits_only_for_remaining_minimum_duration():
|
||||
|
||||
Reference in New Issue
Block a user