Molodetz blogrol volgens DPP-template
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,50 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _create(admin, **overrides):
|
||||
payload = {"title": "Een echte notitie", "body": "Tekst met **nadruk** en `code`.", "topic": "rol", "status": "draft"}
|
||||
payload.update(overrides)
|
||||
response = admin.post("/admin/posts", json=payload, headers=JSON)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["data"]
|
||||
|
||||
|
||||
def test_create_draft_is_hidden_then_published(admin, anon):
|
||||
data = _create(admin, title="Concept dat later live gaat")
|
||||
assert anon.get(f"/posts/{data['slug']}").status_code == 404
|
||||
assert admin.post(f"/admin/posts/{data['uid']}/publish", headers=JSON).status_code == 200
|
||||
page = anon.get(f"/posts/{data['slug']}")
|
||||
assert page.status_code == 200
|
||||
assert "<strong>nadruk</strong>" in page.text
|
||||
|
||||
|
||||
def test_edit_post(admin, anon):
|
||||
data = _create(admin, title="Te bewerken notitie", status="published")
|
||||
response = admin.post(f"/admin/posts/{data['uid']}", json={"title": "Bewerkte notitie", "body": "Nieuwe tekst hier.", "topic": "standaard", "status": "published"}, headers=JSON)
|
||||
assert response.status_code == 200, response.text
|
||||
posts = anon.get("/standaard", headers=JSON).json()["posts"]
|
||||
assert any(post["title"] == "Bewerkte notitie" for post in posts)
|
||||
|
||||
|
||||
def test_delete_goes_to_trash(admin, anon):
|
||||
data = _create(admin, title="Weg ermee notitie", status="published")
|
||||
assert admin.post(f"/admin/posts/{data['uid']}/delete", headers=JSON).status_code == 200
|
||||
assert anon.get(f"/posts/{data['slug']}").status_code == 404
|
||||
trash = admin.get("/admin/trash", headers=JSON).json()["items"]
|
||||
assert any(item["uid"] == data["uid"] for item in trash)
|
||||
|
||||
|
||||
def test_validation_error(admin):
|
||||
response = admin.post("/admin/posts", json={"title": "x"}, headers=JSON)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_html_form_post(admin):
|
||||
response = admin.post("/admin/posts", data={"title": "Via formulier", "body": "Formuliertekst hier.", "topic": "rol", "status": "draft"})
|
||||
assert response.status_code in (302, 303)
|
||||
|
||||
|
||||
def test_member_cannot_write(member):
|
||||
response = member.post("/admin/posts", json={"title": "Niet toegestaan", "body": "Tekst hier.", "topic": "rol"}, headers=JSON)
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from tests.conftest import ADMIN_PASSWORD, ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_login_wrong_password(anon):
|
||||
response = anon.post("/auth/login", data={"username": ADMIN_USERNAME, "password": "verkeerd-wachtwoord"})
|
||||
assert response.status_code in (400, 401, 200)
|
||||
assert "session" not in "".join(response.cookies.keys()).lower() or response.status_code != 302
|
||||
|
||||
|
||||
def test_login_and_logout(anon):
|
||||
response = anon.post("/auth/login", data={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD})
|
||||
assert response.status_code in (302, 303)
|
||||
assert anon.get("/admin").status_code == 200
|
||||
cookie = response.headers.get("set-cookie", "").lower()
|
||||
assert "httponly" in cookie and "samesite=lax" in cookie
|
||||
assert anon.post("/auth/logout").status_code in (302, 303)
|
||||
assert anon.get("/admin").status_code in (302, 303, 401)
|
||||
|
||||
|
||||
def test_admin_redirects_anonymous(anon):
|
||||
response = anon.get("/admin")
|
||||
assert response.status_code in (302, 303)
|
||||
assert "/auth/login" in response.headers["location"]
|
||||
|
||||
|
||||
def test_api_key_header(admin, anon):
|
||||
key = admin.get("/profile/api-key", headers={"Accept": "application/json"}).json()["api_key"]
|
||||
assert anon.get("/admin", headers={"X-API-KEY": key, "Accept": "application/json"}).status_code == 200
|
||||
assert anon.get("/admin", headers={"Authorization": f"Bearer {key}", "Accept": "application/json"}).status_code == 200
|
||||
|
||||
|
||||
def test_no_jwt_anywhere(admin):
|
||||
response = admin.get("/profile/api-key", headers={"Accept": "application/json"})
|
||||
assert response.json()["api_key"].count(".") == 0
|
||||
@@ -0,0 +1,30 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import pytest
|
||||
|
||||
from molodetz.docs_api import all_endpoints
|
||||
|
||||
ENDPOINTS = [item for item in all_endpoints() if "logout" not in item["path"]]
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _call(client, item):
|
||||
path = item["probe_path"]
|
||||
if item["method"] == "GET":
|
||||
return client.get(path, headers=JSON)
|
||||
return client.post(path, json=item["probe_body"] or {}, headers=JSON)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("item", ENDPOINTS, ids=lambda item: f"{item['method']} {item['path']}")
|
||||
def test_documented_auth_matches_reality(item, anon, member, admin):
|
||||
anon_status = _call(anon, item).status_code
|
||||
member_status = _call(member, item).status_code
|
||||
admin_status = _call(admin, item).status_code
|
||||
if item["auth"] == "public":
|
||||
assert anon_status not in (401, 403)
|
||||
elif item["auth"] == "member":
|
||||
assert anon_status in (401, 303, 302)
|
||||
assert member_status not in (401, 403)
|
||||
else:
|
||||
assert anon_status in (401, 303, 302)
|
||||
assert member_status == 403
|
||||
assert admin_status not in (401, 403)
|
||||
@@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def test_join_request_is_stored_and_visible_to_admin(anon, admin):
|
||||
response = anon.post("/binnen", data={"name": "Schrijver Een", "contact": "schrijver@example.invalid", "repo_url": "https://example.com/repo", "message": "Ik wil meeschrijven.", "website": ""})
|
||||
assert response.status_code in (302, 303)
|
||||
assert "ok=1" in response.headers["location"]
|
||||
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
|
||||
row = next(row for row in rows if row["name"] == "Schrijver Een")
|
||||
assert row["contact"] == "schrijver@example.invalid"
|
||||
assert row["repo_url"].startswith("https://example.com/repo")
|
||||
status = admin.post(f"/admin/joins/{row['uid']}/status", json={"status": "contacted"}, headers=JSON)
|
||||
assert status.status_code == 200
|
||||
|
||||
|
||||
def test_join_json_api(anon):
|
||||
response = anon.post("/binnen", json={"name": "Schrijver Twee", "contact": "@twee"}, headers=JSON)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
|
||||
|
||||
def test_honeypot_is_not_stored(anon, admin):
|
||||
anon.post("/binnen", data={"name": "Robot Bot", "contact": "bot@example.invalid", "website": "spam"})
|
||||
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
|
||||
assert not any(row["name"] == "Robot Bot" for row in rows)
|
||||
|
||||
|
||||
def test_join_validation(anon):
|
||||
response = anon.post("/binnen", json={"name": "x", "contact": ""}, headers=JSON)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_member_cannot_view_joins(member):
|
||||
assert member.get("/admin/joins", headers=JSON).status_code == 403
|
||||
@@ -0,0 +1,93 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from defusedxml import ElementTree
|
||||
|
||||
PUBLIC_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/mensen/retoor", "/binnen", "/docs", "/docs/welkom", "/docs/api/inhoud", "/docs/search?q=markdown", "/voorwaarden", "/privacy", "/auth/login"]
|
||||
JSON_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/binnen"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PUBLIC_PAGES)
|
||||
def test_public_pages_return_200(anon, path):
|
||||
response = anon.get(path)
|
||||
assert response.status_code == 200, path
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", JSON_PAGES)
|
||||
def test_json_faces(anon, path):
|
||||
response = anon.get(path, headers={"Accept": "application/json"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
assert isinstance(response.json(), dict)
|
||||
|
||||
|
||||
def test_home_is_molodetz(anon):
|
||||
html = anon.get("/").text
|
||||
assert "Molodetz" in html
|
||||
for label in ("Rol", "Standaard", "Flyers", "Memes", "Mensen", "Binnen"):
|
||||
assert f">{label}</a>" in html
|
||||
assert "wa.me" not in html and "whatsapp" not in html.lower()
|
||||
|
||||
|
||||
def test_no_bare_static_urls_in_rendered_html(anon):
|
||||
for path in ("/", "/flyers", "/memes", "/rol"):
|
||||
html = anon.get(path).text
|
||||
assert not re.search(r"""(src|href)="/static/(?!v|uploads/)""", html), path
|
||||
|
||||
|
||||
def test_versioned_static_asset(anon):
|
||||
html = anon.get("/").text
|
||||
match = re.search(r'href="(/static/v[^"]+/css/base\.css)"', html)
|
||||
assert match
|
||||
assert anon.get(match.group(1)).status_code == 200
|
||||
|
||||
|
||||
def test_seeded_posts_and_placeholders(anon):
|
||||
rol = anon.get("/rol", headers={"Accept": "application/json"}).json()["posts"]
|
||||
seeds = {"Een zes is geen ambitie.", "Af is een eigenschap.", "De assistent is geen senior.", "Geen cursus."}
|
||||
assert seeds <= {post["title"] for post in rol}
|
||||
assert all(post["is_placeholder"] for post in rol if post["title"] in seeds)
|
||||
standaard = anon.get("/standaard", headers={"Accept": "application/json"}).json()["posts"]
|
||||
keuzes = next(post for post in standaard if post["title"] == "Eenendertig keuzes. Jij hebt er nul.")
|
||||
assert keuzes["is_placeholder"] is False
|
||||
|
||||
|
||||
def test_post_page(anon):
|
||||
posts = anon.get("/rol", headers={"Accept": "application/json"}).json()["posts"]
|
||||
post = next(post for post in posts if post["title"] == "Geen cursus.")
|
||||
response = anon.get(f"/posts/{post['slug']}")
|
||||
assert response.status_code == 200
|
||||
assert "Placeholder" in response.text
|
||||
|
||||
|
||||
def test_galleries_have_images(anon):
|
||||
for path, minimum in (("/flyers", 4), ("/memes", 10)):
|
||||
html = anon.get(path).text
|
||||
assert html.count("data-lightbox") >= minimum, path
|
||||
|
||||
|
||||
def test_mensen_lists_real_accounts_only(anon):
|
||||
people = anon.get("/mensen", headers={"Accept": "application/json"}).json()
|
||||
names = [person["username"] for person in people.get("people", [])]
|
||||
assert names[0] == "retoor"
|
||||
assert set(names) <= {"retoor", "lezer"}
|
||||
|
||||
|
||||
def test_unknown_page_is_404(anon):
|
||||
response = anon.get("/bestaat-niet-xyz")
|
||||
assert response.status_code == 404
|
||||
assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"]
|
||||
|
||||
|
||||
def test_robots_and_sitemap(anon):
|
||||
assert "Sitemap:" in anon.get("/robots.txt").text
|
||||
sitemap = anon.get("/sitemap.xml")
|
||||
root = ElementTree.fromstring(sitemap.content)
|
||||
assert root.tag.endswith("urlset")
|
||||
assert len(list(root)) >= 5
|
||||
|
||||
|
||||
def test_health(anon):
|
||||
assert anon.get("/health").json()["status"] == "ok"
|
||||
@@ -0,0 +1,143 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="molodetz-test-"))
|
||||
TEST_PORT = int(os.environ.get("MOLODETZ_TEST_PORT", "8099"))
|
||||
ADMIN_USERNAME = "retoor"
|
||||
ADMIN_PASSWORD = "test-admin-pass-4f9c2a"
|
||||
MEMBER_USERNAME = "lezer"
|
||||
MEMBER_PASSWORD = "test-member-pass-77d1"
|
||||
|
||||
TEST_ENV = {
|
||||
"MOLODETZ_DATA_DIR": str(TEST_DATA_DIR),
|
||||
"MOLODETZ_DATABASE_URL": f"sqlite:///{TEST_DATA_DIR / 'molodetz.db'}",
|
||||
"MOLODETZ_ADMIN_USERNAME": ADMIN_USERNAME,
|
||||
"MOLODETZ_ADMIN_EMAIL": "retoor@molodetz.nl",
|
||||
"MOLODETZ_ADMIN_PASSWORD": ADMIN_PASSWORD,
|
||||
"MOLODETZ_DISABLE_SERVICES": "1",
|
||||
"MOLODETZ_DISABLE_RATE_LIMIT": "1",
|
||||
"MOLODETZ_PORT": str(TEST_PORT),
|
||||
"MOLODETZ_INTERNAL_BASE_URL": f"http://127.0.0.1:{TEST_PORT}",
|
||||
"MOLODETZ_SITEMAP_TTL": "0",
|
||||
"MOLODETZ_LANDING_TTL": "0",
|
||||
"MOLODETZ_UNREAD_TTL": "0",
|
||||
"MOLODETZ_STATIC_VERSION": "test",
|
||||
"SECRET_KEY": "test-secret-key-not-for-production",
|
||||
}
|
||||
os.environ.update(TEST_ENV)
|
||||
|
||||
BASE_URL = f"http://127.0.0.1:{TEST_PORT}"
|
||||
|
||||
|
||||
def _wait_ready(process, timeout=30.0):
|
||||
import httpx
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("test server exited during startup")
|
||||
try:
|
||||
if httpx.get(f"{BASE_URL}/health", timeout=1.0).status_code == 200:
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError("test server did not become ready")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_server():
|
||||
env = {**os.environ, **TEST_ENV}
|
||||
log = open(TEST_DATA_DIR / "server.log", "w")
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "molodetz.main:app", "--host", "127.0.0.1", "--port", str(TEST_PORT)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
_wait_ready(process)
|
||||
_ensure_member()
|
||||
yield BASE_URL
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
log.close()
|
||||
|
||||
|
||||
def _ensure_member():
|
||||
from molodetz.database import create_user, get_user_by_username
|
||||
from molodetz.utils.auth import hash_password
|
||||
|
||||
if get_user_by_username(MEMBER_USERNAME) is None:
|
||||
create_user(MEMBER_USERNAME, "lezer@example.invalid", hash_password(MEMBER_PASSWORD), role="Member")
|
||||
|
||||
|
||||
def _client(base_url):
|
||||
import httpx
|
||||
|
||||
return httpx.Client(base_url=base_url, timeout=15.0, follow_redirects=False)
|
||||
|
||||
|
||||
def _login(client, username, password):
|
||||
response = client.post("/auth/login", data={"username": username, "password": password})
|
||||
assert response.status_code in (302, 303), response.text[:300]
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def anon(app_server):
|
||||
with _client(app_server) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def member(app_server):
|
||||
with _client(app_server) as client:
|
||||
yield _login(client, MEMBER_USERNAME, MEMBER_PASSWORD)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(app_server):
|
||||
with _client(app_server) as client:
|
||||
yield _login(client, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser(app_server):
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
|
||||
slow_mo = int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0") or 0)
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo)
|
||||
yield browser
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(browser):
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 900}, locale="nl-NL")
|
||||
page = context.new_page()
|
||||
page.console_errors = []
|
||||
page.on("pageerror", lambda error: page.console_errors.append(str(error)))
|
||||
page.on("console", lambda message: message.type == "error" and page.console_errors.append(message.text))
|
||||
yield page
|
||||
context.close()
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,102 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from tests.conftest import ADMIN_PASSWORD, ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_home_renders_without_errors(page, app_server):
|
||||
page.goto(app_server + "/")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert page.locator("h1").first.inner_text().strip().lower() == "molodetz"
|
||||
assert page.locator("nav .nav-links a", has_text="Binnen").count() == 1
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_lightbox_opens_and_closes(page, app_server):
|
||||
page.goto(app_server + "/flyers")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.locator("[data-lightbox]").first.click()
|
||||
lightbox = page.locator("dp-lightbox")
|
||||
assert lightbox.evaluate("el => el.classList.contains('visible')")
|
||||
page.keyboard.press("Escape")
|
||||
assert not lightbox.evaluate("el => el.classList.contains('visible')")
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_mobile_nav_toggle(browser, app_server):
|
||||
context = browser.new_context(viewport={"width": 390, "height": 800})
|
||||
page = context.new_page()
|
||||
page.goto(app_server + "/")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert not page.locator("#nav-links").is_visible()
|
||||
page.click("[data-nav-toggle]")
|
||||
assert page.locator("#nav-links").is_visible()
|
||||
context.close()
|
||||
|
||||
|
||||
def test_login_and_editor_preview(page, app_server):
|
||||
page.goto(app_server + "/auth/login")
|
||||
page.fill("input[name=username]", ADMIN_USERNAME)
|
||||
page.fill("input[name=password]", ADMIN_PASSWORD)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_url("**/admin")
|
||||
page.goto(app_server + "/admin/posts/new")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.fill("#body", "Een **vette** zin <script>alert(1)</script> [link](javascript:alert(1)) <b onclick=alert(1)>b</b>")
|
||||
page.wait_for_function("document.querySelector('[data-preview-target] strong')")
|
||||
preview = page.locator("[data-preview-target]").inner_html()
|
||||
assert "<strong>vette</strong>" in preview
|
||||
assert "<script" not in preview
|
||||
assert "javascript:" not in preview
|
||||
assert "onclick" not in preview
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_join_form_submits(page, app_server):
|
||||
page.goto(app_server + "/binnen")
|
||||
page.fill("input[name=name]", "Browser Schrijver")
|
||||
page.fill("input[name=contact]", "browser@example.invalid")
|
||||
page.click("form button[type=submit]")
|
||||
page.wait_for_url("**/binnen?ok=1")
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
PUBLIC_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/mensen/retoor", "/binnen", "/docs", "/docs/welkom", "/docs/api/inhoud", "/docs/component-code", "/voorwaarden", "/privacy"]
|
||||
ADMIN_PAGES = ["/admin", "/admin/posts", "/admin/joins", "/admin/services", "/admin/services/backup", "/admin/trash", "/admin/settings", "/admin/users", "/admin/audit", "/admin/backups", "/admin/stats", "/notifications", "/profile/api-key", "/docs/api/beheer"]
|
||||
|
||||
|
||||
def test_public_pages_have_no_js_errors(page, app_server):
|
||||
for path in PUBLIC_PAGES:
|
||||
page.goto(app_server + path)
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_admin_pages_have_no_js_errors(page, app_server):
|
||||
page.goto(app_server + "/auth/login")
|
||||
page.fill("input[name=username]", ADMIN_USERNAME)
|
||||
page.fill("input[name=password]", ADMIN_PASSWORD)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_url("**/admin")
|
||||
for path in ADMIN_PAGES:
|
||||
page.goto(app_server + path)
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.goto(app_server + "/admin/services/backup")
|
||||
page.click("[data-tab=logs]")
|
||||
assert page.locator("[data-panel=logs]").is_visible()
|
||||
page.wait_for_timeout(3500)
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_docs_tabs_switch(page, app_server):
|
||||
page.goto(app_server + "/docs/api/inhoud")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.click("[data-endpoint] [data-tab=expected] >> nth=0")
|
||||
assert page.locator("[data-endpoint] [data-panel=expected]").first.is_visible()
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_docs_code_blocks_get_highlight_and_copy(page, app_server):
|
||||
page.goto(app_server + "/docs/api")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.wait_for_selector("dp-code .copy-button")
|
||||
assert page.locator("dp-code pre code.hljs").count() >= 1
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,33 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import io
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from molodetz.cli import build_parser
|
||||
from molodetz.cli.commands import read_source
|
||||
|
||||
|
||||
def test_parser_groups():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["posts", "import", "notitie.md", "--topic", "standaard"])
|
||||
assert args.topic == "standaard" and args.path == "notitie.md"
|
||||
args = parser.parse_args(["system", "prune", "--dry-run"])
|
||||
assert args.dry_run is True
|
||||
args = parser.parse_args(["role", "set", "retoor", "Admin"])
|
||||
assert args.role == "Admin"
|
||||
|
||||
|
||||
def test_read_markdown(tmp_path):
|
||||
path = tmp_path / "x.md"
|
||||
path.write_text("# Kop\n\nTekst", encoding="utf-8")
|
||||
assert read_source(path).startswith("# Kop")
|
||||
|
||||
|
||||
def test_read_pdf(tmp_path):
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=200, height=200)
|
||||
buffer = io.BytesIO()
|
||||
writer.write(buffer)
|
||||
path = tmp_path / "x.pdf"
|
||||
path.write_bytes(buffer.getvalue())
|
||||
assert read_source(path) == ""
|
||||
@@ -0,0 +1,18 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz import config
|
||||
from molodetz.gallery import GALLERY
|
||||
|
||||
BANNED = ("selfie", "mirror", "devplace", "nigel", "nldr", "waifu", "zwem")
|
||||
|
||||
|
||||
def test_gallery_files_exist_and_are_allowed():
|
||||
assert GALLERY
|
||||
for entry in GALLERY:
|
||||
name = entry["file"] if isinstance(entry, dict) else entry[0]
|
||||
assert (config.MEDIA_SOURCE_DIR / name).is_file(), name
|
||||
assert not any(word in name.lower() for word in BANNED), name
|
||||
|
||||
|
||||
def test_both_kinds_present():
|
||||
kinds = {entry["kind"] if isinstance(entry, dict) else entry[1] for entry in GALLERY}
|
||||
assert {"flyer", "meme"} <= kinds
|
||||
@@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz import gates
|
||||
|
||||
|
||||
def test_gate_1_template_environment_only_in_templating():
|
||||
assert gates.gate_template_environment() == set()
|
||||
|
||||
|
||||
def test_gate_2_breakpoints_closed_set():
|
||||
assert gates.gate_breakpoints() == gates.BREAKPOINTS
|
||||
|
||||
|
||||
def test_gate_3_http_client_allowlist():
|
||||
assert gates.gate_http_clients() == gates.HTTP_CLIENT_ALLOWLIST
|
||||
|
||||
|
||||
def test_gate_4_client_render_allowlist():
|
||||
assert gates.gate_client_render_templates() == gates.CLIENT_RENDER_ALLOWLIST
|
||||
|
||||
|
||||
def test_gate_5_no_bare_static():
|
||||
assert gates.gate_bare_static() == []
|
||||
|
||||
|
||||
def test_gate_6_no_manual_loading_toggles():
|
||||
assert gates.gate_loading_toggles() == []
|
||||
|
||||
|
||||
def test_no_em_dashes():
|
||||
assert gates.gate_em_dashes() == []
|
||||
|
||||
|
||||
def test_json_error_status_first():
|
||||
assert gates.gate_json_error_order() == []
|
||||
@@ -0,0 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from molodetz.net_guard import UnsafeURL, guard_public_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["http://127.0.0.1/", "http://localhost:8088/", "http://10.0.0.1/", "http://169.254.169.254/", "file:///etc/passwd", "ftp://example.com/"])
|
||||
def test_private_and_odd_urls_are_refused(url):
|
||||
with pytest.raises(UnsafeURL):
|
||||
asyncio.run(guard_public_url(url))
|
||||
@@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz.constants import REPORTABLE_TARGETS, SOFT_DELETE_TABLES, UNREPORTABLE_TABLES
|
||||
from molodetz.docs_api import all_endpoints
|
||||
from molodetz.docs_api.registry import TARGET_TYPES
|
||||
|
||||
|
||||
def test_soft_delete_tables_partition():
|
||||
tables = set(SOFT_DELETE_TABLES)
|
||||
assert tables == set(REPORTABLE_TARGETS) | set(UNREPORTABLE_TABLES)
|
||||
assert not set(REPORTABLE_TARGETS) & set(UNREPORTABLE_TABLES)
|
||||
|
||||
|
||||
def test_documented_endpoints_are_unique():
|
||||
keys = [(item["method"], item["path"]) for item in all_endpoints()]
|
||||
assert len(keys) == len(set(keys))
|
||||
|
||||
|
||||
def test_documented_endpoints_exist_in_app():
|
||||
from molodetz.main import app
|
||||
|
||||
routes = {(method.upper(), path) for path, methods in app.openapi()["paths"].items() for method in methods}
|
||||
missing = [(item["method"], item["path"]) for item in all_endpoints() if (item["method"], item["path"]) not in routes]
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_target_types_known():
|
||||
assert set(TARGET_TYPES) == {"post", "join_request", "user"}
|
||||
|
||||
|
||||
def test_no_oauth_redirect_route():
|
||||
from molodetz.main import app
|
||||
|
||||
assert "/docs/oauth2-redirect" not in app.openapi()["paths"]
|
||||
assert app.swagger_ui_oauth2_redirect_url is None
|
||||
@@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz.rendering import content_preview, normalize_dashes, plain_title, render_content, render_title
|
||||
|
||||
|
||||
def test_markdown_renders_and_escapes_html():
|
||||
html = render_content("**vet** <script>alert(1)</script>")
|
||||
assert "<strong>vet</strong>" in html
|
||||
assert "<script>" not in html
|
||||
|
||||
|
||||
def test_dashes_are_normalized():
|
||||
long_dash = chr(0x2014)
|
||||
assert normalize_dashes(f"a {long_dash} b") == "a - b"
|
||||
assert long_dash not in render_content(f"x {long_dash} y")
|
||||
|
||||
|
||||
def test_javascript_urls_are_dropped():
|
||||
html = render_content("[klik](javascript:alert(1))")
|
||||
assert "javascript:" not in html
|
||||
|
||||
|
||||
def test_emoji_shortcodes():
|
||||
assert "\U0001F44D" in render_content(":thumbsup:")
|
||||
|
||||
|
||||
def test_title_is_inline_only():
|
||||
html = render_title("# Kop *nadruk*")
|
||||
assert "<h1>" not in html
|
||||
assert plain_title("**Vet** titel") == "Vet titel"
|
||||
|
||||
|
||||
def test_preview_truncates():
|
||||
preview = content_preview("woord " * 200, 50)
|
||||
assert len(preview) <= 52
|
||||
@@ -0,0 +1,20 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import pytest
|
||||
|
||||
from molodetz.utils.request_meta import safe_next
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
("/admin", "/admin"),
|
||||
("/rol?page=2", "/rol?page=2"),
|
||||
("https://evil.example/", "/"),
|
||||
("//evil.example/", "/"),
|
||||
("/\\evil.example", "/"),
|
||||
("", "/"),
|
||||
(None, "/"),
|
||||
],
|
||||
)
|
||||
def test_safe_next(value, expected):
|
||||
assert safe_next(value) == expected
|
||||
@@ -0,0 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz.database.sql_lint import collect_sql, lint
|
||||
|
||||
|
||||
def test_raw_sql_parses_with_sqlglot():
|
||||
assert collect_sql(), "expected raw SQL literals to lint"
|
||||
assert lint() == []
|
||||
Reference in New Issue
Block a user