Archived
684 lines
28 KiB
Python
684 lines
28 KiB
Python
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"},
|
|
]
|