feat(dashboard): persist administrator login

This commit is contained in:
2026-10-01 13:56:22 +00:00
parent 7bea8b368e
commit c5eaddb4f3
20 changed files with 1343 additions and 884 deletions
+70 -89
View File
@@ -2,9 +2,7 @@ from __future__ import annotations
from pathlib import Path
import sys
import time
import pytest
from streamlit.testing.v1 import AppTest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -14,31 +12,40 @@ from dashboard import authentication
ADMIN_ID = "test-admin"
ADMIN_PASSWORD = "test-password"
SIGNING_SECRET = "s" * 64
APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
def _body_text(at: AppTest) -> str:
elements = list(at.title) + list(at.header) + list(at.subheader) + list(at.text) + list(at.markdown) + list(at.caption) + list(at.error)
elements = (
list(at.title)
+ list(at.header)
+ list(at.subheader)
+ list(at.text)
+ list(at.markdown)
+ list(at.caption)
+ list(at.error)
)
return "\n".join(str(item.value) for item in elements)
def _configured_app(monkeypatch, *, page: str = "요약") -> AppTest:
def _configure(monkeypatch) -> authentication.AuthSettings:
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
at = AppTest.from_file(str(APP_PATH), default_timeout=10)
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
settings = authentication.load_auth_settings()
assert settings is not None
return settings
def _app_with_cookie(monkeypatch, token: str | None, *, page: str = "요약") -> AppTest:
_configure(monkeypatch)
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = AppTest.from_file(str(APP_PATH), default_timeout=30)
at.query_params["page"] = page
return at.run()
def _submit_login(at: AppTest, admin_id: str, password: str) -> AppTest:
assert [field.label for field in at.text_input] == ["관리자 ID", "비밀번호"]
at.text_input[0].input(admin_id)
at.text_input[1].input(password)
login_button = next(button for button in at.button if button.label == "로그인")
login_button.click()
return at.run()
def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
@@ -46,18 +53,20 @@ def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path
create_ci_fixture_db(fixture)
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False)
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
at = AppTest.from_file(str(APP_PATH), default_timeout=10).run()
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert not at.exception
assert "관리자 로그인" in _body_text(at)
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
assert not at.text_input
assert "이번 달 수입/지출 현황" not in _body_text(at)
def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(monkeypatch):
def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(
monkeypatch,
):
from dashboard import app
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False)
@@ -71,7 +80,7 @@ def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(
def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
at = _configured_app(monkeypatch, page="Import")
at = _app_with_cookie(monkeypatch, None, page="Import")
assert not at.exception
assert "관리자 로그인" in _body_text(at)
@@ -79,96 +88,68 @@ def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
assert "Import 이력" not in _body_text(at)
@pytest.mark.parametrize(
("admin_id", "password"),
[
("wrong-id", ADMIN_PASSWORD),
(ADMIN_ID, "wrong-password"),
],
)
def test_invalid_credentials_have_same_error_and_minimum_one_second_delay(monkeypatch, admin_id, password):
at = _configured_app(monkeypatch)
def test_login_form_posts_credentials_to_server_side_adapter(monkeypatch):
at = _app_with_cookie(monkeypatch, None)
body = _body_text(at)
started_at = time.monotonic()
at = _submit_login(at, admin_id, password)
elapsed = time.monotonic() - started_at
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS
assert authentication.INVALID_CREDENTIALS_MESSAGE in _body_text(at)
assert ADMIN_ID not in _body_text(at)
assert ADMIN_PASSWORD not in _body_text(at)
assert admin_id not in _body_text(at)
assert password not in _body_text(at)
assert 'action="/api/auth/login"' in body
assert 'method="post"' in body
assert 'name="admin_id"' in body
assert 'name="admin_password"' in body
assert 'name="remember_me"' in body
assert ADMIN_ID not in repr(at.session_state)
assert ADMIN_PASSWORD not in repr(at.session_state)
assert admin_id not in repr(at.session_state)
assert password not in repr(at.session_state)
assert SIGNING_SECRET not in repr(at.session_state)
def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch):
at = _app_with_cookie(monkeypatch, "malformed-token")
assert not at.exception
assert "관리자 로그인" in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at)
def test_successful_login_persists_for_current_session_and_logout_clears_it(monkeypatch, tmp_path):
def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state(
monkeypatch, tmp_path
):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
at = _configured_app(monkeypatch)
settings = _configure(monkeypatch)
token = authentication.issue_persistent_token(
settings, remember_me=False, now=1_700_000_000
).value
monkeypatch.setattr(authentication.time, "time", lambda: 1_700_000_001)
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert not at.exception
assert "이번 달 수입/지출 현황" in _body_text(at)
assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at)
assert 'action="/api/auth/logout"' in _body_text(at)
assert token not in repr(at.session_state)
assert ADMIN_PASSWORD not in repr(at.session_state)
assert SIGNING_SECRET not in repr(at.session_state)
at = at.run()
def test_password_rotation_invalidates_existing_cookie(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
settings = _configure(monkeypatch)
token = authentication.issue_persistent_token(settings, remember_me=True).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert "이번 달 수입/지출 현황" in _body_text(at)
logout_button = next(button for button in at.button if button.label == "로그아웃")
logout_button.click()
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "rotated-password")
at = at.run()
assert "관리자 로그인" in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at)
new_session = AppTest.from_file(str(APP_PATH), default_timeout=10).run()
assert "관리자 로그인" in _body_text(new_session)
assert "이번 달 수입/지출 현황" not in _body_text(new_session)
def test_successful_login_does_not_invoke_failure_delay(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
def forbidden_failure_delay(**kwargs):
raise AssertionError("successful login must not invoke the failure delay")
monkeypatch.setattr(authentication, "enforce_failed_login_delay", forbidden_failure_delay)
at = _configured_app(monkeypatch)
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
assert not at.exception
assert "이번 달 수입/지출 현황" in _body_text(at)
def test_configuration_failure_after_login_clears_existing_authentication(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
at = _configured_app(monkeypatch)
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
assert "이번 달 수입/지출 현황" in _body_text(at)
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV)
at = at.run()
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at)
assert authentication.AUTHENTICATED_SESSION_KEY not in repr(at.session_state)
assert ADMIN_ID not in repr(at.session_state)
assert ADMIN_PASSWORD not in repr(at.session_state)