diff --git a/services/astroklient/app/produkt.py b/services/astroklient/app/produkt.py new file mode 100644 index 0000000..f47f522 --- /dev/null +++ b/services/astroklient/app/produkt.py @@ -0,0 +1,8 @@ +"""Nazwa produktu — nakładka astroklienta. + +Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa +tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach. +""" +from __future__ import annotations + +NAZWA = "astroklient" diff --git a/services/astroklient/tests/conftest.py b/services/astroklient/tests/conftest.py index 2d0974a..b72c519 100644 --- a/services/astroklient/tests/conftest.py +++ b/services/astroklient/tests/conftest.py @@ -7,8 +7,12 @@ inaczej sprawdzałyby garść plików, a nie produkt. """ from __future__ import annotations +import json import pathlib +import re import shutil +import subprocess +import sys import pytest @@ -35,3 +39,56 @@ def zloz(cel: pathlib.Path) -> pathlib.Path: @pytest.fixture(scope="session") def drzewo(tmp_path_factory) -> pathlib.Path: return zloz(tmp_path_factory.mktemp("astroklient")) + + +SONDA = ''' +import json, os, hashlib, secrets, re +sol = secrets.token_bytes(16) +dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32) +os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()), + SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0") +from starlette.testclient import TestClient +from app.main import app +from app import security, session, features +from app.clients import logic_client +c = TestClient(app, follow_redirects=False) +H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"} +strona = c.get("/", headers=H).text +zebrane = [] +logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1] +c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20", + "tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"}) +print("WYNIK:" + json.dumps({ + "ekrany": [f.label for f in features.SCREENS], + "uprawnienia": sorted(security._uprawnienia()), + "dziala": {a: c.get(a, headers=H).status_code + for a in ("/", "/interpret", "/timeline", "/synastry", + "/significators", "/pliki")}, + "nie_ma": {a: c.get(a, headers=H).status_code + for a in ("/compile", "/settings", "/accounts", "/files", + "/horoscope/stream", "/compile/pdf")}, + "nawigacja": re.findall(r"", strona, re.S)[0].count('(.*?)", strona) or [None, "?"])[1], + "nazwa_w_tytule": (re.search(r"(.*?) ·", strona) or [None, "?"])[1], + "rdzen_w_tresci": "astrololo" in strona.lower(), + "dokumentacja": {a: c.get(a, headers=H).status_code + for a in ("/docs", "/openapi.json", "/redoc")}, + "logowanie_dziala": bool(security.verify("klient", "tajne")), + "zle_haslo_odmowa": security.verify("klient", "inne") is None, +})) +''' + + +@pytest.fixture(scope="module") +def wynik(drzewo): + """Aplikacja uruchomiona w OSOBNYM PROCESIE. + + Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez + modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie + pozytywny.""" + r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True, + text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"}) + linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None) + assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}" + return json.loads(linia[len("WYNIK:"):]) diff --git a/services/astroklient/tests/test_slownik_zakazany.py b/services/astroklient/tests/test_slownik_zakazany.py index 730f0d9..851a500 100644 --- a/services/astroklient/tests/test_slownik_zakazany.py +++ b/services/astroklient/tests/test_slownik_zakazany.py @@ -86,3 +86,52 @@ def test_the_removal_list_matches_what_is_actually_composed(drzewo): def test_no_optional_module_survived(drzewo): """Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero.""" assert not (drzewo / "app" / "moduly").exists() + + +# ── nazwa produktu, z którego ten produkt powstał ───────────────────────── +# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno +# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do +# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co +# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę +# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej +# okazji. +NAZWA_RDZENIA = "astrololo" +WIDOCZNE_DLA_KLIENTA = ("templates", "static") + + +def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo): + """Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała + z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo. + + To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma + okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać, + czego mu nie dano.""" + grzechy = [] + for katalog in WIDOCZNE_DLA_KLIENTA: + for f in sorted((drzewo / "app" / katalog).rglob("*")): + if not f.is_file() or "__pycache__" in str(f): + continue + try: + tresc = f.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if NAZWA_RDZENIA in tresc.lower(): + grzechy.append(str(f.relative_to(drzewo))) + assert not grzechy, ( + f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje " + f"przeglądarka:\n " + "\n ".join(grzechy)) + + +def test_the_rendered_pages_carry_this_products_own_name(wynik): + """Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do + człowieka, a nie to, co jest w pliku przed złożeniem.""" + assert wynik["nazwa_w_naglowku"] == "astroklient" + assert wynik["nazwa_w_tytule"] == "astroklient" + assert not wynik["rdzen_w_tresci"], ( + "nazwa pełnej aplikacji pada w treści strony oddawanej klientowi") + + +def test_the_api_documentation_is_not_reachable(wynik): + """Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja + OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste.""" + assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"] diff --git a/services/astroklient/tests/test_zlozenie.py b/services/astroklient/tests/test_zlozenie.py index 23f3c34..eb7cd81 100644 --- a/services/astroklient/tests/test_zlozenie.py +++ b/services/astroklient/tests/test_zlozenie.py @@ -5,61 +5,6 @@ czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym s """ from __future__ import annotations -import json -import re -import subprocess -import sys - -import pytest - -SONDA = ''' -import json, os, hashlib, secrets, re -sol = secrets.token_bytes(16) -dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32) -os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()), - SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0") -from starlette.testclient import TestClient -from app.main import app -from app import security, session, features -from app.clients import logic_client -c = TestClient(app, follow_redirects=False) -H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"} -strona = c.get("/", headers=H).text -zebrane = [] -logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1] -c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20", - "tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"}) -print("WYNIK:" + json.dumps({ - "ekrany": [f.label for f in features.SCREENS], - "uprawnienia": sorted(security._uprawnienia()), - "dziala": {a: c.get(a, headers=H).status_code - for a in ("/", "/interpret", "/timeline", "/synastry", - "/significators", "/pliki")}, - "nie_ma": {a: c.get(a, headers=H).status_code - for a in ("/compile", "/settings", "/accounts", "/files", - "/horoscope/stream", "/compile/pdf")}, - "nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'), - "pula": (zebrane[0] or {}).get("tenant"), - "logowanie_dziala": bool(security.verify("klient", "tajne")), - "zle_haslo_odmowa": security.verify("klient", "inne") is None, -})) -''' - - -@pytest.fixture(scope="module") -def wynik(drzewo): - """Aplikacja uruchomiona w OSOBNYM PROCESIE. - - Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez - modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie - pozytywny.""" - r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True, - text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"}) - linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None) - assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}" - return json.loads(linia[len("WYNIK:"):]) - - def test_the_build_starts_at_all(wynik): assert wynik["dziala"]["/"] == 200 diff --git a/services/presentation/app/moduly/dodatki/static/natal.js b/services/presentation/app/moduly/dodatki/static/natal.js index 10abacb..fe97526 100644 --- a/services/presentation/app/moduly/dodatki/static/natal.js +++ b/services/presentation/app/moduly/dodatki/static/natal.js @@ -93,5 +93,5 @@ } }); - window.astrololoNatal = { read: read, clear: clear, key: KEY }; + window.kosmogramNatal = { read: read, clear: clear, key: KEY }; })(); diff --git a/services/presentation/app/moduly/dodatki/static/predictions.js b/services/presentation/app/moduly/dodatki/static/predictions.js index 8f8e73a..ee93dd6 100644 --- a/services/presentation/app/moduly/dodatki/static/predictions.js +++ b/services/presentation/app/moduly/dodatki/static/predictions.js @@ -146,5 +146,5 @@ }); // Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy. - window.astrololoPredictions = { read: read, remove: remove, key: KEY }; + window.kosmogramPredictions = { read: read, remove: remove, key: KEY }; })(); diff --git a/services/presentation/app/podstawa.py b/services/presentation/app/podstawa.py index 6299104..41428d3 100644 --- a/services/presentation/app/podstawa.py +++ b/services/presentation/app/podstawa.py @@ -27,6 +27,7 @@ from fastapi.templating import Jinja2Templates from app import rozszerzenia from app import chartwheel as chartwheel_mod from app import features +from app import produkt from app import session from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label @@ -142,6 +143,9 @@ def static_file(nazwa: str): ) +# Nazwa produktu z modułu, nie z szablonu: szablony są wspólne dla kilku +# produktów, a nazwa nie. +templates.env.globals["produkt"] = produkt.NAZWA templates.env.globals["static"] = static_url # Pozycje specjalne przefiltrowane uprawnieniem — szablon dostaje gotową listę # i nie musi wiedzieć, czego która wymaga. diff --git a/services/presentation/app/produkt.py b/services/presentation/app/produkt.py new file mode 100644 index 0000000..5d9a0df --- /dev/null +++ b/services/presentation/app/produkt.py @@ -0,0 +1,12 @@ +"""Nazwa produktu — to, co widzi człowiek w tytule i nagłówku. + +Osobny moduł, bo szablony są WSPÓLNE dla kilku produktów, a nazwa nie. Wpisana +w szablon na sztywno jechała z nim do każdego obrazu, który go bierze — i tak +astroklient przedstawiał się jako astrololo, czyli mówił klientowi wprost, że +trzyma okrojoną wersję czegoś większego, i podawał frazę do wyszukania. + +Produkt składający się z tego rdzenia NADPISUJE ten plik swoją nakładką. +""" +from __future__ import annotations + +NAZWA = "astrololo" diff --git a/services/presentation/app/static/compile.js b/services/presentation/app/static/compile.js index 33f8cfc..c89d0e2 100644 --- a/services/presentation/app/static/compile.js +++ b/services/presentation/app/static/compile.js @@ -22,11 +22,11 @@ } function readNatal() { - return (window.astrololoNatal && window.astrololoNatal.read()) || null; + return (window.kosmogramNatal && window.kosmogramNatal.read()) || null; } function readPredictions() { - return (window.astrololoPredictions && window.astrololoPredictions.read()) || []; + return (window.kosmogramPredictions && window.kosmogramPredictions.read()) || []; } function item(ok, label, hint) { diff --git a/services/presentation/app/static/formsync.js b/services/presentation/app/static/formsync.js index f72d998..aa612cb 100644 --- a/services/presentation/app/static/formsync.js +++ b/services/presentation/app/static/formsync.js @@ -16,7 +16,7 @@ (function () { 'use strict'; - var KEY = 'astrololo.form.v1'; + var KEY = 'kosmogram.form.v1'; // Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć. // `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id. @@ -122,7 +122,7 @@ // 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie // też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy) - document.addEventListener('astrololo:coords', function () { + document.addEventListener('kosmogram:coords', function () { setTimeout(function () { save(collect()); }, 0); }); })(); diff --git a/services/presentation/app/static/geo.js b/services/presentation/app/static/geo.js index bcfa07c..bcf7d6b 100644 --- a/services/presentation/app/static/geo.js +++ b/services/presentation/app/static/geo.js @@ -2,7 +2,7 @@ // Wpina się do formularza z polami input[name=lat] / input[name=lon]: // - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon, // - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę, -// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje. +// - „Tu i teraz" (now.js) emituje event 'kosmogram:coords' -> mapa się synchronizuje. // Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak. (function () { function ready(fn) { @@ -163,7 +163,7 @@ // zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno // miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu, // na który faktycznie ustawiła się mapa. - document.addEventListener('astrololo:coords', function () { + document.addEventListener('kosmogram:coords', function () { marker.setLatLng([curLat(), curLon()]); map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12)); reverseName(curLat(), curLon()); diff --git a/services/presentation/app/static/now.js b/services/presentation/app/static/now.js index 70bc7f1..269f144 100644 --- a/services/presentation/app/static/now.js +++ b/services/presentation/app/static/now.js @@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () { function (pos) { document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4); document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4); - document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js) + document.dispatchEvent(new CustomEvent('kosmogram:coords')); // zsynchronizuj mapę (geo.js) say('Lokalizacja pobrana ✓'); }, function (err) { diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index ad1a3f1..d125f1f 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>astrololo · {% block title %}{% endblock %} + {{ produkt }} · {% block title %}{% endblock %} {# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`. Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści @@ -20,7 +20,7 @@
-

