This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/tests/test_ui.py
T

683 lines
28 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
import re
from pathlib import Path
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
def _csrf(html: str) -> str:
match = re.search(r'name="csrf" value="([^"]+)"', html)
assert match
return match.group(1)
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
assert "2001:db8::10" in response.text
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
assert "/u/" in response.text
assert "/nic/update?" in response.text
assert "/m/" in response.text
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/magic")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "FRITZ!Box replaces" in response.text
assert "Copy curl examples" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/magic", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/#create"
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
assert "Secret:" in management.text
assert "Router updates" in management.text
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
assert deleted.status_code == 200
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 404
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
set_password = client.post(
f"/m/{account['management_slug']}/password",
data={
"csrf": _csrf(dashboard.text),
"dashboard_password": "correct horse",
"dashboard_password_confirm": "correct horse",
},
follow_redirects=False,
)
assert set_password.status_code == 303
assert "httponly" in set_password.headers["set-cookie"].lower()
assert "samesite=lax" in set_password.headers["set-cookie"].lower()
assert "secure" not in set_password.headers["set-cookie"].lower()
stored = app.state.store.get_account_by_management_slug(account["management_slug"])
assert stored["dashboard_password_hash"].startswith("pbkdf2_sha256$")
assert stored["dashboard_password_hash"] != "correct horse"
locked_client = TestClient(app)
locked = locked_client.get(f"/m/{account['management_slug']}")
assert locked.status_code == 200
assert "Dashboard locked" in locked.text
assert "Update-URL:" not in locked.text
assert "correct horse" not in locked.text
update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 200
assert update.text == "good 203.0.113.9"
nic_update = locked_client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"myip": "203.0.113.10",
"username": account["username"],
"password": account["password"],
},
)
assert nic_update.status_code == 200
assert nic_update.text == "good 203.0.113.10"
bad_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
)
assert bad_unlock.status_code == 401
assert "Incorrect dashboard password." in bad_unlock.text
assert "Update-URL:" not in bad_unlock.text
good_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
follow_redirects=False,
)
assert good_unlock.status_code == 303
assert "max-age=86400" in good_unlock.headers["set-cookie"].lower()
unlocked = locked_client.get(f"/m/{account['management_slug']}")
assert "Update-URL:" in unlocked.text
assert "Password protection" in unlocked.text
def test_management_dashboard_password_cookie_is_secure_on_https_public_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="https://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
response = client.post(
f"/m/{account['management_slug']}/password",
data={
"csrf": _csrf(dashboard.text),
"dashboard_password": "correct horse",
"dashboard_password_confirm": "correct horse",
},
follow_redirects=False,
)
assert response.status_code == 303
assert "secure" in response.headers["set-cookie"].lower()
def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
store.set_dashboard_password("home.ddns.example.net", "correct horse")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
unlock = client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text), "dashboard_password": "correct horse"},
)
assert unlock.status_code == 200
remove = client.post(
f"/m/{account['management_slug']}/password/remove",
data={"csrf": _csrf(unlock.text), "current_dashboard_password": "correct horse"},
)
assert remove.status_code == 200
assert "Not enabled" in remove.text
fresh_client = TestClient(app)
unlocked = fresh_client.get(f"/m/{account['management_slug']}")
assert unlocked.status_code == 200
assert "Update-URL:" in unlocked.text
def test_dashboard_stay_unlocked_cookie_uses_longer_lifetime(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
store.set_dashboard_password("home.ddns.example.net", "correct horse")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
dashboard_unlock_seconds=3600,
dashboard_stay_unlocked_seconds=30 * 24 * 60 * 60,
)
)
client = TestClient(app)
locked = client.get(f"/m/{account['management_slug']}")
response = client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse", "stay_unlocked": "1"},
follow_redirects=False,
)
assert response.status_code == 303
assert "max-age=2592000" in response.headers["set-cookie"].lower()
def test_unlocked_dashboard_posts_require_csrf(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
assert response.status_code == 403
assert store.get_account_by_hostname("home.ddns.example.net")["update_slug"] == account["update_slug"]
def test_existing_dashboard_password_requires_current_password_to_change_or_remove(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
store.set_dashboard_password("home.ddns.example.net", "correct horse")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
locked = client.get(f"/m/{account['management_slug']}")
unlocked = client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
)
change = client.post(
f"/m/{account['management_slug']}/password",
data={
"csrf": _csrf(unlocked.text),
"current_dashboard_password": "wrong",
"dashboard_password": "new password",
"dashboard_password_confirm": "new password",
},
)
remove = client.post(
f"/m/{account['management_slug']}/password/remove",
data={"csrf": _csrf(unlocked.text), "current_dashboard_password": "wrong"},
)
assert change.status_code == 400
assert remove.status_code == 400
assert "Current dashboard password is incorrect." in change.text
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200
assert "Rotate Update-URL" in dashboard.text
assert "Rotate router credentials" in dashboard.text
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
assert rotated_update.status_code == 200
assert "Update-URL rotated." in rotated_update.text
assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
assert rotated_password.status_code == 200
assert "Router credentials rotated" in rotated_password.text
assert "One-box update URL:" in rotated_password.text
assert "Kennwort (optional):" in rotated_password.text
assert "data-secret-toggle" in rotated_password.text
assert client.get(
"/nic/update",
params={
"hostname": "home.ddns.example.net",
"username": "router",
"password": account["password"],
"myip": "203.0.113.9",
},
).status_code == 401
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
assert "dashboard_update_url_rotated" in statuses
assert "dashboard_router_credentials_rotated" in statuses
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
first = store.create_account("first.ddns.example.net", "first")
second = store.create_account("second.ddns.example.net", "second")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
dashboard = client.get(f"/m/{first['management_slug']}")
rotated = client.post(f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
assert rotated.status_code == 200
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("locked.ddns.example.net", "router")
store.set_dashboard_password("locked.ddns.example.net", "correct horse")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
delete = client.post(f"/m/{account['management_slug']}/delete")
claim = client.post(f"/m/{account['management_slug']}/domains/request", data={"domain": "example.net"})
assert rotate.status_code == 401
assert delete.status_code == 401
assert claim.status_code == 401
assert store.get_account_by_hostname("locked.ddns.example.net")["update_slug"] == account["update_slug"]
assert store.get_account_by_slug(account["update_slug"]) is not None
assert store.get_domain_challenge("example.net") is None
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1")
store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/m/{account['management_slug']}")
assert response.status_code == 200
assert "Router updates" in response.text
assert "203.0.113.9" in response.text
assert "203.0.113.10" in response.text
def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("base.ddns.example.net", "router")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
assert "Domain claim created" in challenge.text
assert "TXT name" in challenge.text
assert "TXT value" in challenge.text
assert "Claim secret" in challenge.text
assert "Private claim link" in challenge.text
assert "This claim belongs to this dashboard" in challenge.text
assert "data-secret-toggle" in challenge.text
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
verified = client.post(
f"/m/{account['management_slug']}/domains/verify",
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
)
assert verified.status_code == 200
assert "Domain verified" in verified.text
assert "Create custom hostname" in verified.text
created = client.post(
f"/m/{account['management_slug']}/domains/hostnames",
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "Custom hostname created" in created.text
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
assert "Private status page:" in created.text
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
owner = store.create_account("owner.ddns.example.net", "owner")
other = store.create_account("other.ddns.example.net", "other")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
owner_dashboard = client.get(f"/m/{owner['management_slug']}")
challenge = client.post(f"/m/{owner['management_slug']}/domains/request", data={"csrf": _csrf(owner_dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
verify_other = client.post(
f"/m/{other['management_slug']}/domains/verify",
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "domain": "example.net", "claim_secret": claim_secret},
)
create_other = client.post(
f"/m/{other['management_slug']}/domains/hostnames",
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
verify_owner = client.post(
f"/m/{owner['management_slug']}/domains/verify",
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
)
assert verify_other.status_code == 404
assert create_other.status_code == 403
assert verify_owner.status_code == 200
assert "Domain verified" in verify_owner.text
assert store.get_account_by_hostname("home.example.net") is None
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
assert response.status_code == 403
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
store = DdnsStore(tmp_path / "ddns.sqlite3")
challenge_data = store.create_domain_challenge("example.net")
verify = client.post(
"/verify-domain",
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert verify.status_code == 200
response = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert response.status_code == 200
assert "home.example.net" in response.text
assert "Update-URL:" in response.text
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
challenge = client.post("/request-domain", data={"domain": "example.net"})
assert challenge.status_code == 200
assert "Private claim link" in challenge.text
assert "/d/" in challenge.text
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
claim_page = client.get(f"/d/{claim_secret}")
assert claim_page.status_code == 200
assert "TXT value" in claim_page.text
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create credentials" in verify.text
created = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "generates the hostname, secret update URL, and optional router compatibility credentials automatically" in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
assert "--delete-config" in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/u/<secret>" in response.text
assert "http://127.0.0.1" not in response.text
assert "http://localhost" not in response.text
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert '<script src="/app.js" defer></script>' in response.text
assert '<link href="/app.css" rel="stylesheet">' in response.text
assert "<style>" not in response.text
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert "style-src 'self' https://cdn.jsdelivr.net" in response.headers["content-security-policy"]
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert client.get("/app.js").status_code == 200
app_css = client.get("/app.css")
assert app_css.status_code == 200
assert "text/css" in app_css.headers["content-type"]
assert app_css.headers["cache-control"] == "public, max-age=3600"
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "Made by Karl" in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text
assert 'href="/privacy"' in response.text
privacy = client.get("/privacy")
assert privacy.status_code == 200
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "hashed router passwords" in privacy.text
assert "Made by Karl" in privacy.text
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/favicon.ico"' in response.text
assert 'href="/favicon-32x32.png"' in response.text
assert 'href="/favicon-16x16.png"' in response.text
assert 'href="/apple-touch-icon.png"' in response.text
assert 'href="/site.webmanifest"' in response.text
for path in [
"/favicon.ico",
"/favicon-32x32.png",
"/favicon-16x16.png",
"/apple-touch-icon.png",
"/logo-192.png",
"/logo-512.png",
]:
asset = client.get(path)
assert asset.status_code == 200
assert asset.content
manifest = client.get("/site.webmanifest")
assert manifest.status_code == 200
assert manifest.json()["icons"] == [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
]