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])) from dashboard import authentication ADMIN_ID = "test-admin" ADMIN_PASSWORD = "test-password" 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) return "\n".join(str(item.value) for item in elements) def _configured_app(monkeypatch, *, page: str = "요약") -> AppTest: 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) 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 fixture = tmp_path / "fixture.sqlite3" create_ci_fixture_db(fixture) monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False) monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) at = AppTest.from_file(str(APP_PATH), default_timeout=10).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): from dashboard import app monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False) def forbidden_database_access(): raise AssertionError("get_db_path must not run before authentication") monkeypatch.setattr(app, "get_db_path", forbidden_database_access) app.main() def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch): at = _configured_app(monkeypatch, page="Import") assert not at.exception assert "관리자 로그인" in _body_text(at) assert "PDF Import" not in _body_text(at) 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) 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 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 "이번 달 수입/지출 현황" not in _body_text(at) def test_successful_login_persists_for_current_session_and_logout_clears_it(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 not at.exception assert "이번 달 수입/지출 현황" in _body_text(at) assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at) assert ADMIN_PASSWORD not in repr(at.session_state) at = at.run() assert "이번 달 수입/지출 현황" in _body_text(at) logout_button = next(button for button in at.button if button.label == "로그아웃") logout_button.click() 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)