soapbox/tests/test_app.py

445 lines
17 KiB
Python
Raw Normal View History

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"]
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=', 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=', 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