"""Серверная защита HTML страниц зоотехника (сессия + блокировка /static/*.html).""" import os import tempfile import unittest from app import create_app, db from app.services.setup_state import mark_setup_complete from config import TestingConfig class ZootechHtmlGuardTestConfig(TestingConfig): _TMP_DIR = tempfile.mkdtemp(prefix="wesp-zootech-html-tests-") SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}" SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"} AUTH_LOGIN = "zootech-guard-admin" AUTH_PASSWORD = "zootech-guard-secret" def _drain_response(resp) -> None: """Дочитать тело ответа и закрыть поток (избегает ResourceWarning от send_from_directory).""" try: resp.get_data() finally: close = getattr(resp, "close", None) if callable(close): close() def _dispose_db_engines() -> None: """Закрыть пулы SQLite после теста (убирает ResourceWarning unclosed database).""" try: for eng in db.engines.values(): eng.dispose() except Exception: try: db.engine.dispose() except Exception: pass class ZootechHtmlGuardTests(unittest.TestCase): def setUp(self) -> None: self.app = create_app(ZootechHtmlGuardTestConfig) self.client = self.app.test_client() self.ctx = self.app.app_context() self.ctx.push() db.create_all() mark_setup_complete(self.app) def tearDown(self) -> None: db.session.remove() db.drop_all() _dispose_db_engines() self.ctx.pop() def test_recipes_redirects_to_login_without_session(self) -> None: r = self.client.get("/recipes", follow_redirects=False) self.assertEqual(r.status_code, 302) loc = r.headers.get("Location", "") self.assertIn("/login", loc) self.assertIn("next=", loc) _drain_response(r) def _login(self) -> None: login = self.client.post( "/api/auth/login", json={ "login": "zootech-guard-admin", "password": "zootech-guard-secret", }, ) self.assertEqual(login.status_code, 200) _drain_response(login) def test_recipes_ok_after_login(self) -> None: self._login() r = self.client.get("/recipes") self.assertEqual(r.status_code, 200) self.assertIn("text/html", r.content_type or "") self.assertIn(b"html", r.data.lower()) _drain_response(r) def test_static_recipes_html_blocked_without_named_route(self) -> None: r = self.client.get("/static/recipes.html") self.assertEqual(r.status_code, 404) _drain_response(r) def test_static_recipes_selection_allowlisted_for_kiosk(self) -> None: r = self.client.get("/static/recipes_selection.html") self.assertEqual(r.status_code, 200) _drain_response(r) def test_static_login_html_allowlisted(self) -> None: r = self.client.get("/static/login.html") self.assertEqual(r.status_code, 200) _drain_response(r) def test_login_page_public(self) -> None: r = self.client.get("/login") self.assertEqual(r.status_code, 200) _drain_response(r) def test_zootech_pages_ok_after_login(self) -> None: self._login() for path in ( "/feed_dispensers", "/components", "/reports", "/feed_consumption", ): with self.subTest(path=path): r = self.client.get(path) self.assertEqual(r.status_code, 200, path) self.assertIn(b"html", r.data.lower()) _drain_response(r) def test_zootech_static_html_blocked_without_named_route(self) -> None: for name in ( "feed_dispensers.html", "components.html", "reports.html", "consumption.html", ): with self.subTest(static=name): r = self.client.get(f"/static/{name}") self.assertEqual(r.status_code, 404) _drain_response(r) def test_recipes_wibor_public_for_kiosk(self) -> None: """Выбор рецепта с киоска без логина зоотехника (/scales → /api/recipes_wibor).""" r = self.client.get("/api/recipes_wibor") self.assertEqual(r.status_code, 200) self.assertIn("text/html", r.content_type or "") _drain_response(r) if __name__ == "__main__": unittest.main()