soapbox/tests/test_app.py
Ole-Morten Duesund f0fcb875c5 Tagger på innlegg med tagg-sider
Kommaseparert tagg-felt i editoren, lagret som liste i frontmatter
(dedup case-ufølsomt). /tag/<slug>/ lister alle innlegg med taggen,
også gjesters; /tag/ lister alle tagger med antall. Tagger vises som
piller på innlegg og i lister, som <category> i feeden og i sitemap.
«tag» er reservert som slug/brukernavn.

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

372 lines
14 KiB
Python
Raw 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
assert 'href="../theme/style.css"' in html
assert 'href="../theme/favicon.svg"' in html
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 'href="../../theme/style.css"' in 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 'href="theme/style.css"' in 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")
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 " Ole" in 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." 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()