17 Commits
Author SHA1 Message Date
gitea-release-agent 5813dd4841 chore(release): prepare v0.1.4
Merge the independently reviewed v0.1.4 release preparation.
2026-10-06 01:20:20 +00:00
genichin 37e8d18da6 [verified] chore(release): prepare v0.1.4 2026-10-06 10:19:25 +09:00
genichin 9afea37424 Merge pull request 'fix: 메뉴 전환 시 대시보드 셸 유지 (#9)' (#10) from issue-9-dashboard-menu-transition into main
Reviewed-on: #10
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-06 01:03:48 +00:00
gitea-impl-agent 1ebc53d152 docs(evidence): bind PR 10 rework verification 2026-10-05 08:45:31 +00:00
gitea-impl-agent f01dc182ec fix(dashboard): address PR review 56 2026-10-05 08:41:33 +00:00
gitea-impl-agent a02aceee41 docs(requirements): link dashboard transition PR 2026-10-05 00:51:45 +00:00
gitea-impl-agent 96e11812ce fix(dashboard): keep shell during menu transitions
Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9.
2026-10-05 00:49:28 +00:00
gitea-release-agent ffd943fe65 chore(release): prepare v0.1.3
Merge the independently reviewed v0.1.3 release preparation.
2026-10-04 07:34:51 +00:00
genichin 28527a0914 [verified] chore(release): prepare v0.1.3 2026-10-04 16:32:23 +09:00
genichin 6fe2594c09 Merge pull request 'feat(dashboard): persist administrator login' (#7) from gitea-agent/issue-6-dashboard-persistent-auth into main
Reviewed-on: #7
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-04 07:13:11 +00:00
gitea-impl-agent 6a35775cca [verified] docs(evidence): record F-003 verification 2026-10-04 03:56:53 +00:00
gitea-impl-agent d84ed8ff2a [verified] fix(auth): support Unicode credentials 2026-10-04 03:53:47 +00:00
gitea-impl-agent 755c26865c docs(auth): preserve login runtime evidence 2026-10-03 00:17:44 +00:00
gitea-impl-agent e7242a89f7 fix(auth): bound asynchronous login handling 2026-10-02 12:28:52 +00:00
gitea-impl-agent 3311866431 docs(auth): restore successful login contract 2026-10-02 07:35:01 +00:00
gitea-impl-agent 8544ea30f4 docs(auth): restore legacy acceptance contracts 2026-10-02 06:09:11 +00:00
gitea-impl-agent 7056e012a7 docs(auth): complete persistent token contracts 2026-10-02 04:48:06 +00:00
28 changed files with 3840 additions and 100 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
+14 -6
View File
@@ -12,7 +12,15 @@ if __package__ in (None, ""):
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401
from dashboard.common import (
clear_cache,
get_db_path,
get_transfer_review_items, # noqa: F401
inject_app_css,
render_dashboard_body,
render_sidebar_nav,
validate_dashboard_page,
) # noqa: F401
from dashboard.authentication import require_dashboard_authentication
from dashboard.page_views.accounts import page_accounts
from dashboard.page_views.assets import page_assets
@@ -32,15 +40,15 @@ def main() -> None:
db_path = get_db_path()
inject_app_css()
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
query_page = st.query_params.get("page", "요약")
page = query_page if query_page in pages else "요약"
st.session_state["page"] = page
query_page = st.query_params.get("page")
page = validate_dashboard_page(query_page, pages)
st.session_state["dashboard_page"] = page
with st.sidebar:
st.markdown("## 💰 Jarca Ledger")
st.caption("가계부 · 분류 · Import 운영 콘솔")
st.markdown('<div class="jarca-nav-title">메뉴</div>', unsafe_allow_html=True)
render_sidebar_nav(pages, page)
page = render_sidebar_nav(pages, page)
if st.button("새로고침", key="refresh_button", use_container_width=True):
clear_cache()
st.rerun()
@@ -56,7 +64,7 @@ def main() -> None:
"계좌": page_accounts,
"Import": page_imports,
}
dispatch[page](db_path)
render_dashboard_body(page, lambda: dispatch[page](db_path))
if __name__ == "__main__":
+13 -6
View File
@@ -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")
+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
+42 -8
View File
@@ -4,7 +4,6 @@ import sqlite3
from datetime import datetime
from html import escape
from typing import Any
from urllib.parse import quote
import importlib
import inspect
from zoneinfo import ZoneInfo
@@ -314,6 +313,9 @@ def inject_app_css() -> None:
border-color: rgba(37, 99, 235, 0.48);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
}
.st-key-dashboard_body:has(.st-key-dashboard_loading [data-testid="stAlert"]) .st-key-dashboard_content {
display: none;
}
.jarca-card {
border: 1px solid rgba(226, 232, 240, 0.86);
border-radius: 22px;
@@ -355,13 +357,45 @@ def render_section_label(icon: str, label: str) -> None:
st.markdown(f'<div class="jarca-section-label"><span>{escape(icon)}</span><span>{escape(label)}</span></div>', unsafe_allow_html=True)
def render_sidebar_nav(pages: list[str], current_page: str) -> None:
items = []
for name in pages:
active = " active" if name == current_page else ""
href = f"?page={quote(name)}"
items.append(f'<a class="jarca-nav-item{active}" href="{href}" target="_self">{escape(name)}</a>')
st.markdown(f'<div class="jarca-nav-list">{"".join(items)}</div>', unsafe_allow_html=True)
def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
return page if page in pages else "요약"
def _sync_dashboard_page_query() -> None:
st.query_params["page"] = st.session_state["dashboard_page"]
def render_sidebar_nav(pages: list[str], current_page: str) -> str:
if "dashboard_page" not in st.session_state:
st.session_state["dashboard_page"] = current_page
return st.radio(
"메뉴",
pages,
key="dashboard_page",
label_visibility="collapsed",
on_change=_sync_dashboard_page_query,
)
def render_dashboard_body(page: str, renderer) -> None:
body = st.empty()
body.empty()
try:
with body.container():
with st.container(key="dashboard_body"):
loading = st.empty()
with loading.container():
with st.container(key="dashboard_loading"):
st.info(f"{page} 불러오는 중…")
with st.container(key="dashboard_content"):
renderer()
loading.empty()
except Exception:
body.empty()
with body.container():
st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
if st.button("다시 시도", key=f"retry_{page}"):
st.rerun()
def parse_optional_int(value: str, field_name: str) -> int | None:
+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.4
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.4
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.4
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"
}
@@ -0,0 +1,47 @@
# Issue #9 authenticated browser evidence
This directory binds the dashboard menu-transition acceptance evidence to the exact Git head and working-tree files listed in `manifest.json`.
## Captured transition
- **Path:** `요약` → `분류 규칙`
- **Input:** native Streamlit menu click in an authenticated HTTPS browser session
- **Fixture:** generated synthetic SQLite database only
- **Sampling:** 35 DOM/computed-style frames at 50 ms intervals
- **Viewport:** 1280 × 720
- **Browser:** Headless Chrome 154; Client Hints architecture `arm`, bitness `64`
- **Fixture host:** Linux aarch64, Python 3.13.5, Streamlit 1.58.0, Uvicorn 0.49.0
## Verified browser observations
All twelve manifest checks are true:
- the HTML document and sidebar markers stayed identical;
- `[data-testid="stAppViewContainer"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
- `[data-testid="stSidebar"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
- computed `backgroundImage`, `backgroundColor`, `visibility`, `display`, `opacity`, and rendered geometry were captured for both surfaces in every frame;
- the click and all 35 frames completed inside one uninterrupted Playwright evaluation context, so no full-document navigation occurred;
- the target loading state was observed in two frames;
- the previous `요약` body was absent in every loading frame;
- the unrelated `source=evidence` query key survived;
- final URL, selected menu, target body, and cleared loading state agreed.
`browser-observations.json` contains every frame, body hashes, URL state, markers, computed surface styles, runtime metadata, and explicit limitations. `final-state.jpg` is the final visible browser state and contains no credential or secret.
## Recheck
Run the exact validator recorded in `manifest.json`. The captured result was:
```text
exit code: 0
PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2
```
The browser actions used Playwright MCP. That API reports success/error rather than a numeric process exit code; the manifest records this boundary instead of fabricating an exit status.
## Evidence boundaries
- This is synthetic-fixture browser evidence, not operating-ledger evidence.
- A temporary HTTPS ingress adapter normalized the request scheme. Repository authentication and same-origin enforcement remained active.
- The tunnel URL is intentionally ephemeral and retained only as provenance.
- The screenshot supplements, but does not replace, the machine-readable continuous-frame observations.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -0,0 +1,68 @@
{
"schema": "jarca.issue9.evidence-manifest.v2",
"issue": "jsfamily/jarca-ledger-mcp#9",
"captured_at": "2026-10-05T08:17:17.108Z",
"source": {
"git_head_base": "a02aceee416c5770dfd1f35ac94367f3c4e0c8c8",
"working_tree": true,
"files_sha256": {
"dashboard/app.py": "4c7b3a6264b4fb4ae0e445ddc04c625b295443816b198aa5a60615ac44846ff2",
"dashboard/common.py": "9a0b9dbb4ca88f27757fc293173821c48a56a30bc23f16b0f24731f246411c5a",
"scripts/streamlit_browser_evidence.py": "3cfb449ce1d6fae3abb795727d7ca1bc10dbb759bb811f121abd74fa42993dd7",
"tests/test_dashboard_menu_transition.py": "d23c35d88f6289b7c7e84a271df6ff3f6ff1f25a3a885aa696cc93db0b37035e",
"tests/test_streamlit_browser_evidence.py": "7f595ccf4009961225328486c19258bb0f7bc7dbfb584c60616860274112724a"
}
},
"execution": {
"browser_tool": "mcp__playwright__browser_evaluate + mcp__playwright__browser_run_code_unsafe",
"tool_status": "success",
"exit_code": null,
"exit_code_note": "Playwright MCP reports success/error instead of a numeric process exit code.",
"transition": "요약 -> 분류 규칙",
"sampling_interval_ms": 50,
"frame_count": 35,
"viewport": {
"width": 1280,
"height": 720
},
"browser": "HeadlessChrome/154.0.0.0",
"browser_architecture": {
"client_hint_architecture": "arm",
"client_hint_bitness": "64",
"navigator_platform": "Linux x86_64"
},
"fixture_host_architecture": "aarch64",
"python": "3.13.5",
"streamlit": "1.58.0",
"uvicorn": "0.49.0"
},
"checks": {
"document_identity_stable": true,
"sidebar_identity_stable": true,
"app_surface_background_stable": true,
"sidebar_surface_background_stable": true,
"app_surface_visible_all_frames": true,
"sidebar_surface_visible_all_frames": true,
"surface_backgrounds_observed": true,
"zero_full_document_loads": true,
"loading_observed": true,
"previous_body_absent_while_loading": true,
"query_sibling_preserved": true,
"final_agreement": true
},
"artifacts_sha256": {
"browser-observations.json": "414b62efb2f40483aae4227896040c1b5d887b2c31333f63046e6f34aec2fe8f",
"final-state.jpg": "cc4e1c28463f033ab1c26d1dc27c9bb93c6fc5495321c0cef73fdb1098221234"
},
"validator": {
"command": "python3 -c \"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))\"",
"exit_code": 0,
"output": "PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2"
},
"limitations": [
"Generated synthetic SQLite fixture only; no operating ledger data was used.",
"A temporary HTTPS ingress adapter normalized the trusted request scheme for repository same-origin authentication.",
"The ephemeral tunnel URL is expired provenance and is not a rerun target.",
"The same-document no-load assertion is bound to the uninterrupted Playwright evaluation context, stable document marker, and empty document-load event record."
]
}
@@ -0,0 +1,122 @@
{
"environment": [
"os",
"kernel",
"architecture",
"python",
"uv"
],
"command_env_allowlist": [
"PATH",
"HOME",
"TMPDIR",
"LANG",
"VIRTUAL_ENV",
"UV_CACHE_DIR"
],
"secret_env_names": [],
"forbidden_regexes": [],
"hash_paths": [
"docs/evidence/issue-9-dashboard-menu-transition/README.md",
"docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json",
"docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg",
"docs/evidence/issue-9-dashboard-menu-transition/manifest.json",
"docs/project-structure.md",
"docs/requirements/req-002_dashboard-menu-transition.md",
"docs/runtime-flow.md",
"scripts/streamlit_browser_evidence.py",
"tests/test_streamlit_browser_evidence.py"
],
"commands": [
{
"id": "full-suite",
"purpose": "Run the repository full test suite at the rework source revision",
"argv": [
"uv",
"run",
"--extra",
"dashboard",
"--extra",
"dev",
"pytest"
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"full-suite-result"
]
},
{
"id": "browser-evidence-validator",
"purpose": "Validate checked-in authenticated browser evidence, source and artifact hashes",
"argv": [
"python3",
"-c",
"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))"
],
"expected_exit_code": 0,
"max_output_bytes": 8192,
"claims": [
"browser-evidence-result"
]
},
{
"id": "documentation-inventory",
"purpose": "Validate canonical requirement and documentation remediation coverage",
"argv": [
"python3",
"-c",
"from pathlib import Path\nimport re\nq=Path('docs/requirements/req-002_dashboard-menu-transition.md').read_text(); p=Path('docs/project-structure.md').read_text(); f=Path('docs/runtime-flow.md').read_text()\nrb=re.split(r'(?=^### REQ-002\\.\\d{2}(?!-AC)\\b)',q,flags=re.M)[1:]; ab=re.split(r'(?=^### REQ-002\\.\\d{2}-AC01\\b)',q,flags=re.M)[1:]\nassert len(rb)==len(ab)==9 and all('**Impact**' in x for x in rb) and all('**Cases**' in x and '**State Invariants**' in x for x in ab)\nassert all(x in q for x in ('REQ-001.05-AC01','REQ-001.07-AC01','REQ-001.08-AC01','REQ-001.09-AC01'))\nassert all(x in p for x in ('dashboard/common.py','inject_app_css()','JARCA_COLORS','JARCA_PLOTLY_TEMPLATE','outside the replaceable body'))\nassert all(x in f for x in ('do not initiate or replay financial writes or external calls','explicit page user actions','no new idempotency contract'))\nprint('PASS requirements=9 impact=9 acceptance_criteria=9 cases=9 state_invariants=9 missing=0 ambiguous=0')"
],
"expected_exit_code": 0,
"max_output_bytes": 8192,
"claims": [
"documentation-contract-result"
]
},
{
"id": "ruff",
"purpose": "Run static lint checks",
"argv": [
"uv",
"run",
"--extra",
"dashboard",
"--extra",
"dev",
"ruff",
"check",
"dashboard",
"tests",
"scripts"
],
"expected_exit_code": 0,
"max_output_bytes": 8192,
"claims": [
"lint-result"
]
},
{
"id": "compile",
"purpose": "Compile Python sources and tests",
"argv": [
"python3",
"-m",
"compileall",
"-q",
"dashboard",
"tests",
"scripts"
],
"expected_exit_code": 0,
"max_output_bytes": 8192,
"claims": [
"compile-result"
]
}
],
"limitations": [
"The authenticated browser transition was captured before this clean source commit from byte-identical hashed files; this manifest validates the tracked browser artifacts and their exact source hashes rather than rerunning the browser.",
"Gitea commit status and Actions state are authoritative external readbacks and are not produced by this local manifest."
]
}
File diff suppressed because one or more lines are too long
+44 -1
View File
@@ -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·복원에서 변경하거나 연장하지 않음
## 예시
@@ -216,3 +221,41 @@ Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=
- 실패 계약: wrong credential, invalid configuration, cross-origin, malformed cookie tests
- 경계 계약: expiry, rotation, oversized token, same-host different-port origin tests
- 구현·스키마·예시 정합성: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search
## Dashboard 메뉴 전환 계약 — REQ-002
### dashboard-menu-transition
- 책임: authenticated Dashboard에서 공통 shell/background를 유지하고 선택된 page body만 교체한다.
- 종류·프로토콜: Streamlit rerun, `st.radio` callback, query parameter
- 호출 대상·operation: `dashboard.app.main()` → `render_sidebar_nav()` → `render_dashboard_body()`
- 버전: 1.0
- 호출 주체: authenticated browser
- 인증 방식: 기존 `gate` 계약 선행; 메뉴는 인증을 우회하지 않음
- 필요 권한: valid Dashboard auth cookie
#### 상태와 입력
정확한 menu options/order는 `월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`이다. selected value는 `st.session_state["dashboard_page"]`에 둔다. 직접 query의 `page`가 known option이면 해당 page를, absent/unknown이면 `요약`을 사용한다.
#### 출력·불변조건
- callback은 `st.query_params["page"]`만 갱신하고 `month`, `account_id`, `filter` 등 sibling key를 보존한다.
- menu click은 document/path navigation이 아니며 stable `st.empty` body container 안에서 target renderer를 실행한다.
- 공통 sidebar, refresh control, CSS/background는 유지하고 body만 target content로 대체한다.
- 느린 target에는 `<target> 불러오는 중…`을 표시한 뒤 renderer 완료 시 placeholder를 제거한다.
- renderer 예외는 body 안의 `페이지를 불러오지 못했습니다. 다시 시도해 주세요.`와 retry control로 대체하며 예외 상세를 노출하지 않는다.
- serialized callback이 여러 번 실행되면 마지막 selection이 query와 body의 최종 상태가 된다.
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `page_fallback` | request | missing/unknown `page` | 가능 | `요약` 표시 | sibling query 유지 |
| `renderer_unavailable` | rendering | target renderer exception | 가능 | generic error의 retry 클릭 | previous body 제거, auth/shell 유지 |
#### 검증 경계
AppTest는 callback, query mapping, synthetic renderer output과 error/loading event를 검증하지만 실제 browser의 document navigation 부재, paint timing, focus/scroll, network request 또는 perceived shell continuity를 증명하지 않는다. 따라서 `REQ-002.03-AC01`은 authenticated real-browser evidence가 필수이며 AppTest는 companion evidence일 뿐이다. history/back-forward와 path URL은 계약에서 제외한다.
상세 acceptance target은 `docs/requirements/req-002_dashboard-menu-transition.md`의 `REQ-002.01-AC01`부터 `REQ-002.09-AC01`까지를 따른다.
+23 -4
View File
@@ -17,6 +17,7 @@ dashboard/
├── auth_server.py
├── authentication.py
├── app.py
├── common.py
└── page_views/
```
@@ -25,9 +26,12 @@ dashboard/
| `dashboard/server.py` | source | ASGI app 조립 | `app` | route wiring only | source review required | developer |
| `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer |
| `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer |
| `dashboard/app.py` | source | gate then page dispatch | `main()` | no token signing details | source review required | developer |
| `dashboard/app.py` | source | auth gate first, then DB path resolution, common shell/sidebar/refresh assembly, and target body renderer dispatch | `main()` | shell/background remain outside the replaceable body; no token signing details | source review required | developer |
| `dashboard/common.py` | source | shared Dashboard shell, menu/query transition, stable body container, loading/error/retry rendering, and app background/style | `render_sidebar_nav()`, `render_dashboard_body()`, `inject_app_css()` | no authentication transport or financial write orchestration | source review required | developer |
| `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer |
`dashboard/common.py` owns the shared visual theme through `JARCA_COLORS`, `JARCA_PLOTLY_TEMPLATE`, and `inject_app_css()`. Page renderers consume that theme but do not own or replace the authenticated shell/sidebar background.
### verification-tests — 인증 검증
```text
@@ -35,6 +39,8 @@ tests/
├── test_dashboard_authentication.py
├── test_dashboard_persistent_authentication.py
├── test_dashboard_auth_server.py
├── test_dashboard_menu_transition.py
├── test_streamlit_browser_evidence.py
└── test_dashboard_authentication_app.py
```
@@ -43,12 +49,23 @@ tests/
| `tests/test_dashboard_authentication.py` | test | credential compare, delay, redaction | pytest tests | test-only settings | source change paired with test | developer |
| `tests/test_dashboard_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer |
| `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer |
| `tests/test_dashboard_menu_transition.py` | test | REQ-002 menu/query/body transition and cache-clear regression | pytest/AppTest | synthetic fixture DB; no real secrets | source change paired with test and browser evidence where required | developer |
| `tests/test_streamlit_browser_evidence.py` | test | transition evidence manifest, redaction, TLS fixture and fail-closed validation | pytest | generated synthetic values only; no captured credential values | evidence-harness change paired with test | developer |
| `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer |
### browser-evidence — 실제 전환 관찰
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `scripts/streamlit_browser_evidence.py` | verification tool | synthetic DB와 runtime 생성 인증 설정으로 실제 HTTPS Dashboard 전환의 frame/DOM/document/shell 관찰을 수집하고 redacted manifest를 검증 | `--mode transition` | 운영 DB·credential 사용 금지; 성공 증거는 browser/server teardown 뒤에만 확정 | source review and focused test required | developer |
| `docs/evidence/issue-9-dashboard-menu-transition/` | evidence artifact | exact source hash에 결합한 authenticated browser frame, screenshot, runtime, validator 결과 | `manifest.json` | synthetic fixture only; credential/token/cookie 값 금지 | immutable after verification except complete regeneration | verification runner |
### deployment-docs — 운영 문서
```text
docs/requirements/req-001_dashboard-administrator-authentication.md
docs/requirements/req-002_dashboard-menu-transition.md
docs/evidence/issue-9-dashboard-menu-transition/
docs/interface-contract.md
docs/runtime-flow.md
README.md
@@ -58,6 +75,7 @@ deploy/k8s/README.md
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `docs/requirements/req-001_dashboard-administrator-authentication.md` | requirement | approved REQ-001 revision 2.0 | REQ-001 | canonical traceability | update in place | developer |
| `docs/requirements/req-002_dashboard-menu-transition.md` | requirement | approved REQ-002 revision 1.0 | REQ-002 | canonical traceability | update in place | developer |
| `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer |
| `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer |
| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer |
@@ -71,15 +89,16 @@ deploy/k8s/README.md
| `dashboard.server` | `dashboard.app` | 허용 | app remains protected by gate | `docs/runtime-flow.md` |
| `dashboard.app` | `dashboard.authentication` | 허용 | gate precedes DB/page dispatch | `docs/interface-contract.md` |
| `dashboard.authentication` | `dashboard.page_views` | 금지 | auth module never imports financial pages | `docs/project-structure.md` |
| `dashboard.app` | `dashboard.common` | 허용 | menu/query/body transition helpers only after auth gate | `docs/interface-contract.md` |
| `dashboard.auth_server` | `jarca_ledger_mcp` DB | 금지 | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.md` |
## 변경 규칙
| 구분 | 규칙 |
|---|---|
| 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; no DB session module |
| 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; menu transition stays in existing `dashboard.app`/`dashboard.common`; no DB session module |
| 이름·패키지 | use existing `dashboard` package and snake_case modules |
| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage |
| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage; REQ-002 transition behavior requires focused pytest/AppTest targets and real-browser evidence where specified |
| 생성 산출물 | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values |
## 검증
@@ -98,4 +117,4 @@ deploy/k8s/README.md
```
- 정적 분석 기대 결과: exit 0 with no diagnostics
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests
@@ -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 — 안전한 관찰성
**판정 기준**
@@ -0,0 +1,427 @@
---
{
"schema": "gitea-agent.requirement/v1",
"id": "REQ-002",
"title": "Dashboard 메뉴 전환 시 공통 화면 연속성",
"revision": "1.0",
"status": "approved",
"depends_on": ["REQ-001"],
"applies_to": ["dashboard"],
"language": "ko"
}
---
# REQ-002 — Dashboard 메뉴 전환 시 공통 화면 연속성
## 1. 목적
인증된 Dashboard에서 메뉴를 바꿀 때 문서 전체를 이동시키지 않고 공통 shell과 배경을 유지하며 body만 교체한다. 현재 메뉴 선택, URL 상태, 느린 렌더링, 렌더링 실패의 동작을 예측 가능하게 만든다.
## 2. 배경
Issue #9의 Dashboard 메뉴 전환 계약이다. REQ-001의 인증 경계, cookie, logout, page dispatch와 기존 금융 페이지 계약을 유지한 채 Streamlit 메뉴 전환을 연속적인 단일 화면으로 제공한다.
## 3. 범위
### 포함
- Streamlit `st.radio` 기반 메뉴의 정확한 10개 label/order와 selected state
- 메뉴 클릭 시 document navigation 없이 공통 shell/background를 유지하고 body만 교체
- 느린 대상 page 렌더링 중 대상별 loading 표시
- renderer 예외의 local generic error와 retry
- 빠른 연속 선택에서 latest serialized selection wins
- 직접 `?page=` 접근, 누락/unknown page의 `요약` fallback, sibling query key 보존
- 기존 auth gate, cookie, logout, finance page, cache-clear 회귀 검증
### 제외
- browser history/back-forward 동작, history API 계약, path URL 설계
- 인증 방식·cookie 속성·MCP transport·SQLite schema 변경
- page renderer의 금융 계산·gateway 의미 변경
- 별도 document route 또는 full-document navigation 구현
## 4. 용어와 기준
- **공통 shell**: 인증 후 `dashboard/app.py`가 제공하는 sidebar, 메뉴, refresh control 및 앱 배경/CSS.
- **body**: 선택된 page renderer가 공통 shell 안에서 채우는 주 콘텐츠 영역.
- **latest serialized selection**: Streamlit rerun/콜백 직렬화 순서에서 마지막으로 반영된 `dashboard_page` 선택.
- **AppTest**: Streamlit `streamlit.testing.v1.AppTest`의 synthetic runtime 검증. DOM, 실제 browser document navigation, 실제 paint/지연 체감은 검증하지 못한다.
- **real-browser evidence**: 실제 browser에서 authenticated session으로 메뉴를 클릭하고 URL/document/body/shell을 관찰해 남기는 증거. AppTest를 대체하지 않으며 AppTest만으로 충족할 수 없는 browser-level 항목에 필수다.
## 5. 요구사항
### REQ-002.01 — 메뉴 정체성
메뉴는 정확히 다음 순서와 label을 유지한다: `월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`. selected state는 현재 page와 일치한다.
**Impact**
- Interfaces: `dashboard_page` radio and `page` query parameter
- Persistence: none
- Consumers: dashboard page renderers
- Migration: none
- Compatibility: existing labels, order, and selected-state contract
- Deployment prerequisites: none
### REQ-002.02 — URL과 직접 접근
메뉴 선택은 query의 `page`만 대상 label로 갱신하고 `month`, `account_id`, `filter` 등 sibling query key를 보존한다. 직접 `?page=<known>`은 해당 page를 렌더링하고, missing/unknown 값은 `요약`으로 fallback한다.
**Impact**
- Interfaces: browser query parameters
- Persistence: none
- Consumers: direct links and page renderers
- Migration: none
- Compatibility: sibling query keys and direct page access
- Deployment prerequisites: none
### REQ-002.03 — 연속 화면
메뉴 클릭은 document navigation을 일으키지 않는다. 공통 shell과 background는 유지하고 body만 선택된 renderer의 내용으로 교체한다. history/back-forward와 path URL은 이 요구사항의 판정 범위가 아니다.
**Impact**
- Interfaces: Streamlit shell/body rendering
- Persistence: none
- Consumers: authenticated dashboard users
- Migration: none
- Compatibility: existing shell, background, and path behavior
- Deployment prerequisites: none
### REQ-002.04 — 전환 중 표시
느린 renderer가 실행되는 동안 target page label을 포함한 `불러오는 중…` loading을 대상 body 영역에 표시하고, renderer 완료 후 loading placeholder를 제거한다.
**Impact**
- Interfaces: target body loading state
- Persistence: none
- Consumers: dashboard users
- Migration: none
- Compatibility: existing renderer output after completion
- Deployment prerequisites: none
### REQ-002.05 — local failure와 retry
target renderer 예외는 공통 shell을 무너뜨리지 않고 body 영역을 generic error와 `다시 시도` control로 교체한다. 예외 상세·secret은 UI에 노출하지 않는다.
**Impact**
- Interfaces: body error and retry control
- Persistence: none
- Consumers: dashboard users and support operators
- Migration: none
- Compatibility: shell remains available; secrets remain redacted
- Deployment prerequisites: none
### REQ-002.06 — 최신 선택 우선
연속 메뉴 선택은 serialized callback 순서의 마지막 선택을 query와 body에 반영하며 이전 선택이 마지막 상태를 덮어쓰지 않는다.
**Impact**
- Interfaces: serialized menu callback and query state
- Persistence: none
- Consumers: page selection state
- Migration: none
- Compatibility: latest-selection ordering contract
- Deployment prerequisites: none
### REQ-002.07 — 인증 경계 보존
모든 menu/page dispatch는 REQ-001 gate와 cookie 검증 뒤에만 실행된다. no-cookie/invalid-cookie는 page body에 도달하지 않으며 valid cookie는 기존 Dashboard에 접근한다.
**Impact**
- Interfaces: REQ-001 authentication gate and cookie verification
- Persistence: none
- Consumers: authenticated dashboard dispatch
- Migration: none
- Compatibility: REQ-001 expiry, rotation, cleanup, and logout behavior
- Deployment prerequisites: existing REQ-001 settings
### REQ-002.08 — 금융 page 회귀 없음
요약, 자산/마감, 분류 규칙, 확인필요 큐, 월정산의 기존 synthetic fixture DB 핵심 내용과 기존 page query 계약을 유지한다.
**Impact**
- Interfaces: existing page/query contracts
- Persistence: existing synthetic fixture DB reads and writes
- Consumers: financial page renderers
- Migration: none
- Compatibility: existing financial calculations and gateway semantics
- Deployment prerequisites: existing fixture and page setup
### REQ-002.09 — 운영 control 회귀 없음
refresh control은 기존 `clear_cache` callable 계약을 유지하고, 메뉴 전환은 cache-clear 동작을 변경하지 않는다.
**Impact**
- Interfaces: `clear_cache` callable and refresh control
- Persistence: none
- Consumers: dashboard operators
- Migration: none
- Compatibility: existing cache-clear behavior
- Deployment prerequisites: none
## 6. 제약과 불변조건
- `dashboard/app.py`의 auth gate와 page dispatch는 유지하며 인증 전에 DB path를 resolve하지 않는다.
- 메뉴 클릭은 full document reload 또는 path navigation을 추가하지 않는다.
- body 교체는 stable `st.empty` container 안에서 수행하며 공통 shell/background를 삭제하지 않는다.
- loading/error/retry는 target body에 국소적으로 표시하고 generic error 외 renderer exception 내용을 공개하지 않는다.
- query update는 `page` key만 변경하며 sibling page-specific key를 삭제하거나 정규화하지 않는다.
- REQ-001의 auth/cookie/logout, financial gateway/page, cache-clear, MCP 및 SQLite semantics는 변경하지 않는다.
## 7. 인수 기준
각 기준은 하나의 명시된 test/evidence target으로 판정한다. AppTest는 synthetic runtime 판정이며 real-browser evidence가 필요한 기준을 대신하지 않는다.
### REQ-002.01-AC01 — label/order/selected
**Cases**: positive, unchanged-state
**State Invariants**: labels/order and selected state remain exact.
**판정 기준**
정확한 10개 label과 순서가 radio options로 전달되고 `dashboard_page` selected state가 유지된다.
**검증**
`tests/test_dashboard_menu_transition.py::test_dashboard_menu_labels_and_order_are_preserved`
**검증 분류**
- 수준: unit
- 백엔드: pytest monkeypatch
- 증거: radio options, key, label visibility assertion
### REQ-002.02-AC01 — query와 fallback
**Cases**: positive, boundary, unchanged-state
**State Invariants**: only `page` changes; sibling query keys remain unchanged.
**판정 기준**
callback은 `page`만 갱신하고 sibling key를 보존하며, missing/unknown은 `요약`, known direct query는 요청 page를 선택한다.
**검증**
`tests/test_dashboard_menu_transition.py::test_sidebar_selection_updates_only_page_query_parameter`, `tests/test_dashboard_menu_transition.py::test_missing_or_unknown_page_falls_back_to_overview`, `tests/test_dashboard_menu_transition.py::test_direct_page_query_access_renders_requested_page`
**검증 분류**
- 수준: unit/integration
- 백엔드: pytest monkeypatch
- 증거: exact query mapping and selected renderer assertion
### REQ-002.03-AC01 — body-only transition
**Cases**: positive, unchanged-state
**State Invariants**: document, shell, and background remain stable; body changes only.
**판정 기준**
실제 browser에서 authenticated menu click 후 document URL path와 full-document identity가 바뀌지 않고 공통 sidebar/background가 남으며 target body만 바뀐다. history/back-forward와 path URL은 판정하지 않는다.
**검증**
Required real-browser evidence: `docs/evidence/issue-9-dashboard-menu-transition/`의 authenticated `요약` → `분류 규칙` native click 연속 frame, screenshot, DOM, URL, document/sidebar identity와 background 관찰. `manifest.json`은 정확한 working-tree file hash, runtime viewport·interval·환경·제약, artifact hash 및 validator exit code를 기록한다. 이 실행은 local Chrome 부재 때문에 Playwright MCP browser runner로 수집했으며, 재현 가능한 local fail-closed harness는 `scripts/streamlit_browser_evidence.py --mode transition`과 `tests/test_streamlit_browser_evidence.py`로 유지한다. AppTest companion: `tests/test_dashboard_menu_transition.py::test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings`.
**검증 분류**
- 수준: browser evidence + integration companion
- 백엔드: real browser, Streamlit AppTest
- 한계: AppTest는 실제 document navigation, browser paint, focus/scroll, network request, perceived continuity를 증명하지 못한다.
### REQ-002.04-AC01 — target loading
**Cases**: positive, boundary
**State Invariants**: loading appears before completion and is removed after rendering.
**판정 기준**
slow renderer 동안 target label loading이 먼저 표시되고 완료 후 placeholder가 제거된다.
**검증**
`tests/test_dashboard_menu_transition.py::test_slow_transition_clears_previous_body_and_shows_target_loading`; real-browser frames: `docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json`
**검증 분류**
- 수준: unit + browser evidence
- 백엔드: pytest fake Streamlit placeholder, authenticated Playwright browser
- 증거: ordered event list와 50 ms 연속 frame에서 `loading=true`, `previous_body_visible=false`, 최종 `loading=false`
- 한계: browser evidence는 실제 authenticated transition을 50 ms 간격으로 표본화했으며 loading은 2개 frame에서 관찰됐다. 표본 사이의 모든 순간을 보장하지 않는다.
### REQ-002.05-AC01 — local error/retry
**Cases**: negative, positive
**State Invariants**: shell remains intact; renderer details and secrets are not exposed.
**판정 기준**
renderer 예외가 generic error/retry body로 국소 대체되고 예외 상세가 노출되지 않는다.
**검증**
`tests/test_dashboard_menu_transition.py::test_body_error_is_local_and_retry_rerenders_target`
**검증 분류**
- 수준: unit
- 백엔드: pytest monkeypatch
- 증거: error text, retry event, secret-detail absence assertion
### REQ-002.06-AC01 — latest selection
**Cases**: concurrency, unchanged-state
**State Invariants**: latest serialized selection is final.
**판정 기준**
연속 선택 `거래내역` → `자산/마감` → `Import` 후 query가 마지막 `Import`이고 sibling key가 보존된다.
**검증**
`tests/test_dashboard_menu_transition.py::test_rapid_menu_clicks_commit_latest_selection_only`
**검증 분류**
- 수준: unit
- 백엔드: pytest monkeypatch
- 증거: final query mapping
### REQ-002.07-AC01 — auth regression
**Cases**: negative, positive
**State Invariants**: auth gate precedes DB/page dispatch; cookie semantics remain REQ-001.
**판정 기준**
valid cookie가 기존 Dashboard를 열고 cookie가 session state에 저장되지 않으며 gate rejection은 DB/page dispatch 전에 종료된다.
**검증**
`tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`, `tests/test_dashboard_authentication_app.py::test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects`
**검증 분류**
- 수준: integration regression
- 백엔드: Streamlit AppTest, synthetic fixture DB
- 증거: authenticated body and pre-gate forbidden DB sentinel
### REQ-002.08-AC01 — finance page regression
**Cases**: positive, unchanged-state
**State Invariants**: existing financial page results and query contracts remain unchanged.
**판정 기준**
기존 핵심 금융 page의 expected text와 synthetic fixture DB 결과가 유지된다.
**검증**
`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`
**검증 분류**
- 수준: regression integration
- 백엔드: Streamlit AppTest, synthetic SQLite
- 증거: page expectation and metric assertions
### REQ-002.09-AC01 — cache-clear regression
**Cases**: positive, unchanged-state
**State Invariants**: `clear_cache` remains callable and menu transition does not alter it.
**판정 기준**
refresh control이 callable `clear_cache` 계약을 유지한다.
**검증**
`tests/test_dashboard_menu_transition.py::test_refresh_control_preserves_cache_clear_contract`
**검증 분류**
- 수준: regression unit
- 백엔드: pytest
- 증거: callable/name assertion
### Acceptance Criteria canonical fields
Each Acceptance Criterion has explicit `Cases` and `State Invariants` in addition to the existing Korean verification text.
| Acceptance Criterion | Cases | State Invariants |
|---|---|---|
| `REQ-002.01-AC01` | positive, unchanged-state | labels/order and selected state remain exact |
| `REQ-002.02-AC01` | positive, boundary, unchanged-state | only `page` changes; sibling query keys remain unchanged |
| `REQ-002.03-AC01` | positive, unchanged-state | document, shell, and background remain stable; body changes only |
| `REQ-002.04-AC01` | positive, boundary | loading appears before completion and is removed after rendering |
| `REQ-002.05-AC01` | negative, positive | shell remains intact; renderer details and secrets are not exposed |
| `REQ-002.06-AC01` | concurrency, unchanged-state | latest serialized selection is final |
| `REQ-002.07-AC01` | negative, positive | auth gate precedes DB/page dispatch; cookie semantics remain REQ-001 |
| `REQ-002.08-AC01` | positive, unchanged-state | existing financial page results and query contracts remain unchanged |
| `REQ-002.09-AC01` | positive, unchanged-state | `clear_cache` remains callable and menu transition does not alter it |
## 8. 오류 및 충돌 계약
인증 실패는 항상 REQ-001 gate가 우선하며 메뉴·query로 우회할 수 없다. renderer 실패는 해당 body만 generic error/retry로 대체한다. 빠른 선택은 Streamlit serialized callback 순서를 따르며 마지막 serialized selection이 최종 상태다. history/back-forward와 path URL은 요구하지 않는다.
## 9. 데이터 및 인터페이스 계약
- `dashboard_page` is the canonical selected menu field; `page` is the only query field written by menu selection.
- `month`, `account_id`, `filter`, and other sibling query keys are preserved verbatim.
- Body loading, error, retry, and renderer output are local to the stable body container; shell/background are not replaced.
## 10. 하위호환성
- Authentication expiry, rotation, invalid-cookie cleanup, and explicit logout remain governed by `REQ-001.05-AC01`, `REQ-001.07-AC01`, `REQ-001.08-AC01`, and `REQ-001.09-AC01`.
- Existing exact tests remain the authority: `tests/test_dashboard_persistent_authentication.py::test_token_expiry_is_absolute_and_not_sliding`, `tests/test_dashboard_persistent_authentication.py::test_tampering_and_rotations_invalidate_token`, and `tests/test_dashboard_auth_server.py::test_logout_clears_cookie_with_matching_scope`.
- Menu/query reruns/rendering do not initiate or replay financial writes or external calls. Financial writes and external calls remain behind explicit page user actions; this adds no new idempotency contract.
## 11. 검증 계획
- Menu transition tests and required browser evidence verify REQ-002 criteria; REQ-001 authentication tests above verify expiry, rotation, and logout regressions.
### Test Traceability Contract
Test definitions reference the exact Acceptance Criterion IDs and execution evidence records revision, commit, environment, and result separately.
## 12. 추적성
- 근거 이슈: `#9`
- 구현 PR: `#10`
- 관련 설계 문서: `docs/project-structure.md`, `docs/runtime-flow.md`
- 관련 requirement: `REQ-001`
- 구현 진입점: `dashboard/app.py::main`, `dashboard/common.py::render_sidebar_nav`, `dashboard/common.py::validate_dashboard_page`, `dashboard/common.py::render_dashboard_body`, `dashboard/common.py::_sync_dashboard_page_query`
- 검증 파일: `tests/test_dashboard_menu_transition.py`, `tests/test_streamlit_browser_evidence.py`, `tests/test_dashboard_app.py`, `tests/test_dashboard_authentication_app.py`
- browser evidence 도구: `scripts/streamlit_browser_evidence.py --mode transition`
## 13. 확정된 결정
- Keep the authenticated shell and background stable while replacing only the body.
- Preserve REQ-001 authentication semantics and keep financial writes/external calls behind explicit page actions.
## 14. Open Questions
- 없음
## 15. 승인 기록
- 상태: `approved`
- 승인 주체: existing requirement approval
- 승인 근거: `REQ-002` revision `1.0`
+37 -4
View File
@@ -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`로 전이
## 관찰 가능성
@@ -110,3 +112,34 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
- 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin
- 동시성 흐름: independent failed submissions and no application lock; ingress rate-limit configuration review
- 운영 진입점과 기대 결과: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page
## 메뉴 전환 — REQ-002
인증 gate와 기존 `dashboard-01` page dispatch의 선행 순서는 변하지 않는다. valid cookie를 통과한 뒤에만 menu transition flow가 실행되며, auth 실패는 기존 cleanup/unauthenticated 흐름으로 종료된다.
### transition — authenticated body replacement
- 시작 이벤트: authenticated browser selects a value in `st.radio(key="dashboard_page")`
- 선행조건: `gate-02` success; current page/query resolved
- 실행 방식: Streamlit callback and rerun, no document/path navigation
- 트랜잭션·영속성 경계: no new persistence; existing financial SQLite rules unchanged
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `transition-01` | dashboard app/common | authenticated selected menu | `render_sidebar_nav` reads exact 10 options and selected state | session state `dashboard_page` | Streamlit radio callback | `transition-02` | `dashboard-fail` |
| `transition-02` | common callback | selected `dashboard_page` | write query `page` only; preserve sibling query keys | browser query state | Streamlit query params | `transition-03` | `transition-03` |
| `transition-03` | dashboard app | validated page | open stable `st.empty` body, show target loading, invoke target renderer | body placeholder only; existing DB reads/writes | page/gateway code | `transition-04` | `transition-error` |
| `transition-04` | dashboard app | renderer completed | remove target loading and leave target body; shell/background remain | body replacement only | none | `authenticated` | `dashboard-fail` |
| `transition-error` | dashboard app | renderer exception | clear partial body and show generic error/retry locally | body error state only | none | retry reruns target | `authenticated` with shell intact |
### transition invariants
- known direct `?page=` selects that page; missing/unknown selects `요약`.
- serialized rapid selections commit the latest selection only.
- menu transition never runs before auth and never changes cookie/logout/DB schema/MCP semantics.
- menu/query reruns and rendering do not initiate or replay financial writes or external calls; those remain behind explicit page user actions, with no new idempotency contract.
- history/back-forward and path URL behavior are explicitly outside this flow.
### transition verification boundary
`tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`의 authenticated HTTPS frame/DOM/document/shell evidence가 REQ-002.03-AC01에 필수다. `tests/test_streamlit_browser_evidence.py`는 이 manifest가 실제 teardown 이후에만 성공하고 document navigation, shell identity drift, 누락 관찰 또는 secret 포함 시 fail-closed하는지 검증한다.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.2"
__version__ = "0.1.4"
+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.4"
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,
+323 -34
View File
@@ -14,10 +14,16 @@ import asyncio
import base64
import json
import os
import platform
import shutil
import signal
import subprocess
import sys
import time
import hashlib
import math
import secrets
import ssl
import urllib.parse
import urllib.request
from datetime import datetime
@@ -44,6 +50,13 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-<timestamp>.")
parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.")
parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.")
parser.add_argument("--mode", choices=("capture", "transition"), default="capture", help="Capture mode")
parser.add_argument("--cert-file", default="", help="TLS certificate for transition mode")
parser.add_argument("--key-file", default="", help="TLS private key for transition mode")
parser.add_argument("--from-page", default="요약")
parser.add_argument("--to-page", default="분류 규칙")
parser.add_argument("--interval-ms", type=int, default=100)
parser.add_argument("--frames", type=int, default=8)
return parser.parse_args()
@@ -145,12 +158,13 @@ def seed_fixture_db(db_path: Path) -> dict[str, Any]:
return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)}
def wait_for_http(url: str, timeout_seconds: int = 45) -> None:
def wait_for_http(url: str, timeout_seconds: int = 45, ssl_context: ssl.SSLContext | None = None) -> None:
"""Wait for HTTP readiness, using verified TLS unless explicitly overridden."""
deadline = time.time() + timeout_seconds
last_error: str | None = None
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response:
if response.status == 200:
return
last_error = f"HTTP {response.status}"
@@ -195,6 +209,7 @@ class CdpClient:
def __init__(self, ws: websockets.ClientConnection) -> None:
self.ws = ws
self.next_id = 1
self.events: list[dict[str, Any]] = []
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
command_id = self.next_id
@@ -207,6 +222,8 @@ class CdpClient:
if "error" in payload:
raise RuntimeError(f"CDP {method} failed: {payload['error']}")
return payload.get("result", {})
if payload.get("method"):
self.events.append(payload)
async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]:
@@ -295,10 +312,233 @@ def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtoo
}
def redact_secrets(value: Any, secrets_to_redact: tuple[str, ...] = ()) -> Any:
"""Return JSON-safe evidence with credential-like values removed."""
if isinstance(value, dict):
return {key: redact_secrets(item, secrets_to_redact) for key, item in value.items()}
if isinstance(value, list):
return [redact_secrets(item, secrets_to_redact) for item in value]
if isinstance(value, str):
result = value
for secret in secrets_to_redact:
if secret:
result = result.replace(secret, "[REDACTED]")
return result
return value
def _validate_surface_records(records: Any, label: str) -> None:
required = ("stAppViewContainer", "stSidebar")
if not isinstance(records, dict) or any(not isinstance(records.get(name), dict) for name in required):
raise ValueError(f"transition observations require surface records for {label}")
for name in required:
surface = records[name]
if any(key not in surface or surface[key] is None for key in ("backgroundImage", "backgroundColor", "visibility", "display", "opacity", "rendered")):
raise ValueError(f"transition observations require complete surface records for {label}")
if not str(surface["backgroundImage"]).strip() or str(surface["backgroundImage"]).strip().lower() == "none":
raise ValueError(f"transition observations reject empty surface background for {label}")
if not str(surface["backgroundColor"]).strip():
raise ValueError(f"transition observations reject empty surface background color for {label}")
try:
opacity = float(surface["opacity"])
except (TypeError, ValueError) as exc:
raise ValueError(f"transition observations require valid surface opacity for {label}") from exc
if not math.isfinite(opacity) or surface["visibility"] != "visible" or surface["display"] == "none" or opacity <= 0 or surface["rendered"] is not True:
raise ValueError(f"transition observations require visible rendered surfaces for {label}")
def validate_transition_manifest(manifest: dict[str, Any], secrets_to_redact: tuple[str, ...] = ()) -> None:
"""Fail closed unless transition evidence contains every required observation."""
serialized = json.dumps(manifest, ensure_ascii=False)
if any(secret and secret in serialized for secret in secrets_to_redact):
raise ValueError("secret value present in transition manifest")
if manifest.get("success") is not True:
raise ValueError("transition manifest is not successful")
observations = manifest.get("observations") or {}
frames = observations.get("frames")
if not isinstance(frames, list) or len(frames) < 2:
raise ValueError("transition observations require at least two frames")
for index, frame in enumerate(frames):
_validate_surface_records(frame.get("surfaces"), f"frame {index}")
document_events = observations.get("document_navigation_events")
if not isinstance(document_events, list) or document_events:
raise ValueError("transition observations require zero document navigation events")
shell = observations.get("stable_shell") or {}
stable_fields = ("document_marker", "sidebar_marker", "background")
if any(not isinstance(shell.get(key), dict) for key in stable_fields):
raise ValueError("transition observations require pre/post stable shell markers")
if any(
not isinstance(values.get(position), str) or not values[position].strip()
for values in (shell[key] for key in stable_fields)
for position in ("pre", "post")
):
raise ValueError("transition observations require non-empty stable shell markers")
if any(shell[key]["pre"] != shell[key]["post"] for key in stable_fields):
raise ValueError("transition observations require stable shell identity and background")
stable_surfaces = shell.get("surfaces")
if not isinstance(stable_surfaces, dict) or stable_surfaces.get("pre") != frames[0].get("surfaces"):
raise ValueError("transition observations require stable shell surface records")
_validate_surface_records(stable_surfaces.get("pre"), "stable shell pre")
_validate_surface_records(stable_surfaces.get("post"), "stable shell post")
if stable_surfaces["pre"] != stable_surfaces["post"] or stable_surfaces["post"] != frames[-1].get("surfaces"):
raise ValueError("transition observations require stable shell surface consistency")
if observations.get("prior_body_removed") is not True:
raise ValueError("transition observations require prior body removal")
loading_frames = [frame for frame in frames if frame.get("loading") is True]
if observations.get("loading_observed") is not True or not loading_frames:
raise ValueError("transition observations require observed loading state")
if any(frame.get("prior_body_visible") is not False for frame in loading_frames):
raise ValueError("transition observations require prior body absence while loading")
if not observations.get("target_page"):
raise ValueError("transition observations require target page")
if observations.get("final_agreement") is not True:
raise ValueError("transition observations require target body agreement")
runtime = manifest.get("runtime") or {}
if not runtime.get("viewport") or not isinstance(runtime.get("interval_ms"), int):
raise ValueError("transition manifest requires viewport and interval")
command = manifest.get("command") or {}
if command.get("exit_code") != 0 or command.get("teardown") != "complete":
raise ValueError("transition manifest requires successful command teardown")
source_revision = manifest.get("source_revision")
if not isinstance(source_revision, str) or len(source_revision) != 40:
raise ValueError("transition manifest requires full source revision")
source_files = manifest.get("source_files")
if not isinstance(source_files, dict) or not source_files or any(
not isinstance(path, str)
or not isinstance(digest, str)
or len(digest) != 64
for path, digest in source_files.items()
):
raise ValueError("transition manifest requires source files and SHA-256 digests")
source_paths = tuple(source_files)
if manifest.get("source_revision_kind") != "git-head-base":
raise ValueError("transition manifest requires git-head-base revision kind")
if source_revision != current_git_revision():
raise ValueError("transition manifest source revision does not match current Git HEAD")
if source_files != source_file_digests(source_paths):
raise ValueError("transition manifest source file digests do not match current files")
if manifest.get("source_worktree_dirty") != git_worktree_dirty(source_paths):
raise ValueError("transition manifest working-tree state does not match current files")
if not isinstance(manifest.get("evidence_command"), list) or not manifest["evidence_command"]:
raise ValueError("transition manifest requires evidence command")
if not runtime.get("host_architecture") or not runtime.get("browser_user_agent"):
raise ValueError("transition manifest requires runtime identity")
def current_git_revision() -> str:
return subprocess.run(
["git", "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
def git_worktree_dirty(paths: tuple[str, ...]) -> bool:
result = subprocess.run(
["git", "status", "--porcelain", "--", *paths],
check=True,
capture_output=True,
text=True,
)
return bool(result.stdout.strip())
def source_file_digests(paths: tuple[str, ...]) -> dict[str, str]:
for path in paths:
candidate = Path(path)
if candidate.is_absolute() or ".." in candidate.parts:
raise ValueError("source file paths must be repository-relative")
return {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths}
async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: str,
screenshot: Path, snapshot: Path, interval_ms: int, frame_count: int,
admin_id: str, admin_password: str) -> dict[str, Any]:
async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws:
cdp = CdpClient(ws)
await cdp.call("Page.enable")
await cdp.call("Runtime.enable")
await cdp.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False})
await cdp.call("Page.navigate", {"url": base_url})
await asyncio.sleep(1)
login_expression = """(async () => {
const body = new URLSearchParams({admin_id: %s, admin_password: %s});
const response = await fetch('/api/auth/login', {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body});
return {status: response.status, location: response.url};
})()""" % (json.dumps(admin_id), json.dumps(admin_password))
await cdp.call("Runtime.evaluate", {"expression": login_expression, "awaitPromise": True, "returnByValue": True})
await cdp.call("Page.navigate", {"url": f"{base_url}/?page={urllib.parse.quote(from_page)}"})
deadline = time.time() + 45
while time.time() < deadline:
check = await cdp.call("Runtime.evaluate", {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True})
if from_page in (check.get("result", {}).get("value", "") or ""):
break
await asyncio.sleep(.5)
install = """(() => {
window.__evidenceEvents=[];
window.__evidenceDocumentMarker ||= crypto.randomUUID();
window.__evidencePriorBodyText = (document.querySelector('[data-testid=stMain]') || document.body)?.innerText || '';
const sidebar=document.querySelector('[data-testid=stSidebar]');
if (sidebar) sidebar.dataset.evidenceMarker ||= crypto.randomUUID();
window.__evidenceSurface = (selector) => { const element=document.querySelector(selector); if (!element) return null; const style=getComputedStyle(element); const rect=element.getBoundingClientRect(); return {backgroundImage:style.backgroundImage, backgroundColor:style.backgroundColor, visibility:style.visibility, display:style.display, opacity:style.opacity, rendered:rect.width>0 && rect.height>0 && style.visibility!=='hidden' && style.display!=='none' && Number(style.opacity)>0}; };
for (const name of ['pushState','replaceState']) {
const old=history[name];
history[name]=function(){window.__evidenceEvents.push({type:name,url:String(arguments[2]||location.href)}); return old.apply(this,arguments)};
}
})()"""
await cdp.call("Runtime.evaluate", {"expression": install})
cdp.events.clear()
observations: list[dict[str, Any]] = []
click = """(() => { const labels=[...document.querySelectorAll('label')]; const target=labels.find(x=>x.innerText.trim()===%s); if(target){target.click(); return true;} const input=[...document.querySelectorAll('input')].find(x=>x.value===%s); if(input){input.click(); return true;} return false; })()""" % (json.dumps(to_page), json.dumps(to_page))
deadline = time.time() + 45
index = 0
while time.time() < deadline and (index < frame_count or not observations or observations[-1].get("selected_menu") != to_page):
expression = """(() => { const body=document.body; const text=body?.innerText||''; const main=document.querySelector('[data-testid=stMain]')||body; const mainText=main.innerText||''; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); const selected=selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || ''; const prior=window.__evidencePriorBodyText||''; return {at_ms:%d, selected_menu:selected, body_text:mainText, prior_body_visible:Boolean(prior)&&mainText.includes(prior), document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, loading:/불러오는 중/.test(text)}; })()""" % (index * interval_ms)
result = await cdp.call("Runtime.evaluate", {"expression": expression, "returnByValue": True})
frame = result.get("result", {}).get("value", {})
frame["body_text_hash"] = hashlib.sha256(frame.get("body_text", "").encode()).hexdigest()
frame.pop("body_text", None)
observations.append(frame)
if index == 0:
await cdp.call("Runtime.evaluate", {"expression": click, "returnByValue": True})
index += 1
await asyncio.sleep(interval_ms / 1000)
final = await cdp.call("Runtime.evaluate", {"expression": "(() => { const main=document.querySelector('[data-testid=stMain]')||document.body; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); return {url:location.href, body:main.innerText||'', selected:selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || '', document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, history_events:window.__evidenceEvents||[], browser_user_agent:navigator.userAgent}; })()", "returnByValue": True})
data = final.get("result", {}).get("value", {})
html = await cdp.call("Runtime.evaluate", {"expression": "document.documentElement.outerHTML", "returnByValue": True})
html_text = html.get("result", {}).get("value", "")
snapshot.write_text(redact_secrets(html_text, (admin_id, admin_password)), encoding="utf-8")
image = await cdp.call("Page.captureScreenshot", {"format":"png", "fromSurface":True, "captureBeyondViewport":True})
screenshot.write_bytes(base64.b64decode(image["data"]))
document_events = [{"type": event.get("method"), "params": event.get("params", {})} for event in cdp.events if event.get("method") in ("Page.frameNavigated", "Page.loadEventFired")]
pre = observations[0] if observations else {}
loading_frames = [frame for frame in observations if frame.get("loading")]
return {"frames": observations, "document_navigation_events": document_events, "history_events": data.get("history_events", []), "stable_shell": {"document_marker": {"pre": pre.get("document_marker"), "post": data.get("document_marker")}, "sidebar_marker": {"pre": pre.get("sidebar_marker"), "post": data.get("sidebar_marker")}, "background": {"pre": pre.get("background"), "post": data.get("background")}, "surfaces": {"pre": pre.get("surfaces"), "post": data.get("surfaces")}}, "loading_observed": bool(loading_frames), "prior_body_removed": bool(loading_frames) and all(frame.get("prior_body_visible") is False for frame in loading_frames), "target_page": to_page, "final_agreement": data.get("selected") == to_page and to_page in data.get("body", ""), "final_url": data.get("url", ""), "browser_user_agent": data.get("browser_user_agent", "")}
def transition_capture(chrome: str, base_url: str, from_page: str, to_page: str, output_dir: Path, devtools_port: int,
interval_ms: int, frame_count: int,
admin_id: str, admin_password: str) -> dict[str, Any]:
profile_dir = output_dir / "chrome-profile-transition"
screenshot, snapshot = output_dir / "transition.png", output_dir / "transition.dom.html"
chrome_cmd = [chrome, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors", f"--remote-debugging-port={devtools_port}", f"--user-data-dir={profile_dir}", "--window-size=1440,1400", "about:blank"]
proc = subprocess.Popen(chrome_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
ws_url = wait_for_devtools(devtools_port)
return asyncio.run(cdp_transition(ws_url, base_url, from_page, to_page, screenshot, snapshot, interval_ms, frame_count, admin_id, admin_password))
finally:
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=8)
except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5)
remove_tree_with_retries(profile_dir)
def main() -> int:
args = parse_args()
chrome = require_executable(args.chrome, "Chrome")
streamlit = require_executable(args.streamlit, "Streamlit")
streamlit = None if args.mode == "transition" else require_executable(args.streamlit, "Streamlit")
run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S")
output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id
output_dir.mkdir(parents=True, exist_ok=True)
@@ -308,56 +548,105 @@ def main() -> int:
env = os.environ.copy()
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
base_url = f"http://{args.host}:{args.port}"
server_cmd = [
streamlit,
"run",
"dashboard/app.py",
"--server.address",
args.host,
"--server.port",
str(args.port),
"--server.headless",
"true",
"--browser.gatherUsageStats",
"false",
]
transition_secrets: tuple[str, ...] = ()
if args.mode == "transition":
if not args.cert_file or not args.key_file:
raise SystemExit("transition mode requires --cert-file and --key-file")
env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18)
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32)
env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32)
transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"])
scheme = "https" if args.mode == "transition" else "http"
base_url = f"{scheme}://{args.host}:{args.port}"
if args.mode == "transition":
server_cmd = [sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--ssl-certfile", args.cert_file, "--ssl-keyfile", args.key_file]
else:
server_cmd = [
streamlit, "run", "dashboard/app.py", "--server.address", args.host,
"--server.port", str(args.port), "--server.headless", "true",
"--browser.gatherUsageStats", "false",
]
server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True)
manifest: dict[str, Any] = {
"success": False,
"mode": args.mode,
"base_url": base_url,
"output_dir": str(output_dir.resolve()),
"fixture": fixture,
"fixture": {"db_path": fixture["db_path"]},
"chrome": chrome,
"streamlit": streamlit,
"pages": [],
"server_command": server_cmd,
}
error: str | None = None
try:
wait_for_http(base_url)
for index, page in enumerate(args.pages or DEFAULT_PAGES):
manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
manifest["success"] = True
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None)
if args.mode == "transition":
observations = transition_capture(chrome, base_url, args.from_page, args.to_page, output_dir, 19222, args.interval_ms, args.frames, env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"])
source_paths = (
"dashboard/app.py",
"dashboard/common.py",
"scripts/streamlit_browser_evidence.py",
)
manifest.update(
{
"observations": observations,
"runtime": {
"viewport": {"width": 1440, "height": 1400},
"interval_ms": args.interval_ms,
"frame_count": args.frames,
"host_architecture": platform.machine(),
"python_version": platform.python_version(),
"browser_user_agent": observations.get("browser_user_agent", ""),
},
"source_revision": current_git_revision(),
"source_revision_kind": "git-head-base",
"source_worktree_dirty": git_worktree_dirty(source_paths),
"source_files": source_file_digests(source_paths),
"evidence_command": ["scripts/streamlit_browser_evidence.py", *sys.argv[1:]],
"limitations": ["TLS uses caller-provided local certificate"],
}
)
else:
for index, page in enumerate(args.pages or DEFAULT_PAGES):
manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
except Exception as exc: # noqa: BLE001 - failure is recorded in redacted evidence
error = str(exc)
finally:
log_tail = ""
if server.stdout is not None:
try:
time.sleep(0.5)
if server.poll() is not None:
log_tail = server.stdout.read()[-4000:]
except Exception:
log_tail = ""
manifest["server_log_tail"] = log_tail
(output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
if not args.keep_server and server.poll() is None:
os.killpg(server.pid, signal.SIGTERM)
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(server.pid, signal.SIGKILL)
server.wait(timeout=5)
returncode = server.poll()
teardown = "complete" if not args.keep_server and returncode is not None else "incomplete"
effective_exit = 0 if returncode in (0, -signal.SIGTERM) else returncode
manifest["server_log_tail"] = ""
manifest["command"] = {"exit_code": effective_exit, "teardown": teardown}
if error:
manifest["error"] = error
if args.mode == "transition" and error is None and teardown == "complete" and effective_exit == 0:
try:
manifest["success"] = True
validate_transition_manifest(manifest, transition_secrets)
except Exception as exc: # noqa: BLE001 - preserve failed manifest
manifest["success"] = False
manifest["error"] = str(exc)
elif args.mode == "capture" and error is None and (
(args.keep_server and returncode is None)
or (teardown == "complete" and effective_exit == 0)
):
manifest["success"] = True
safe_manifest = redact_secrets(manifest, transition_secrets)
manifest_path = output_dir / "manifest.json"
temporary_path = output_dir / ".manifest.json.tmp"
temporary_path.write_text(json.dumps(safe_manifest, ensure_ascii=False, indent=2), encoding="utf-8")
temporary_path.replace(manifest_path)
print(json.dumps(safe_manifest, ensure_ascii=False, indent=2))
return 0 if manifest.get("success") is True else 1
if __name__ == "__main__":
+167 -1
View File
@@ -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
+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():
+219
View File
@@ -0,0 +1,219 @@
from __future__ import annotations
from pathlib import Path
import sys
import pytest
from streamlit.testing.v1 import AppTest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard import app, authentication, common
from scripts.ci_fixture_db import create_ci_fixture_db
PAGES = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
def test_dashboard_menu_labels_and_order_are_preserved(monkeypatch):
calls = {}
def radio(label, options, **kwargs):
calls.update(label=label, options=list(options), kwargs=kwargs)
return "요약"
monkeypatch.setattr(common.st, "radio", radio)
assert common.render_sidebar_nav(PAGES, "요약") == "요약"
assert calls["options"] == PAGES
assert calls["kwargs"]["key"] == "dashboard_page"
assert calls["kwargs"]["label_visibility"] == "collapsed"
def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
query = {"page": "요약", "month": "2026-06", "account_id": "checking"}
state = {"dashboard_page": "거래내역"}
callback = {}
monkeypatch.setattr(common.st, "query_params", query)
monkeypatch.setattr(common.st, "session_state", state)
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
common.render_sidebar_nav(PAGES, "요약")
callback["fn"]()
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
def test_missing_or_unknown_page_falls_back_to_overview():
assert common.validate_dashboard_page(None, PAGES) == "요약"
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
assert common.validate_dashboard_page("거래내역", PAGES) == "거래내역"
def test_direct_page_query_access_renders_requested_page(monkeypatch):
selected = {}
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "계좌"})
monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "요약"})
def capture_sidebar_page(pages, page):
selected["sidebar_page"] = page
selected["session_page"] = app.st.session_state["dashboard_page"]
return page
monkeypatch.setattr(app, "render_sidebar_nav", capture_sidebar_page)
monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: False)
monkeypatch.setattr(app, "page_accounts", lambda db: selected.setdefault("db", db))
for name in PAGES:
if name != "계좌":
monkeypatch.setattr(app, {"월정산": "page_monthly_settlement", "요약": "page_overview", "자산/마감": "page_assets", "거래내역": "page_transactions", "확인필요 큐": "page_cross_transfer_review", "미분류": "page_uncategorized", "분류 규칙": "page_rules", "카테고리": "page_categories", "Import": "page_imports"}[name], lambda db: None)
monkeypatch.setattr(app, "render_dashboard_body", lambda page, renderer: renderer())
app.main()
assert selected == {
"sidebar_page": "계좌",
"session_page": "계좌",
"db": "fixture.sqlite3",
}
def test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings(tmp_path, monkeypatch):
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
assert settings is not None
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
at = AppTest.from_file(str(app_path), default_timeout=10)
at.query_params["page"] = "요약"
at.query_params["month"] = "2026-06"
at.run()
at.radio(key="dashboard_page").set_value("분류 규칙").run()
assert not at.exception
assert at.query_params["page"] == ["분류 규칙"]
assert at.query_params["month"] == ["2026-06"]
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
assert "현재 분류 규칙" in body_text
def test_slow_transition_clears_previous_body_and_shows_target_loading(monkeypatch):
events = []
monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
common.render_dashboard_body("거래내역", lambda: events.append(("render", "거래내역")))
assert events[0] == ("empty", "placeholder")
assert ("open", "dashboard_body") in events
assert ("open", "dashboard_loading") in events
assert ("open", "dashboard_content") in events
assert events.index(("info", "거래내역 불러오는 중…")) < events.index(("render", "거래내역"))
assert events.index(("render", "거래내역")) < len(events) - 1
assert events.count(("empty", "placeholder")) >= 2
def test_body_error_is_local_and_retry_rerenders_target(monkeypatch):
events = []
monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
monkeypatch.setattr(common.st, "error", lambda message: events.append(("error", message)))
monkeypatch.setattr(common.st, "button", lambda *args, **kwargs: events.append(("retry", args[0])) or True)
monkeypatch.setattr(common.st, "rerun", lambda: events.append(("rerun", "target")))
common.render_dashboard_body("요약", lambda: (_ for _ in ()).throw(RuntimeError("secret details")))
assert ("error", "페이지를 불러오지 못했습니다. 다시 시도해 주세요.") in events
assert ("rerun", "target") in events
assert all("secret details" not in str(event) for event in events)
def test_rapid_menu_clicks_commit_latest_selection_only(monkeypatch):
query = {"page": "요약", "filter": "pending"}
state = {"dashboard_page": "요약"}
callback = {}
monkeypatch.setattr(common.st, "query_params", query)
monkeypatch.setattr(common.st, "session_state", state)
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
common.render_sidebar_nav(PAGES, "요약")
for page in ("거래내역", "자산/마감", "Import"):
state["dashboard_page"] = page
callback["fn"]()
assert query == {"page": "Import", "filter": "pending"}
def test_refresh_control_preserves_cache_clear_contract(monkeypatch):
events = []
class ExpectedRerun(BaseException):
pass
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "요약"})
monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page)
monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: True)
monkeypatch.setattr(app, "clear_cache", lambda: events.append("clear_cache"))
def rerun():
events.append("rerun")
raise ExpectedRerun
monkeypatch.setattr(app.st, "rerun", rerun)
with pytest.raises(ExpectedRerun):
app.main()
assert events == ["clear_cache", "rerun"]
class _FakePlaceholder:
def __init__(self, events):
self.events = events
def container(self):
return _FakeContainer(self.events)
def info(self, message):
self.events.append(("info", message))
def empty(self):
self.events.append(("empty", "placeholder"))
class _FakeContainer:
def __init__(self, events):
self.events = events
def __enter__(self):
self.events.append(("container", "open"))
return self
def __exit__(self, exc_type, exc, tb):
self.events.append(("container", "close"))
return False
class _FakeKeyedContainer:
def __init__(self, events, key):
self.events = events
self.key = key
def __enter__(self):
self.events.append(("open", self.key))
return self
def __exit__(self, exc_type, exc, tb):
self.events.append(("close", self.key))
return False
def empty(self):
self.events.append(("empty", self.key))
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
import json
import ssl
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import streamlit_browser_evidence as evidence
SECRET_VALUES = ("synthetic-password", "a" * 64, "cookie-value")
SURFACE_STYLE = {
"backgroundImage": "linear-gradient(rgb(1, 2, 3), rgb(4, 5, 6))",
"backgroundColor": "rgb(255, 255, 255)",
"visibility": "visible",
"display": "block",
"opacity": "1",
"rendered": True,
}
def surface_records() -> dict[str, dict[str, object]]:
return {
"stAppViewContainer": dict(SURFACE_STYLE),
"stSidebar": dict(SURFACE_STYLE),
}
def test_redact_secrets_removes_values_from_nested_manifest() -> None:
payload = {"url": "https://127.0.0.1/?token=cookie-value", "nested": ["synthetic-password", {"key": "a" * 64}]}
redacted = evidence.redact_secrets(payload, SECRET_VALUES)
serialized = json.dumps(redacted)
assert all(secret not in serialized for secret in SECRET_VALUES)
assert redacted["nested"][0] == "[REDACTED]"
def test_transition_requires_synthetic_fixture_database(tmp_path: Path) -> None:
with pytest.raises(SystemExit, match="fixture.sqlite3"):
evidence.assert_synthetic_db_path(tmp_path / "operating.sqlite3")
def test_validate_transition_manifest_requires_observations() -> None:
manifest = {
"mode": "transition",
"success": True,
"observations": {"frames": [], "navigation_events": []},
}
with pytest.raises(ValueError, match="frames"):
evidence.validate_transition_manifest(manifest)
def complete_manifest() -> dict[str, Any]:
return {
"mode": "transition",
"success": True,
"observations": {
"frames": [
{
"at_ms": 0,
"selected_menu": "요약",
"body_text_hash": "hash",
"loading": False,
"prior_body_visible": True,
"surfaces": surface_records(),
},
{
"at_ms": 100,
"selected_menu": "분류 규칙",
"body_text_hash": "hash2",
"loading": True,
"prior_body_visible": False,
"surfaces": surface_records(),
},
],
"document_navigation_events": [],
"history_events": [{"type": "pushState", "url": "/?page=rules"}],
"stable_shell": {
"document_marker": {"pre": "doc-1", "post": "doc-1"},
"sidebar_marker": {"pre": "side-1", "post": "side-1"},
"background": {"pre": "rgb(255, 255, 255)", "post": "rgb(255, 255, 255)"},
"surfaces": {"pre": surface_records(), "post": surface_records()},
},
"loading_observed": True,
"prior_body_removed": True,
"target_page": "분류 규칙",
"final_agreement": True,
},
"runtime": {
"viewport": {"width": 1440, "height": 1400},
"interval_ms": 50,
"host_architecture": "aarch64",
"browser_user_agent": "Synthetic Browser",
},
"source_revision": evidence.current_git_revision(),
"source_revision_kind": "git-head-base",
"source_worktree_dirty": evidence.git_worktree_dirty(("dashboard/app.py", "dashboard/common.py")),
"source_files": evidence.source_file_digests(("dashboard/app.py", "dashboard/common.py")),
"evidence_command": ["scripts/streamlit_browser_evidence.py", "--mode", "transition"],
"command": {"exit_code": 0, "teardown": "complete"},
"limitations": [],
}
def test_validate_transition_manifest_accepts_complete_secret_free_shape() -> None:
evidence.validate_transition_manifest(complete_manifest())
def test_wait_for_http_uses_verified_tls_by_default_and_fixture_context_opt_in() -> None:
response = type("Response", (), {"status": 200, "__enter__": lambda self: self, "__exit__": lambda *args: None})()
with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
evidence.wait_for_http("https://example.test")
assert urlopen.call_args.kwargs["context"] is None
fixture_context = ssl._create_unverified_context()
with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
evidence.wait_for_http("https://127.0.0.1", ssl_context=fixture_context)
assert urlopen.call_args.kwargs["context"] is fixture_context
def test_validate_transition_manifest_rejects_fabricated_navigation_event() -> None:
manifest = complete_manifest()
manifest["observations"]["document_navigation_events"] = [{"type": "load", "url": "/"}]
with pytest.raises(ValueError, match="document navigation"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_requires_observed_loading_without_prior_body() -> None:
no_loading = complete_manifest()
no_loading["observations"]["loading_observed"] = False
with pytest.raises(ValueError, match="loading"):
evidence.validate_transition_manifest(no_loading)
stale_body = complete_manifest()
stale_body["observations"]["frames"][1]["prior_body_visible"] = True
with pytest.raises(ValueError, match="prior body"):
evidence.validate_transition_manifest(stale_body)
def test_validate_transition_manifest_requires_pre_post_shell_identity() -> None:
manifest = complete_manifest()
manifest["observations"]["stable_shell"] = {"sidebar": True, "background": "#fff"}
with pytest.raises(ValueError, match="shell"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_rejects_unselected_target_body_agreement() -> None:
manifest = complete_manifest()
manifest["observations"]["final_agreement"] = False
with pytest.raises(ValueError, match="agreement"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_rejects_attestation_before_teardown() -> None:
manifest = complete_manifest()
manifest["command"] = {"exit_code": 0, "teardown": "running"}
with pytest.raises(ValueError, match="teardown"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_requires_exact_source_and_runtime_identity() -> None:
manifest = complete_manifest()
manifest.pop("source_files")
with pytest.raises(ValueError, match="source files"):
evidence.validate_transition_manifest(manifest)
manifest = complete_manifest()
manifest["runtime"].pop("browser_user_agent")
with pytest.raises(ValueError, match="runtime identity"):
evidence.validate_transition_manifest(manifest)
manifest = complete_manifest()
manifest["source_files"]["dashboard/app.py"] = "0" * 64
with pytest.raises(ValueError, match="source file digests"):
evidence.validate_transition_manifest(manifest)
def test_transition_manifest_rejects_secret_text() -> None:
manifest = {"mode": "transition", "success": False, "error": "synthetic-password"}
with pytest.raises(ValueError, match="secret"):
evidence.validate_transition_manifest(manifest, SECRET_VALUES)
def test_validate_transition_manifest_requires_surface_style_evidence_on_every_frame() -> None:
manifest = complete_manifest()
manifest["observations"]["frames"][1]["surfaces"].pop("stSidebar")
with pytest.raises(ValueError, match="surface"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_requires_stable_surface_style_evidence() -> None:
manifest = complete_manifest()
manifest["observations"]["stable_shell"]["surfaces"]["post"]["stSidebar"]["opacity"] = "0"
with pytest.raises(ValueError, match="surface"):
evidence.validate_transition_manifest(manifest)
@pytest.mark.parametrize(
("field", "value"),
(
("backgroundImage", "none"),
("backgroundColor", ""),
("visibility", "hidden"),
("display", "none"),
("opacity", "0"),
("opacity", "NaN"),
("rendered", False),
),
)
def test_validate_transition_manifest_rejects_non_visible_surface_styles(field: str, value: object) -> None:
manifest = complete_manifest()
observations = manifest["observations"]
for frame in observations["frames"]:
frame["surfaces"]["stAppViewContainer"][field] = value
observations["stable_shell"]["surfaces"]["pre"]["stAppViewContainer"][field] = value
observations["stable_shell"]["surfaces"]["post"]["stAppViewContainer"][field] = value
with pytest.raises(ValueError, match="surface"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_rejects_missing_stable_shell_markers() -> None:
manifest = complete_manifest()
manifest["observations"]["stable_shell"]["document_marker"] = {"pre": None, "post": None}
with pytest.raises(ValueError, match="shell"):
evidence.validate_transition_manifest(manifest)
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "jarca-ledger-mcp"
version = "0.1.2"
version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "mcp" },