astrololo

+

{{ produkt }}

{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych — ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że program umie więcej, niż pokazuje (PRE-26). #} diff --git a/services/presentation/app/templates/logowanie.html b/services/presentation/app/templates/logowanie.html index 89dd9a4..9019677 100644 --- a/services/presentation/app/templates/logowanie.html +++ b/services/presentation/app/templates/logowanie.html @@ -3,14 +3,14 @@ - astrololo · logowanie + {{ produkt }} · logowanie {# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
-

astrololo

+

{{ produkt }}

{% if request.query_params.get('wylogowano') %}

Wylogowano.

diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py index df906d3..c5b0f98 100644 --- a/services/presentation/tests/test_compile.py +++ b/services/presentation/tests/test_compile.py @@ -137,8 +137,8 @@ def _pdf_sample_chart() -> dict: def test_reads_stores_through_their_api_not_raw_storage(): """Format danych ma jednego właściciela — moduł, który je zapisuje. Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie.""" - assert "window.astrololoNatal" in JS - assert "window.astrololoPredictions" in JS + assert "window.kosmogramNatal" in JS + assert "window.kosmogramPredictions" in JS # chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu) assert "localStorage.getItem" not in JS assert "localStorage.setItem" not in JS diff --git a/services/presentation/tests/test_predictions.py b/services/presentation/tests/test_predictions.py index b72d8c2..d5d91e8 100644 --- a/services/presentation/tests/test_predictions.py +++ b/services/presentation/tests/test_predictions.py @@ -82,7 +82,7 @@ def test_corrupt_storage_does_not_break_the_page(): def test_storage_is_exposed_for_the_compile_tab(): """Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy.""" - assert "window.astrololoPredictions" in JS + assert "window.kosmogramPredictions" in JS def test_does_nothing_on_other_tabs(): diff --git a/services/presentation/tests/test_static_js.py b/services/presentation/tests/test_static_js.py index 6b12478..5f3cd7e 100644 --- a/services/presentation/tests/test_static_js.py +++ b/services/presentation/tests/test_static_js.py @@ -46,14 +46,14 @@ def _geo_source() -> str: def _handler_body() -> str: - """Ciało handlera zdarzenia astrololo:coords (wysyłanego przez „Tu i teraz"). + """Ciało handlera zdarzenia kosmogram:coords (wysyłanego przez „Tu i teraz"). Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia — ta pada także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast kodu (test przechodziłby albo padał z niewłaściwego powodu). """ src = _geo_source() - start = src.index("addEventListener('astrololo:coords'") + start = src.index("addEventListener('kosmogram:coords'") end = src.index("});", start) return src[start:end] @@ -63,7 +63,7 @@ def test_now_button_handler_refreshes_place_name(): a w polu tekstowym zostawała stara nazwa — formularz pokazywał jedno miejsce, a liczył dla innego.""" assert "reverseName(" in _handler_body(), ( - "handler astrololo:coords nie odświeża nazwy miejsca (reverseName)" + "handler kosmogram:coords nie odświeża nazwy miejsca (reverseName)" )