astroklient: własna nazwa produktu zamiast odziedziczonej po rdzeniu #85

Merged
gitea merged 1 commits from fix/nazwa-produktu into master 2026-08-27 15:08:36 +00:00
17 changed files with 149 additions and 74 deletions
Showing only changes of commit 338ec24e90 - Show all commits
+8
View File
@@ -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"
+57
View File
@@ -7,8 +7,12 @@ inaczej sprawdzałyby garść plików, a nie produkt.
""" """
from __future__ import annotations from __future__ import annotations
import json
import pathlib import pathlib
import re
import shutil import shutil
import subprocess
import sys
import pytest import pytest
@@ -35,3 +39,56 @@ def zloz(cel: pathlib.Path) -> pathlib.Path:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def drzewo(tmp_path_factory) -> pathlib.Path: def drzewo(tmp_path_factory) -> pathlib.Path:
return zloz(tmp_path_factory.mktemp("astroklient")) 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"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
"pula": (zebrane[0] or {}).get("tenant"),
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", 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:"):])
@@ -86,3 +86,52 @@ def test_the_removal_list_matches_what_is_actually_composed(drzewo):
def test_no_optional_module_survived(drzewo): def test_no_optional_module_survived(drzewo):
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero.""" """Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
assert not (drzewo / "app" / "moduly").exists() 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"]
@@ -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 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): def test_the_build_starts_at_all(wynik):
assert wynik["dziala"]["/"] == 200 assert wynik["dziala"]["/"] == 200
@@ -93,5 +93,5 @@
} }
}); });
window.astrololoNatal = { read: read, clear: clear, key: KEY }; window.kosmogramNatal = { read: read, clear: clear, key: KEY };
})(); })();
@@ -146,5 +146,5 @@
}); });
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy. // 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 };
})(); })();
+4
View File
@@ -27,6 +27,7 @@ from fastapi.templating import Jinja2Templates
from app import rozszerzenia from app import rozszerzenia
from app import chartwheel as chartwheel_mod from app import chartwheel as chartwheel_mod
from app import features from app import features
from app import produkt
from app import session from app import session
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label 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 templates.env.globals["static"] = static_url
# Pozycje specjalne przefiltrowane uprawnieniem — szablon dostaje gotową listę # Pozycje specjalne przefiltrowane uprawnieniem — szablon dostaje gotową listę
# i nie musi wiedzieć, czego która wymaga. # i nie musi wiedzieć, czego która wymaga.
+12
View File
@@ -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"
+2 -2
View File
@@ -22,11 +22,11 @@
} }
function readNatal() { function readNatal() {
return (window.astrololoNatal && window.astrololoNatal.read()) || null; return (window.kosmogramNatal && window.kosmogramNatal.read()) || null;
} }
function readPredictions() { function readPredictions() {
return (window.astrololoPredictions && window.astrololoPredictions.read()) || []; return (window.kosmogramPredictions && window.kosmogramPredictions.read()) || [];
} }
function item(ok, label, hint) { function item(ok, label, hint) {
+2 -2
View File
@@ -16,7 +16,7 @@
(function () { (function () {
'use strict'; 'use strict';
var KEY = 'astrololo.form.v1'; var KEY = 'kosmogram.form.v1';
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć. // Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id. // `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 // 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) // 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); setTimeout(function () { save(collect()); }, 0);
}); });
})(); })();
+2 -2
View File
@@ -2,7 +2,7 @@
// Wpina się do formularza z polami input[name=lat] / input[name=lon]: // Wpina się do formularza z polami input[name=lat] / input[name=lon]:
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/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ę, // - 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. // Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
(function () { (function () {
function ready(fn) { function ready(fn) {
@@ -163,7 +163,7 @@
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno // 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, // miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
// na który faktycznie ustawiła się mapa. // na który faktycznie ustawiła się mapa.
document.addEventListener('astrololo:coords', function () { document.addEventListener('kosmogram:coords', function () {
marker.setLatLng([curLat(), curLon()]); marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12)); map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
reverseName(curLat(), curLon()); reverseName(curLat(), curLon());
+1 -1
View File
@@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
function (pos) { function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4); document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.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 ✓'); say('Lokalizacja pobrana ✓');
}, },
function (err) { function (err) {
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title> <title>{{ produkt }} · {% block title %}{% endblock %}</title>
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa {# 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`. 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 Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
@@ -20,7 +20,7 @@
<body> <body>
<main> <main>
<header class="topbar"> <header class="topbar">
<h1>astrololo</h1> <h1>{{ produkt }}</h1>
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych — {# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
program umie więcej, niż pokazuje (PRE-26). #} program umie więcej, niż pokazuje (PRE-26). #}
@@ -3,14 +3,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · logowanie</title> <title>{{ produkt }} · logowanie</title>
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc {# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #} linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
<link rel="stylesheet" href="{{ static('base.css') }}"> <link rel="stylesheet" href="{{ static('base.css') }}">
</head> </head>
<body> <body>
<main class="login-main"> <main class="login-main">
<header class="topbar"><h1>astrololo</h1></header> <header class="topbar"><h1>{{ produkt }}</h1></header>
{% if request.query_params.get('wylogowano') %} {% if request.query_params.get('wylogowano') %}
<p class="done-note">Wylogowano.</p> <p class="done-note">Wylogowano.</p>
+2 -2
View File
@@ -137,8 +137,8 @@ def _pdf_sample_chart() -> dict:
def test_reads_stores_through_their_api_not_raw_storage(): def test_reads_stores_through_their_api_not_raw_storage():
"""Format danych ma jednego właściciela — moduł, który je zapisuje. """Format danych ma jednego właściciela — moduł, który je zapisuje.
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie.""" Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
assert "window.astrololoNatal" in JS assert "window.kosmogramNatal" in JS
assert "window.astrololoPredictions" in JS assert "window.kosmogramPredictions" in JS
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu) # chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
assert "localStorage.getItem" not in JS assert "localStorage.getItem" not in JS
assert "localStorage.setItem" not in JS assert "localStorage.setItem" not in JS
@@ -82,7 +82,7 @@ def test_corrupt_storage_does_not_break_the_page():
def test_storage_is_exposed_for_the_compile_tab(): def test_storage_is_exposed_for_the_compile_tab():
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy.""" """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(): def test_does_nothing_on_other_tabs():
@@ -46,14 +46,14 @@ def _geo_source() -> str:
def _handler_body() -> 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 Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia ta pada
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast 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). kodu (test przechodziłby albo padał z niewłaściwego powodu).
""" """
src = _geo_source() src = _geo_source()
start = src.index("addEventListener('astrololo:coords'") start = src.index("addEventListener('kosmogram:coords'")
end = src.index("});", start) end = src.index("});", start)
return src[start:end] 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 w polu tekstowym zostawała stara nazwa formularz pokazywał jedno miejsce,
a liczył dla innego.""" a liczył dla innego."""
assert "reverseName(" in _handler_body(), ( 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)"
) )