soapbox/tests/test_app.py
Ole-Morten Duesund 42b296ca06 Cache-busting: immutable bare på versjonerte URL-er, streng asset()
Fra code review av ea07f34:
- /theme/* fikk max-age=1 år + immutable også uten ?v=; url("favicon.svg")
  inne i CSS ville da sittet fast etter en temaendring. Nå bare med hash.
- asset() feiler ved bygging på ukjent temafil i stedet for å gi en
  uversjonert URL i stillhet.
- rel() defineres én gang og gjenbrukes av asset(); dødt is_dir-guard
  fjernet; docstring peker på asset() for temafiler.
- Tester: uversjonert forespørsel skal ikke være immutable; hash-format
  sjekkes på nestede sider; ukjent asset gir feil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
2026-08-26 15:59:53 +02:00

458 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from urllib.parse import unquote
from soapbox.app import create_app
from soapbox.config import load_config
from tests.conftest import csrf, login, png_bytes
def _create_post(client, prefix=""):
login(client, prefix=prefix)
r = client.post(f"{prefix}/admin/new", data={"csrf": csrf(client)})
assert r.status_code == 302
post_id = r.headers["Location"].split("/admin/edit/")[1]
return post_id
def test_login_required(client):
assert client.get("/admin/").status_code == 302
assert client.get("/admin/login").status_code == 200
assert client.get("/admin/static/admin.css").content_type.startswith("text/css")
assert client.get("/admin/static/editor.js").status_code == 200
def test_full_flow_owner(client, cfg):
post_id = _create_post(client)
assert post_id == "_owner/nytt-innlegg"
token = csrf(client)
# Last opp et bilde
r = client.post(
f"/admin/upload/{post_id}",
data={"file": (__import__("io").BytesIO(png_bytes()), "Mitt Bilde.PNG")},
headers={"X-CSRF": token},
content_type="multipart/form-data",
)
assert r.status_code == 200, r.data
assert r.json["filename"] == "mitt-bilde.png"
# Forhåndsvisning peker bildet til admin-media
r = client.post(
f"/admin/preview/{post_id}",
json={"body": "![](mitt-bilde.png) [x](/annet/)"},
headers={"X-CSRF": token},
)
assert f'src="/admin/media/{post_id}/mitt-bilde.png"' in r.text
assert 'href="../annet/"' in r.text
# Publiser: slug følger tittelen
body = "Hei **verden**\n\n![](mitt-bilde.png)\n\n[hjem](/)"
r = client.post(
f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Grønt er skjønt", "body": body, "action": "publish"},
)
assert unquote(r.headers["Location"]).endswith("/admin/edit/_owner/grønt-er-skjønt")
assert (cfg.content_dir / "posts/grønt-er-skjønt/index.md").exists()
assert (cfg.content_dir / "posts/grønt-er-skjønt/mitt-bilde.png").exists()
# Statisk output
r = client.get("/grønt-er-skjønt")
assert r.status_code == 301 and unquote(r.headers["Location"]).endswith("/grønt-er-skjønt/")
html = client.get("/grønt-er-skjønt/").text
assert "<strong>verden</strong>" in html
m = re.search(r'href="\.\./theme/style\.css\?v=([0-9a-f]{8})"', html)
assert m, html[:600]
assert re.search(r'href="\.\./theme/favicon\.svg\?v=[0-9a-f]{8}"', html)
r = client.get(f"/theme/style.css?v={m.group(1)}")
assert r.status_code == 200 and "immutable" in r.headers["Cache-Control"]
# uten hash (f.eks. url("favicon.svg") fra CSS) skal cachen være kort
assert "immutable" not in client.get("/theme/favicon.svg").headers["Cache-Control"]
assert 'href="../"' in html # intern lenke omskrevet til relativ
assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200
front = client.get("/").text
assert "Grønt er skjønt" in front
assert 'href="https://kode.naiv.no/olemd/soapbox"' in front # AGPL: lenke til kilden
feed = client.get("/feed.xml").text
assert "https://blog.example.no/gr%C3%B8nt-er-skj%C3%B8nt/" in feed
# Etter publisering fryses slug selv om tittelen endres
r = client.post(
"/admin/edit/_owner/grønt-er-skjønt",
data={"csrf": token, "title": "Ny tittel", "body": body, "action": "save"},
)
assert unquote(r.headers["Location"]).endswith("/admin/edit/_owner/grønt-er-skjønt")
# Git-historikk
from soapbox.gitstore import log
lines = log(cfg.content_dir)
assert any("Publiserte: Grønt er skjønt" in line for line in lines)
assert all("eier:" in line for line in lines)
def test_guest_flow_and_isolation(client, cfg):
post_id = _create_post(client) # eierens utkast
client.post("/admin/logout", data={"csrf": csrf(client)})
login(client, "gjest1")
token = csrf(client)
# Gjest kan ikke røre eierens innlegg
assert client.get(f"/admin/edit/{post_id}").status_code == 403
assert client.get("/admin/users").status_code == 403
r = client.post("/admin/new", data={"csrf": token})
gid = r.headers["Location"].split("/admin/edit/")[1]
assert gid == "gjest1/nytt-innlegg"
client.post(
f"/admin/edit/{gid}",
data={"csrf": token, "title": "Post 1", "body": "hei", "action": "publish"},
)
assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists()
html = client.get("/gjest1/post-1/").text
assert re.search(r'href="\.\./\.\./theme/style\.css\?v=[0-9a-f]{8}"', html)
assert "Gjest En" in html
assert "Post 1" in client.get("/gjest1/").text
assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden
# eierens utkast er ikke publisert
assert client.get("/nytt-innlegg/").status_code == 404
def test_login_next_no_open_redirect(client):
def attempt(nxt):
client.get("/admin/login") # ferskt CSRF-token (roteres ved innlogging)
creds = {"username": "eier", "password": "hemmelig123", "csrf": csrf(client)}
return client.post("/admin/login", data={**creds, "next": nxt})
for bad in ("//evil.com", "/\\evil.com", "https://evil.com"):
assert attempt(bad).headers["Location"] == "/admin/", bad
assert attempt("/admin/users").headers["Location"] == "/admin/users"
def test_csrf_enforced(client):
login(client)
assert client.post("/admin/new", data={}).status_code == 403
def test_subpath_mount(tmp_path):
cfg = load_config({"SOAPBOX_DATA_DIR": str(tmp_path / "d"), "SOAPBOX_BASE_PATH": "/blog/"})
assert cfg.base_path == "/blog"
app = create_app(cfg)
app.config["TESTING"] = True
from soapbox import db
conn = db.connect(cfg.db_path)
db.create_user(conn, "eier", "hemmelig123", db.ROLE_OWNER)
conn.close()
client = app.test_client()
assert client.get("/admin/login").status_code == 404
assert client.get("/blog/admin/login").status_code == 200
login(client, prefix="/blog")
r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")})
assert r.headers["Location"].startswith("/blog/admin/edit/")
html = client.get("/blog/").text
assert re.search(r'href="theme/style\.css\?v=[0-9a-f]{8}"', html)
assert re.search(r'href="admin/"', html)
assert client.get("/blog/theme/style.css").status_code == 200
def test_no_inline_js_with_content(client, cfg):
"""Titler med anførselstegn skal ikke havne i en JS-kontekst (XSS)."""
post_id = _create_post(client)
token = csrf(client)
title = 'Ola\'s "test" <b>'
r = client.post(
f"/admin/edit/{post_id}",
data={"csrf": token, "title": title, "body": "x", "action": "save"},
)
html = client.get(r.headers["Location"]).text
assert "onsubmit=" not in html and "onclick=" not in html
assert "Ola&#39;s" in html
def test_post_history(client, cfg):
post_id = _create_post(client)
token = csrf(client)
r = client.post(
f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Hist", "body": "første", "action": "publish"},
)
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "Hist", "body": "andre", "action": "save"},
)
html = client.get(f"/admin/history/{pid}").text
assert "Publiserte: Hist" in html and "Lagret: Hist" in html
assert "Opprettet utkast" in html # fra før slug-omdøpingen (--follow)
sha = re.search(r"sha=([0-9a-f]{40})", html).group(1)
diff = client.get(f"/admin/history/{pid}?sha={sha}").text
assert '<span class="add">+andre</span>' in diff and '<span class="del">-første</span>' in diff
assert client.get(f"/admin/history/{pid}?sha=deadbeef").status_code == 404
assert client.get(f"/admin/history/{pid}?sha=;rm").status_code == 404
# Gjest får ikke se eierens historikk
client.post("/admin/logout", data={"csrf": token})
login(client, "gjest1")
assert client.get(f"/admin/history/{pid}").status_code == 403
def test_user_theme_in_content_repo(client, cfg):
"""Eget tema i content/themes/ overstyrer delvis og velges via innstillinger."""
theme = cfg.content_dir / "themes" / "skog"
(theme / "static").mkdir(parents=True)
(theme / "static" / "style.css").write_text(":root{--bg:#000}")
(theme / "templates").mkdir()
(theme / "templates" / "index.html").write_text("SKOG {{ rel('theme/style.css') }}")
assert cfg.available_themes() == ["green", "skog"]
login(client)
token = csrf(client)
assert (
client.post(
"/admin/settings", data={"csrf": token, "title": "T", "theme": "../etc"}
).status_code
== 400
)
r = client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "skog"})
assert r.status_code == 302
assert 'theme = "skog"' in (cfg.content_dir / "site.toml").read_text()
assert cfg.theme == "skog"
assert client.get("/").text.startswith("SKOG theme/style.css") # rel() gir fortsatt ren sti
assert client.get("/theme/style.css").text == ":root{--bg:#000}"
assert client.get("/theme/favicon.svg").status_code == 200 # arvet fra green
# post.html finnes ikke i skog → arves fra green
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}", data={"csrf": token, "title": "P", "body": "x", "action": "publish"}
)
assert '<article class="post">' in client.get("/p/").text
def test_rebuild_on_startup(cfg):
"""Utdatert public/ (f.eks. etter oppgradering) skal erstattes ved oppstart."""
cfg.public_dir.mkdir(parents=True)
(cfg.public_dir / "index.html").write_text("GAMMEL")
create_app(cfg)
assert "GAMMEL" not in (cfg.public_dir / "index.html").read_text()
def test_slug_uniqueness(client, cfg):
login(client)
token = csrf(client)
ids = []
for _ in range(2):
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
r = client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "Samme", "body": "x", "action": "publish"},
)
ids.append(r.headers["Location"].split("/admin/edit/")[1])
assert ids == ["_owner/samme", "_owner/samme-2"]
def test_editor_accessibility_markup(client):
post_id = _create_post(client)
html = client.get(f"/admin/edit/{post_id}").text
assert '<label for="body" class="visually-hidden">Innhold</label>' in html
assert "aria-live" not in html.split('id="preview"')[1].split("</div>")[0]
assert 'aria-keyshortcuts="Control+B"' in html
front = client.get("/").text
assert "<h1" in front
def test_logo_links_to_blog_root(client):
login(client)
assert '<a class="brand" href="/"' in client.get("/admin/").text
# offentlig tema: relativt til roten fra hvilken som helst dybde
assert 'class="site-title" href="./"' in client.get("/").text
def test_sitemap_and_open_graph(client, cfg):
post_id = _create_post(client)
token = csrf(client)
client.post(
f"/admin/upload/{post_id}",
data={"file": (__import__("io").BytesIO(png_bytes((10, 10))), "b.png")},
headers={"X-CSRF": token},
content_type="multipart/form-data",
)
client.post(
f"/admin/edit/{post_id}",
data={
"csrf": token,
"title": "OG-test",
"body": "Første *avsnitt* her.\n\n![x](b.png)",
"action": "publish",
},
)
html = client.get("/og-test/").text
assert '<meta property="og:type" content="article">' in html
assert '<meta property="og:title" content="OG-test">' in html
assert '<meta property="og:url" content="https://blog.example.no/og-test/">' in html
assert '<meta property="og:image" content="https://blog.example.no/og-test/b.png">' in html
assert '<meta property="og:description" content="Første avsnitt her.">' in html
assert '<meta property="og:type" content="website">' in client.get("/").text
sm = client.get("/sitemap.xml")
assert sm.status_code == 200
assert "<loc>https://blog.example.no/</loc>" in sm.text
assert "<loc>https://blog.example.no/og-test/</loc>" in sm.text
def test_base_path_derived_from_site_url(tmp_path):
cfg = load_config(
{"SOAPBOX_DATA_DIR": str(tmp_path / "a"), "SOAPBOX_SITE_URL": "https://x.no/blog/"}
)
assert cfg.base_path == "/blog" and cfg.site_url == "https://x.no/blog"
cfg = load_config({"SOAPBOX_DATA_DIR": str(tmp_path / "b"), "SOAPBOX_SITE_URL": "https://x.no"})
assert cfg.base_path == ""
# eksplisitt tom verdi overstyrer (proxy stripper prefikset)
cfg = load_config(
{
"SOAPBOX_DATA_DIR": str(tmp_path / "c"),
"SOAPBOX_SITE_URL": "https://x.no/blog",
"SOAPBOX_BASE_PATH": "",
}
)
assert cfg.base_path == ""
def test_signature_from_profile(client, cfg):
post_id = _create_post(client)
token = csrf(client)
client.post(
f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Sign", "body": "x", "action": "publish"},
)
assert re.search(
r" <a[^>]*>Ole</a>", client.get("/sign/").text
) # visningsnavn fra brukertabellen
assert (cfg.content_dir / "posts/sign/index.md").read_text().count('author: "eier"') == 1
r = client.post(
"/admin/profile", data={"csrf": token, "display_name": "Ole M.", "email": "ole@example.no"}
)
assert r.status_code == 302
html = client.get("/sign/").text
assert ">Ole M.</a>" in html and 'href="mailto:ole@example.no"' in html
assert "<dc:creator>Ole M.</dc:creator>" in client.get("/feed.xml").text
assert (
client.post(
"/admin/profile", data={"csrf": token, "display_name": "x", "email": "ikke-epost"}
).status_code
== 200
)
def test_tags(client, cfg):
login(client)
token = csrf(client)
def publish(title, tags):
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": title, "body": "x", "tags": tags, "action": "publish"},
)
publish("En", "Friluftsliv, skog ,skog, Skog")
publish("To", "friluftsliv")
publish("Tre", "")
md = (cfg.content_dir / "posts/en/index.md").read_text()
assert 'tags: ["Friluftsliv", "skog"]' in md # dedup, case-ufølsomt, trimmet
assert "tags:" not in (cfg.content_dir / "posts/tre/index.md").read_text()
assert 'href="../tag/friluftsliv/"' in client.get("/en/").text
page = client.get("/tag/friluftsliv/").text
assert (
"tagg: friluftsliv" in page.lower()
and ">En<" in page
and ">To<" in page
and ">Tre<" not in page
)
assert client.get("/tag/skog/").status_code == 200
assert client.get("/tag/").status_code == 200 and "(2)" in client.get("/tag/").text
assert "<category>skog</category>" in client.get("/feed.xml").text
assert "https://blog.example.no/tag/friluftsliv/" in client.get("/sitemap.xml").text
# "tag" er reservert som slug
publish("tag", "")
assert not (cfg.content_dir / "posts/tag").exists()
def test_user_pages(client, cfg):
login(client)
token = csrf(client)
client.post(
"/admin/profile",
data={
"csrf": token,
"display_name": "Ole",
"email": "",
"bio": "Skriver om **skog**.\n\n<b>x</b>",
},
)
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "Mitt", "body": "x", "action": "publish"},
)
client.post(
"/admin/users",
data={
"csrf": token,
"action": "create",
"username": "aa",
"display_name": "Åse",
"password": "passord123",
},
)
lst = client.get("/brukere/").text
order = [n for n in ("Ole", "Gjest En", "Åse") if n in lst]
assert [lst.index(n) for n in order] == sorted(
lst.index(n) for n in order
) # eier, så Gjest En, Åse
assert lst.index("Ole") < lst.index("Gjest En") < lst.index("Åse")
page = client.get("/brukere/eier/").text
assert "<strong>skog</strong>" in page and "&lt;b&gt;" in page # markdown, ikke rå HTML
assert ">Mitt<" in page and "Hovedbruker" in page
assert "Ingen innlegg ennå" in client.get("/brukere/gjest1/").text
assert 'href="../brukere/eier/"' in client.get("/mitt/").text # signatur lenker til brukersiden
assert "https://blog.example.no/brukere/eier/" in client.get("/sitemap.xml").text
# "brukere" er reservert som slug
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "brukere", "body": "x", "action": "publish"},
)
assert not (cfg.content_dir / "posts/brukere").exists()
def test_asset_hash_changes_with_content(client, cfg):
login(client)
token = csrf(client)
before = re.search(r"style\.css\?v=([0-9a-f]{8})", client.get("/").text).group(1)
theme = cfg.content_dir / "themes" / "green2" / "static"
theme.mkdir(parents=True)
(theme / "style.css").write_text("body{color:red}")
client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "green2"})
after = re.search(r"style\.css\?v=([0-9a-f]{8})", client.get("/").text).group(1)
assert before != after
def test_asset_unknown_file_fails_build(client, cfg):
theme = cfg.content_dir / "themes" / "feil" / "templates"
theme.mkdir(parents=True)
(theme / "index.html").write_text("{{ asset('theme/finnes-ikke.css') }}")
login(client)
import pytest
with pytest.raises(ValueError, match="finnes-ikke"):
client.post("/admin/settings", data={"csrf": csrf(client), "title": "T", "theme": "feil"})