Compare commits
5
Commits
755c26865c
..
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd943fe65 | ||
|
|
28527a0914 | ||
|
|
6fe2594c09 | ||
|
|
6a35775cca | ||
|
|
d84ed8ff2a |
+5
-11
@@ -72,19 +72,13 @@ artifacts:
|
|||||||
- type: container-http
|
- type: container-http
|
||||||
name_template: "jarca-dashboard-{run_id}"
|
name_template: "jarca-dashboard-{run_id}"
|
||||||
command:
|
command:
|
||||||
- python
|
- uvicorn
|
||||||
- -m
|
- dashboard.server:app
|
||||||
- streamlit
|
- --host
|
||||||
- run
|
|
||||||
- dashboard/app.py
|
|
||||||
- --server.address
|
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
- --server.port
|
- --port
|
||||||
- "8501"
|
- "8501"
|
||||||
- --server.headless
|
- --proxy-headers
|
||||||
- "true"
|
|
||||||
- --browser.gatherUsageStats
|
|
||||||
- "false"
|
|
||||||
container_port: 8501
|
container_port: 8501
|
||||||
path: /_stcore/health
|
path: /_stcore/health
|
||||||
expected_status: 200
|
expected_status: 200
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ async def login(request: Request) -> Response:
|
|||||||
keep_blank_values=True,
|
keep_blank_values=True,
|
||||||
strict_parsing=True,
|
strict_parsing=True,
|
||||||
max_num_fields=3,
|
max_num_fields=3,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="strict",
|
||||||
)
|
)
|
||||||
except (UnicodeDecodeError, ValueError):
|
except (UnicodeDecodeError, ValueError):
|
||||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||||
|
|||||||
@@ -113,8 +113,12 @@ def verify_credentials(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
|
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
|
||||||
return False
|
return False
|
||||||
id_matches = hmac.compare_digest(submitted_id, credentials.admin_id)
|
id_matches = hmac.compare_digest(
|
||||||
password_matches = hmac.compare_digest(submitted_password, credentials.password)
|
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
|
return id_matches and password_matches
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: init-db
|
- 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
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- python
|
- python
|
||||||
@@ -62,7 +62,7 @@ spec:
|
|||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
containers:
|
containers:
|
||||||
- name: mcp
|
- 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
|
imagePullPolicy: IfNotPresent
|
||||||
env:
|
env:
|
||||||
- name: JARCA_LEDGER_DB_PATH
|
- name: JARCA_LEDGER_DB_PATH
|
||||||
@@ -104,7 +104,7 @@ spec:
|
|||||||
- name: mcp-tmp
|
- name: mcp-tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
- name: dashboard
|
- 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
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- uvicorn
|
- uvicorn
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Jarca Ledger MCP server."""
|
"""Jarca Ledger MCP server."""
|
||||||
|
|
||||||
__version__ = "0.1.2"
|
__version__ = "0.1.3"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jarca-ledger-mcp"
|
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"
|
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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,
|
Creates a synthetic fixture DB, starts the authenticated Streamlit ASGI wrapper,
|
||||||
and always attempts process cleanup. Output is intentionally status-only.
|
waits for HTTP 200, and always attempts process cleanup. Output is intentionally
|
||||||
|
status-only.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ from scripts.ci_fixture_db import create_ci_fixture_db
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
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("--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("--port", type=int, default=18501, help="Deterministic smoke port")
|
||||||
parser.add_argument("--timeout", type=int, default=45, help="Readiness timeout in seconds")
|
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)
|
fixture = create_ci_fixture_db(fixture_path)
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["JARCA_LEDGER_DB_PATH"] = str(fixture_path)
|
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"
|
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"streamlit",
|
"uvicorn",
|
||||||
"run",
|
"dashboard.server:app",
|
||||||
"dashboard/app.py",
|
"--host",
|
||||||
"--server.address",
|
|
||||||
args.host,
|
args.host,
|
||||||
"--server.port",
|
"--port",
|
||||||
str(args.port),
|
str(args.port),
|
||||||
"--server.headless",
|
"--proxy-headers",
|
||||||
"true",
|
|
||||||
"--browser.gatherUsageStats",
|
|
||||||
"false",
|
|
||||||
]
|
]
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
|
|||||||
@@ -87,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(
|
def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -104,6 +148,23 @@ def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
|||||||
assert ADMIN_ID not in response.headers["location"]
|
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:
|
def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None:
|
||||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
|
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch):
|
||||||
credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD)
|
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))
|
comparisons.append((left, right))
|
||||||
return left == right
|
return left == right
|
||||||
|
|
||||||
monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare)
|
monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare)
|
||||||
|
|
||||||
assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True
|
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()
|
comparisons.clear()
|
||||||
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
|
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():
|
def test_failed_login_delay_waits_only_for_remaining_minimum_duration():
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jarca-ledger-mcp"
|
name = "jarca-ledger-mcp"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|||||||
Reference in New Issue
Block a user