5 Commits
11 changed files with 331 additions and 36 deletions
+5 -11
View File
@@ -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
+2
View File
@@ -104,6 +104,8 @@ async def login(request: Request) -> Response:
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)
+6 -2
View File
@@ -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
+3 -3
View File
@@ -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,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 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.2"
__version__ = "0.1.3"
+1 -1
View File
@@ -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"
+14 -13
View File
@@ -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,
+61
View File
@@ -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(
client: TestClient,
) -> 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"]
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)
+10 -4
View File
@@ -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():
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "jarca-ledger-mcp"
version = "0.1.2"
version = "0.1.3"
source = { editable = "." }
dependencies = [
{ name = "mcp" },