@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,247 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from devplacepy.database import get_setting, get_table, set_setting
|
||||
from devplacepy.main import app
|
||||
from devplacepy.routers.admin import communities as router_module
|
||||
from devplacepy.services.community.runner import CommunityRunner
|
||||
from devplacepy.services.community.store import store
|
||||
from devplacepy.utils import register_account
|
||||
from tests.unit.services.community.fakes import FakeFetcher, json_community, no_sleep
|
||||
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def unique(prefix: str) -> str:
|
||||
_counter[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
|
||||
|
||||
|
||||
def make_account(role: str = "Member") -> dict:
|
||||
name = unique("cmty")
|
||||
uid, _, _ = register_account(name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True)
|
||||
if role != "Member":
|
||||
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
||||
row = get_table("users").find_one(uid=uid)
|
||||
return {"username": name, "headers": {"X-API-KEY": row["api_key"], "Accept": "application/json"}}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
previous = {key: get_setting(key, "") for key in ("sync_enabled", "sync_origins")}
|
||||
set_setting("sync_enabled", "1")
|
||||
set_setting("sync_origins", "")
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
for key, value in previous.items():
|
||||
set_setting(key, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_world(monkeypatch):
|
||||
routes = json_community()
|
||||
monkeypatch.setattr(
|
||||
router_module,
|
||||
"CommunityRunner",
|
||||
lambda: CommunityRunner(fetcher_factory=lambda: FakeFetcher(routes), sleep=no_sleep),
|
||||
)
|
||||
get_table("communities").delete()
|
||||
get_table("community_runs").delete()
|
||||
get_table("sync_links").delete(origin="forum.test")
|
||||
return routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(client):
|
||||
return make_account("Admin")
|
||||
|
||||
|
||||
def wait_for(predicate, seconds: float = 10.0):
|
||||
deadline = time.time() + seconds
|
||||
while time.time() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.1)
|
||||
raise AssertionError("condition never became true")
|
||||
|
||||
|
||||
def add_ready(client, admin) -> dict:
|
||||
response = client.post("/admin/communities", data={"query": "https://forum.test/api/posts"}, headers=admin["headers"])
|
||||
assert response.status_code == 200, response.text
|
||||
return wait_for(lambda: (row := store.get("forum-test")) and row["status"] == "ready" and row)
|
||||
|
||||
|
||||
def test_member_is_refused(client, admin, fake_world):
|
||||
member = make_account()
|
||||
assert client.get("/admin/communities", headers=member["headers"], follow_redirects=False).status_code in (303, 403)
|
||||
assert client.post("/admin/communities", data={"query": "x.test"}, headers=member["headers"], follow_redirects=False).status_code in (303, 403)
|
||||
|
||||
|
||||
def test_add_learns_and_shows_detail(client, admin, fake_world):
|
||||
row = add_ready(client, admin)
|
||||
assert row["origin"] == "forum.test"
|
||||
assert row["schema"]["feeds"][0]["type"] == "json_api"
|
||||
assert row["description"] == "A friendly forum about code."
|
||||
assert row["user_count"] == 1234
|
||||
listing = client.get("/admin/communities", headers=admin["headers"])
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["items"][0]["slug"] == "forum-test"
|
||||
detail = client.get("/admin/communities/forum-test", headers=admin["headers"])
|
||||
assert detail.status_code == 200
|
||||
body = detail.json()
|
||||
assert body["feeds"][0]["pagination"] == "page"
|
||||
assert body["collectors_lines"] > 100
|
||||
html = client.get("/admin/communities/forum-test", headers={"X-API-KEY": admin["headers"]["X-API-KEY"]})
|
||||
assert html.status_code == 200 and "Learned feeds" in html.text
|
||||
|
||||
|
||||
def test_add_is_idempotent(client, admin, fake_world):
|
||||
add_ready(client, admin)
|
||||
again = client.post("/admin/communities", data={"query": "https://forum.test/api/posts"}, headers=admin["headers"])
|
||||
assert again.status_code == 200
|
||||
assert len(store.list()) == 1
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing_and_keeps_watermark(client, admin, fake_world):
|
||||
row = add_ready(client, admin)
|
||||
users_before = get_table("users").count()
|
||||
response = client.post("/admin/communities/forum-test/run", data={"dry_run": "true"}, headers=admin["headers"])
|
||||
assert response.status_code == 200
|
||||
run = wait_for(lambda: next((item for item in store.runs(row["uid"]) if item["status"] == "dry_run"), None))
|
||||
assert run["dry_run"] == 1
|
||||
assert run["pushed"]["posts"]["created"] == 10
|
||||
assert "DRY RUN" in run["log"]
|
||||
assert get_table("users").count() == users_before
|
||||
after = store.get("forum-test")
|
||||
assert not after["watermark"]
|
||||
assert after["run_count"] == 0
|
||||
|
||||
|
||||
def test_live_run_creates_then_is_unchanged(client, admin, fake_world):
|
||||
row = add_ready(client, admin)
|
||||
client.post("/admin/communities/forum-test/run", data={}, headers=admin["headers"])
|
||||
first = wait_for(lambda: next((item for item in store.runs(row["uid"]) if item["status"] in ("ok", "partial", "failed")), None))
|
||||
assert first["status"] == "ok", first["errors"]
|
||||
assert first["pushed"]["posts"]["created"] == 10
|
||||
assert get_table("sync_links").count(origin="forum.test", kind="posts") == 10
|
||||
done = store.get("forum-test")
|
||||
assert done["run_count"] == 1 and done["watermark"]["feeds"]["feed1"]["since"]
|
||||
assert done["next_run_at"]
|
||||
client.post("/admin/communities/forum-test/run", data={}, headers=admin["headers"])
|
||||
second = wait_for(lambda: len([item for item in store.runs(row["uid"]) if item["status"] != "running"]) >= 2 and store.runs(row["uid"])[0])
|
||||
assert second["pushed"].get("posts", {}).get("created", 0) == 0
|
||||
|
||||
|
||||
def test_live_run_refused_when_sync_disabled(client, admin, fake_world):
|
||||
row = add_ready(client, admin)
|
||||
set_setting("sync_enabled", "0")
|
||||
try:
|
||||
client.post("/admin/communities/forum-test/run", data={}, headers=admin["headers"])
|
||||
run = wait_for(lambda: next((item for item in store.runs(row["uid"]) if item["status"] != "running"), None))
|
||||
assert run["status"] == "failed"
|
||||
assert any("disabled" in error for error in run["errors"])
|
||||
finally:
|
||||
set_setting("sync_enabled", "1")
|
||||
|
||||
|
||||
def test_update_and_delete(client, admin, fake_world):
|
||||
add_ready(client, admin)
|
||||
client.post("/admin/communities/forum-test/update", data={"interval_seconds": "7200", "enabled": "on"}, headers=admin["headers"])
|
||||
assert store.get("forum-test")["interval_seconds"] == 7200
|
||||
bad = client.post("/admin/communities/forum-test/update", data={"interval_seconds": "10"}, headers=admin["headers"])
|
||||
assert bad.status_code in (400, 422)
|
||||
client.post("/admin/communities/forum-test/delete", headers=admin["headers"])
|
||||
assert store.get("forum-test") is None
|
||||
assert get_table("communities").find_one(slug="forum-test")["deleted_at"]
|
||||
|
||||
|
||||
def test_unknown_community_is_404(client, admin, fake_world):
|
||||
assert client.get("/admin/communities/nope", headers=admin["headers"]).status_code == 404
|
||||
|
||||
|
||||
GATED = [
|
||||
("get", "/admin/communities", {}),
|
||||
("post", "/admin/communities", {"query": "https://forum.test/api/posts"}),
|
||||
("get", "/admin/communities/forum-test", {}),
|
||||
("post", "/admin/communities/forum-test/run", {}),
|
||||
("post", "/admin/communities/forum-test/relearn", {}),
|
||||
("post", "/admin/communities/forum-test/update", {"interval_seconds": "7200"}),
|
||||
("post", "/admin/communities/forum-test/delete", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path,data", GATED)
|
||||
def test_every_endpoint_refuses_guests_and_members_and_changes_nothing(client, admin, fake_world, method, path, data):
|
||||
add_ready(client, admin)
|
||||
before = store.get("forum-test")
|
||||
member = make_account()
|
||||
for headers in ({"Accept": "application/json"}, member["headers"]):
|
||||
response = client.request(method.upper(), path, data=data or None, headers=headers, follow_redirects=False)
|
||||
assert response.status_code in (303, 401, 403), (headers, response.status_code)
|
||||
after = store.get("forum-test")
|
||||
assert after is not None
|
||||
assert after["interval_seconds"] == before["interval_seconds"] and len(store.list()) == 1
|
||||
|
||||
|
||||
def test_password_is_stored_but_never_returned(client, admin, fake_world):
|
||||
client.post(
|
||||
"/admin/communities",
|
||||
data={"query": "https://forum.test/api/posts", "login_username": "bot", "login_password": "s3cret-pw"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
row = wait_for(lambda: (found := store.get("forum-test")) and found["status"] == "ready" and found)
|
||||
assert row["login_password"] == "s3cret-pw"
|
||||
for url in ("/admin/communities", "/admin/communities/forum-test"):
|
||||
body = client.get(url, headers=admin["headers"])
|
||||
assert "s3cret-pw" not in body.text and body.json()
|
||||
page = client.get("/admin/communities/forum-test", headers={"X-API-KEY": admin["headers"]["X-API-KEY"]})
|
||||
assert "s3cret-pw" not in page.text and "(stored)" in page.text
|
||||
client.post("/admin/communities/forum-test/update", data={"interval_seconds": "7200", "login_username": "other"}, headers=admin["headers"])
|
||||
assert store.get("forum-test")["login_password"] == "s3cret-pw"
|
||||
client.post("/admin/communities/forum-test/update", data={"interval_seconds": "7200", "clear_login": "on"}, headers=admin["headers"])
|
||||
assert store.get("forum-test")["login_password"] == ""
|
||||
|
||||
|
||||
def test_devii_tools_are_admin_only_and_delete_needs_confirmation():
|
||||
from devplacepy.services.devii.actions.catalog.community import COMMUNITY_ACTIONS
|
||||
from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED
|
||||
from devplacepy.docs_api.groups import community
|
||||
|
||||
assert {action.name for action in COMMUNITY_ACTIONS} >= {"community_add", "community_run", "community_delete"}
|
||||
assert all(action.requires_admin for action in COMMUNITY_ACTIONS)
|
||||
assert "community_delete" in CONFIRM_REQUIRED
|
||||
assert next(action for action in COMMUNITY_ACTIONS if action.name == "community_delete").params[-1].name == "confirm"
|
||||
assert community.GROUP["admin"] is True
|
||||
|
||||
|
||||
def test_update_saves_sync_options_and_keeps_unsent_ones(client, admin, fake_world):
|
||||
add_ready(client, admin)
|
||||
body = {"interval_seconds": 86400, "enabled": "true", "alias": "true", "sample_percent": 40, "topics": "devlog, rant"}
|
||||
assert client.post("/admin/communities/forum-test/update", data=body, headers=admin["headers"]).status_code == 200
|
||||
options = store.get("forum-test")["sync_options"]
|
||||
assert options["alias"] is True and options["sample_percent"] == 40 and options["topics"] == ["devlog", "rant"]
|
||||
client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "jitter_percent": 10}, headers=admin["headers"])
|
||||
kept = store.get("forum-test")["sync_options"]
|
||||
assert kept["alias"] is True and kept["jitter_percent"] == 10
|
||||
|
||||
|
||||
def test_update_rejects_bad_options_and_late_aliasing(client, admin, fake_world):
|
||||
add_ready(client, admin)
|
||||
bad = client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "topics": "nonsense"}, headers=admin["headers"])
|
||||
assert bad.status_code == 400
|
||||
get_table("sync_links").insert({"uid": "late1", "origin": "forum.test", "kind": "users", "origin_uid": "1", "local_uid": "x", "fingerprint": "f", "origin_updated_at": "", "first_synced_at": "", "synced_at": ""})
|
||||
late = client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "alias": "true"}, headers=admin["headers"])
|
||||
assert late.status_code == 400
|
||||
assert not store.get("forum-test")["sync_options"].get("alias")
|
||||
|
||||
|
||||
def test_sync_options_are_admin_only(client, fake_world):
|
||||
member = make_account("Member")
|
||||
add_ready(client, make_account("Admin"))
|
||||
assert client.get("/admin/communities/forum-test", headers=member["headers"]).status_code in (401, 403)
|
||||
assert client.post("/admin/communities/forum-test/update", data={"interval_seconds": 86400, "alias": "true"}, headers=member["headers"]).status_code in (401, 403)
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,36 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.database.nodes import create_device
|
||||
from devplacepy.main import app
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def admin_headers(who: dict) -> dict:
|
||||
return {**who["headers"], **JSON}
|
||||
|
||||
|
||||
def make_admin() -> dict:
|
||||
return make_member(consent=False, enabled=False, role="Admin")
|
||||
|
||||
|
||||
def make_device(owner_uid: str, label: str = "Home laptop") -> dict:
|
||||
return create_device(owner_uid, label=label, kind="native")[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
previous = get_setting("nodes_enabled", "0")
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
set_setting("nodes_enabled", previous)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin():
|
||||
return make_admin()
|
||||
@@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database.nodes import list_flags, raise_flag
|
||||
from tests.api.admin.nodes.conftest import admin_headers, make_admin, make_device
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
|
||||
def test_member_cannot_resolve(client):
|
||||
member = make_member()
|
||||
assert client.post("/admin/nodes/flags/x/resolve", headers=admin_headers(member)).status_code == 403
|
||||
|
||||
|
||||
def test_resolve_closes_the_flag_once(client, admin):
|
||||
owner = make_member()
|
||||
device = make_device(owner["uid"])
|
||||
flag = raise_flag("refusal_mismatch", device_uid=device["uid"], user_uid=owner["uid"], detail="test")
|
||||
listed = client.get("/admin/nodes", headers=admin_headers(admin)).json()["flags"]
|
||||
assert flag["uid"] in {item["uid"] for item in listed}
|
||||
assert client.post(f"/admin/nodes/flags/{flag['uid']}/resolve", headers=admin_headers(admin)).status_code == 200
|
||||
assert flag["uid"] not in {item["uid"] for item in list_flags()}
|
||||
assert client.post(f"/admin/nodes/flags/{flag['uid']}/resolve", headers=admin_headers(admin)).status_code == 409
|
||||
|
||||
|
||||
def test_unknown_flag_is_404(client, admin):
|
||||
assert client.post("/admin/nodes/flags/nope/resolve", headers=admin_headers(admin)).status_code == 404
|
||||
|
||||
|
||||
def test_junior_admin_cannot_resolve_a_senior_admins_flag(client):
|
||||
senior = make_admin()
|
||||
junior = make_admin()
|
||||
device = make_device(senior["uid"])
|
||||
flag = raise_flag("integrity_failure", device_uid=device["uid"], user_uid=senior["uid"], detail="test")
|
||||
client.post(f"/admin/nodes/flags/{flag['uid']}/resolve", headers=admin_headers(junior))
|
||||
assert flag["uid"] in {item["uid"] for item in list_flags()}
|
||||
@@ -0,0 +1,42 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database.nodes import LIMITS
|
||||
from tests.api.admin.nodes.conftest import admin_headers, make_device
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
|
||||
def test_guest_is_refused(client):
|
||||
assert client.get("/admin/nodes", headers={"Accept": "application/json"}).status_code == 401
|
||||
|
||||
|
||||
def test_member_is_refused(client):
|
||||
member = make_member()
|
||||
assert client.get("/admin/nodes", headers=admin_headers(member)).status_code == 403
|
||||
|
||||
|
||||
def test_admin_overview_shape(client, admin):
|
||||
owner = make_member()
|
||||
device = make_device(owner["uid"], "Overview laptop")
|
||||
body = client.get("/admin/nodes", headers=admin_headers(admin)).json()
|
||||
assert set(body["pool"]) >= {"online", "connected", "capacity", "open_circuits"}
|
||||
assert body["queue"]["max_depth"] == LIMITS.max_queue_depth
|
||||
assert body["limits"]["max_connected_nodes"] == LIMITS.max_connected_nodes
|
||||
assert {item["key"] for item in body["settings"]} == {
|
||||
"nodes_max_total",
|
||||
"nodes_daily_quota_user",
|
||||
"nodes_daily_quota_guest",
|
||||
"nodes_cache_ttl_seconds",
|
||||
"nodes_daily_social_user",
|
||||
"nodes_social_via_nodes",
|
||||
}
|
||||
listed = {row["uid"]: row for row in body["devices"]}
|
||||
assert listed[device["uid"]]["owner_username"] == owner["username"]
|
||||
assert len(body["devices"]) <= 100
|
||||
assert "secret_hash" not in listed[device["uid"]]
|
||||
|
||||
|
||||
def test_html_page_renders(client, admin):
|
||||
response = client.get("/admin/nodes", headers=admin["headers"])
|
||||
assert response.status_code == 200
|
||||
assert "Community nodes" in response.text
|
||||
assert "data-live=\"summary\"" in response.text
|
||||
@@ -0,0 +1,24 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from tests.api.admin.nodes.conftest import admin_headers
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
|
||||
def test_member_cannot_flip_the_switch(client):
|
||||
member = make_member()
|
||||
assert client.post("/admin/nodes/killswitch", json={"enabled": True}, headers=admin_headers(member)).status_code == 403
|
||||
|
||||
|
||||
def test_admin_sets_and_flips_the_switch(client, admin):
|
||||
previous = get_setting("nodes_enabled", "0")
|
||||
try:
|
||||
set_setting("nodes_enabled", "0")
|
||||
on = client.post("/admin/nodes/killswitch", json={"enabled": True}, headers=admin_headers(admin))
|
||||
assert on.json()["data"] == {"enabled": True}
|
||||
assert get_setting("nodes_enabled", "0") == "1"
|
||||
flipped = client.post("/admin/nodes/killswitch", json={}, headers=admin_headers(admin))
|
||||
assert flipped.json()["data"] == {"enabled": False}
|
||||
assert get_setting("nodes_enabled", "1") == "0"
|
||||
finally:
|
||||
set_setting("nodes_enabled", previous)
|
||||
@@ -0,0 +1,36 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.database.nodes import LIMITS
|
||||
from tests.api.admin.nodes.conftest import admin_headers
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
KEYS = ("nodes_max_total", "nodes_daily_quota_user", "nodes_social_via_nodes")
|
||||
|
||||
|
||||
def test_member_cannot_change_settings(client):
|
||||
member = make_member()
|
||||
assert client.post("/admin/nodes/settings", json={"nodes_max_total": 1}, headers=admin_headers(member)).status_code == 403
|
||||
|
||||
|
||||
def test_only_submitted_fields_change_and_ceilings_clamp(client, admin):
|
||||
previous = {key: get_setting(key, "") for key in KEYS}
|
||||
try:
|
||||
response = client.post(
|
||||
"/admin/nodes/settings",
|
||||
json={"nodes_max_total": LIMITS.max_connected_nodes + 500, "nodes_social_via_nodes": "1"},
|
||||
headers=admin_headers(admin),
|
||||
)
|
||||
data = response.json()["data"]
|
||||
assert "nodes_max_total" in data["clamped"]
|
||||
assert get_setting("nodes_max_total", "") == str(LIMITS.max_connected_nodes)
|
||||
assert get_setting("nodes_social_via_nodes", "") == "1"
|
||||
assert get_setting("nodes_daily_quota_user", "") == previous["nodes_daily_quota_user"]
|
||||
finally:
|
||||
for key, value in previous.items():
|
||||
set_setting(key, value)
|
||||
|
||||
|
||||
def test_negative_and_unknown_values_are_rejected(client, admin):
|
||||
assert client.post("/admin/nodes/settings", json={"nodes_max_total": -1}, headers=admin_headers(admin)).status_code in (400, 422)
|
||||
assert client.post("/admin/nodes/settings", json={"nodes_social_via_nodes": "2"}, headers=admin_headers(admin)).status_code in (400, 422)
|
||||
@@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database.nodes import get_device
|
||||
from tests.api.admin.nodes.conftest import admin_headers, make_admin, make_device
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
|
||||
def test_member_cannot_suspend(client):
|
||||
member = make_member()
|
||||
device = make_device(member["uid"])
|
||||
response = client.post(f"/admin/nodes/devices/{device['uid']}/suspend", json={"minutes": 5}, headers=admin_headers(member))
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_suspend_and_unsuspend_round_trip(client, admin):
|
||||
owner = make_member()
|
||||
device = make_device(owner["uid"])
|
||||
url = f"/admin/nodes/devices/{device['uid']}"
|
||||
response = client.post(f"{url}/suspend", json={"minutes": 30, "reason": "noisy"}, headers=admin_headers(admin))
|
||||
assert response.json()["data"] == {"uid": device["uid"], "minutes": 30}
|
||||
row = get_device(device["uid"])
|
||||
assert row["status"] == "suspended"
|
||||
assert row["suspend_reason"] == "noisy"
|
||||
assert client.post(f"{url}/unsuspend", headers=admin_headers(admin)).status_code == 200
|
||||
assert get_device(device["uid"])["status"] == "active"
|
||||
assert client.post(f"{url}/unsuspend", headers=admin_headers(admin)).status_code == 409
|
||||
|
||||
|
||||
def test_minutes_are_bounded(client, admin):
|
||||
owner = make_member()
|
||||
device = make_device(owner["uid"])
|
||||
url = f"/admin/nodes/devices/{device['uid']}/suspend"
|
||||
for minutes in (0, 10081):
|
||||
assert client.post(url, json={"minutes": minutes}, headers=admin_headers(admin)).status_code in (400, 422)
|
||||
|
||||
|
||||
def test_unknown_device_is_404(client, admin):
|
||||
response = client.post("/admin/nodes/devices/nope/suspend", json={"minutes": 5}, headers=admin_headers(admin))
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_junior_admin_cannot_suspend_a_senior_admins_device(client):
|
||||
senior = make_admin()
|
||||
junior = make_admin()
|
||||
device = make_device(senior["uid"])
|
||||
client.post(f"/admin/nodes/devices/{device['uid']}/suspend", json={"minutes": 5}, headers=admin_headers(junior))
|
||||
assert get_device(device["uid"])["status"] == "active"
|
||||
@@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy import config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifacts(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "NODE_CLIENTS_DIR", tmp_path)
|
||||
(tmp_path / "devplace-node-linux-amd64").write_bytes(b"binary-bytes")
|
||||
(tmp_path / "manifest.json").write_text('[{"version": "0.3.0"}]')
|
||||
(tmp_path / "bad name.txt").write_text("x")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_client_version_lists_artifacts_with_checksums(client, artifacts):
|
||||
body = client.get("/nodes/client-version").json()
|
||||
assert body["version"] == "0.3.0" and body["protocol"] >= 1
|
||||
names = [item["name"] for item in body["artifacts"]]
|
||||
assert names == ["devplace-node-linux-amd64"]
|
||||
assert body["artifacts"][0]["sha256"] == hashlib.sha256(b"binary-bytes").hexdigest()
|
||||
|
||||
|
||||
def test_download_serves_the_file_with_its_checksum(client, artifacts):
|
||||
response = client.get("/nodes/download/devplace-node-linux-amd64")
|
||||
assert response.status_code == 200 and response.content == b"binary-bytes"
|
||||
assert response.headers["x-content-sha256"] == hashlib.sha256(b"binary-bytes").hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["manifest.json", "missing", "..%2Fsecret", "bad%20name.txt", ".hidden"])
|
||||
def test_download_refuses_everything_else(client, artifacts, name):
|
||||
assert client.get(f"/nodes/download/{name}").status_code in (404, 422)
|
||||
|
||||
|
||||
def test_download_refuses_a_symlink(client, artifacts):
|
||||
outside = artifacts.parent / "outside.bin"
|
||||
outside.write_bytes(b"secret")
|
||||
(artifacts / "link").symlink_to(outside)
|
||||
assert client.get("/nodes/download/link").status_code == 404
|
||||
|
||||
|
||||
def test_empty_directory_is_an_empty_list(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "NODE_CLIENTS_DIR", tmp_path / "absent")
|
||||
assert client.get("/nodes/client-version").json()["artifacts"] == []
|
||||
@@ -0,0 +1,187 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import nodes as store
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.nodes.broker import JobBroker
|
||||
from devplacepy.services.nodes.policy import DestinationPolicy
|
||||
from devplacepy.services.nodes.spec import CLOSE_PROTOCOL, SPEC
|
||||
from tests.api.nodes.conftest import make_member
|
||||
from tests.api.nodes.ws import hello, ticket_for
|
||||
|
||||
URL = "https://www.reddit.com/r/python.json"
|
||||
BROKER = JobBroker()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def relaxed(client):
|
||||
service = service_manager.get_service("nodes")
|
||||
original = service.scheduler.destination_ready
|
||||
service.scheduler.destination_ready = lambda target: True
|
||||
get_table("node_requests").delete()
|
||||
yield service
|
||||
service.scheduler.destination_ready = original
|
||||
get_table("node_requests").delete()
|
||||
|
||||
|
||||
def enqueue(caller="caller-1", kind="user", url=URL):
|
||||
target = DestinationPolicy().check(url)
|
||||
return BROKER.enqueue(kind, caller, target, "any")
|
||||
|
||||
|
||||
def connect(client, member):
|
||||
device_uid, _, ticket = ticket_for(client, member)
|
||||
return device_uid, client.websocket_connect(f"/nodes/ws?ticket={ticket}")
|
||||
|
||||
|
||||
def next_job(ws, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
frame = json.loads(ws.receive_text())
|
||||
if frame["type"] == "job":
|
||||
return frame
|
||||
if frame["type"] == "ping":
|
||||
ws.send_text(json.dumps({"type": "pong"}))
|
||||
raise AssertionError("no job arrived")
|
||||
|
||||
|
||||
def wait_for(uid, status, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
row = store.get_request(uid)
|
||||
if row["status"] == status:
|
||||
return row
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"request stayed {store.get_request(uid)['status']}")
|
||||
|
||||
|
||||
def test_a_queued_request_reaches_the_node_and_the_result_comes_back(client, relaxed, member):
|
||||
device_uid, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
assert json.loads(ws.receive_text())["type"] == "welcome"
|
||||
uid = enqueue()
|
||||
job = next_job(ws)
|
||||
assert job["job_id"] == uid and job["url"] == URL and job["method"] == "GET"
|
||||
assert job["timeout"] == SPEC.limits.job_timeout_seconds
|
||||
ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "job_result",
|
||||
"job_id": uid,
|
||||
"status": 200,
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body_b64": base64.b64encode(b'{"ok": true}').decode(),
|
||||
}
|
||||
)
|
||||
)
|
||||
wait_for(uid, "done")
|
||||
result = BROKER.read(uid)
|
||||
assert result.http_status == 200 and result.body == b'{"ok": true}'
|
||||
assert result.headers == {"content-type": "application/json"}
|
||||
today = store.usage_today(device_uid)
|
||||
assert today["requests_ok"] == 1 and today["bytes_down"] == 12
|
||||
assert store.get_donor(member["uid"])["requests_ok"] == 1
|
||||
|
||||
|
||||
def test_a_node_that_speaks_no_known_transport_gets_nothing(client, relaxed, member):
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["any"]))
|
||||
ws.receive_text()
|
||||
uid = enqueue()
|
||||
time.sleep(0.7)
|
||||
assert store.get_request(uid)["status"] == "queued"
|
||||
|
||||
|
||||
def test_a_donor_never_receives_its_own_request(client, relaxed, member):
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
ws.receive_text()
|
||||
uid = enqueue(caller=member["uid"])
|
||||
time.sleep(0.7)
|
||||
assert store.get_request(uid)["status"] == "queued"
|
||||
|
||||
|
||||
def test_a_donor_opt_out_of_the_platform_is_respected_live(client, relaxed, member):
|
||||
store.save_node_settings(member["uid"], {"allowed_platforms": []})
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
assert json.loads(ws.receive_text())["settings"]["allowed_platforms"] == []
|
||||
uid = enqueue()
|
||||
time.sleep(0.7)
|
||||
assert store.get_request(uid)["status"] == "queued"
|
||||
|
||||
|
||||
def test_a_job_error_marks_the_request_failed(client, relaxed, member):
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
ws.receive_text()
|
||||
uid = enqueue()
|
||||
job = next_job(ws)
|
||||
ws.send_text(json.dumps({"type": "job_error", "job_id": job["job_id"], "code": "timeout"}))
|
||||
row = wait_for(uid, "failed")
|
||||
assert row["error"] == "timeout"
|
||||
|
||||
|
||||
def test_disconnecting_with_a_job_in_flight_fails_it(client, relaxed, member):
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
ws.receive_text()
|
||||
uid = enqueue()
|
||||
next_job(ws)
|
||||
row = wait_for(uid, "failed")
|
||||
assert row["error"] == "node_disconnected"
|
||||
|
||||
|
||||
def test_a_forged_result_is_an_integrity_failure_and_a_flag(client, relaxed, member):
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
ws.receive_text()
|
||||
uid = enqueue()
|
||||
next_job(ws)
|
||||
ws.send_text(
|
||||
json.dumps({"type": "job_result", "job_id": uid, "status": 200, "body_b64": "***"})
|
||||
)
|
||||
row = wait_for(uid, "failed")
|
||||
assert row["error"] == "bad_base64"
|
||||
assert any(flag["kind"] == "integrity" for flag in store.list_flags())
|
||||
|
||||
|
||||
def test_answering_a_job_that_was_never_assigned_closes_the_socket(client, relaxed, member):
|
||||
_, connection = connect(client, member)
|
||||
with connection as ws:
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
ws.receive_text()
|
||||
ws.send_text(
|
||||
json.dumps({"type": "job_result", "job_id": "made-up", "status": 200, "body_b64": ""})
|
||||
)
|
||||
with pytest.raises(WebSocketDisconnect) as info:
|
||||
ws.receive_text()
|
||||
assert info.value.code == CLOSE_PROTOCOL
|
||||
|
||||
|
||||
def test_two_donors_split_two_requests(client, relaxed):
|
||||
first, second = make_member(), make_member()
|
||||
_, one = connect(client, first)
|
||||
_, two = connect(client, second)
|
||||
with one as ws_one, two as ws_two:
|
||||
for ws in (ws_one, ws_two):
|
||||
ws.send_text(hello(modes=["fetch"]))
|
||||
ws.receive_text()
|
||||
a = enqueue(caller="x")
|
||||
b = enqueue(caller="y", url="https://old.reddit.com/r/rust.json")
|
||||
jobs = {next_job(ws_one)["job_id"], next_job(ws_two)["job_id"]}
|
||||
assert jobs == {a, b}
|
||||
@@ -0,0 +1,84 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import set_consent
|
||||
from devplacepy.database.nodes import record_job, save_node_settings
|
||||
from tests.api.nodes.conftest import make_member
|
||||
|
||||
|
||||
def test_guests_get_401(client):
|
||||
assert client.get("/nodes/me").status_code == 401
|
||||
assert client.post("/nodes/settings", json={"enabled": False}).status_code == 401
|
||||
|
||||
|
||||
def test_me_reports_status_and_never_caches(client, member):
|
||||
response = client.get("/nodes/me", headers=member["headers"])
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
body = response.json()
|
||||
assert body["consent"] is True and body["can_donate"] is True
|
||||
assert body["settings"]["enabled"] is True
|
||||
assert body["devices"] == [] and body["max_devices"] > 0
|
||||
assert body["donated"]["lifetime_requests"] == 0
|
||||
assert "reddit" in body["platforms"]
|
||||
|
||||
|
||||
def test_me_explains_why_a_member_cannot_donate(client):
|
||||
who = make_member(consent=False, enabled=False)
|
||||
body = client.get("/nodes/me", headers=who["headers"]).json()
|
||||
assert body["can_donate"] is False
|
||||
assert body["reason"] and body["message"]
|
||||
|
||||
|
||||
def test_settings_are_clamped_and_unsent_fields_keep_their_value(client, member):
|
||||
response = client.post(
|
||||
"/nodes/settings",
|
||||
json={"bandwidth_mb_day": 100, "active_from": "08:00", "active_to": "22:00", "timezone": "Europe/Amsterdam"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
settings = response.json()["settings"]
|
||||
assert settings["bandwidth_mb_day"] == 100 and settings["active_from"] == "08:00"
|
||||
assert settings["enabled"] is True
|
||||
again = client.post("/nodes/settings", json={"max_concurrent": 1}, headers=member["headers"]).json()
|
||||
assert again["settings"]["bandwidth_mb_day"] == 100 and again["settings"]["max_concurrent"] == 1
|
||||
too_big = client.post("/nodes/settings", json={"bandwidth_mb_day": 10**9}, headers=member["headers"])
|
||||
assert too_big.status_code in (400, 422)
|
||||
|
||||
|
||||
def test_switching_on_needs_the_consent(client):
|
||||
who = make_member(consent=False, enabled=False)
|
||||
refused = client.post("/nodes/settings", json={"enabled": True}, headers=who["headers"])
|
||||
assert refused.status_code == 403
|
||||
assert refused.json()["error"]["reason"] == "no_consent"
|
||||
set_consent("user", who["uid"], "bandwidth_sharing", True)
|
||||
assert client.post("/nodes/settings", json={"enabled": True}, headers=who["headers"]).status_code == 200
|
||||
|
||||
|
||||
def test_the_assistant_can_switch_off_but_never_on(client):
|
||||
who = make_member(enabled=False)
|
||||
headers = {**who["headers"], "X-Devii-Agent": "1"}
|
||||
refused = client.post("/nodes/settings", json={"enabled": True}, headers=headers)
|
||||
assert refused.status_code == 403
|
||||
save_node_settings(who["uid"], {"enabled": True})
|
||||
off = client.post("/nodes/settings", json={"enabled": False}, headers=headers)
|
||||
assert off.status_code == 200 and off.json()["settings"]["enabled"] is False
|
||||
|
||||
|
||||
def test_stats_are_aggregate_only(client, member):
|
||||
device = client.post("/nodes/devices", json={"label": "x"}, headers=member["headers"]).json()["device"]
|
||||
record_job(device["uid"], member["uid"], ok=True, bytes_up=10, bytes_down=90)
|
||||
body = client.get("/nodes/stats").json()
|
||||
assert set(body) == {"requests", "bytes", "donors"}
|
||||
assert body["requests"] >= 1 and body["bytes"] >= 100 and body["donors"] >= 1
|
||||
assert member["username"] not in str(body)
|
||||
|
||||
|
||||
def test_leaderboard_lists_only_members_who_opted_in(client):
|
||||
hidden = make_member()
|
||||
shown = make_member()
|
||||
for who, public in ((hidden, False), (shown, True)):
|
||||
save_node_settings(who["uid"], {"public_display": public})
|
||||
device = client.post("/nodes/devices", json={"label": "x"}, headers=who["headers"]).json()["device"]
|
||||
record_job(device["uid"], who["uid"], ok=True, bytes_up=1, bytes_down=5)
|
||||
names = [item["username"] for item in client.get("/nodes/leaderboard").json()["donors"]]
|
||||
assert shown["username"] in names and hidden["username"] not in names
|
||||
@@ -0,0 +1,65 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.services.nodes import social as social_module
|
||||
|
||||
HN = json.dumps({"hits": [{"objectID": "9", "title": "A story", "points": 4}]}).encode()
|
||||
|
||||
|
||||
def test_guests_get_401(client):
|
||||
assert client.get("/nodes/social/search?q=x").status_code == 401
|
||||
assert client.post("/nodes/internal/fetch", json={"url": "https://www.reddit.com/"}).status_code == 401
|
||||
|
||||
|
||||
def test_query_is_required(client, member):
|
||||
assert client.get("/nodes/social/search", headers=member["headers"]).status_code == 400
|
||||
|
||||
|
||||
def test_search_returns_hits_and_reasons(client, member, monkeypatch):
|
||||
async def getter(url):
|
||||
return HN
|
||||
|
||||
monkeypatch.setattr(social_module, "direct_get", getter)
|
||||
body = client.get(
|
||||
"/nodes/social/search?q=sqlite&platforms=hackernews,reddit", headers=member["headers"]
|
||||
).json()
|
||||
assert body["hits"][0]["platform"] == "hackernews"
|
||||
assert body["unavailable"] == {"reddit": "nodes_not_used"}
|
||||
assert body["platforms"] == ["hackernews", "reddit"]
|
||||
|
||||
|
||||
def test_search_has_a_daily_quota(client, member, monkeypatch):
|
||||
async def getter(url):
|
||||
return HN
|
||||
|
||||
monkeypatch.setattr(social_module, "direct_get", getter)
|
||||
previous = get_setting("nodes_daily_social_user", "300")
|
||||
set_setting("nodes_daily_social_user", "2")
|
||||
try:
|
||||
codes = [
|
||||
client.get("/nodes/social/search?q=a&platforms=hackernews", headers=member["headers"]).status_code
|
||||
for _ in range(3)
|
||||
]
|
||||
finally:
|
||||
set_setting("nodes_daily_social_user", previous)
|
||||
assert codes == [200, 200, 429]
|
||||
|
||||
|
||||
def test_internal_fetch_refuses_hosts_off_the_allowlist(client, member):
|
||||
response = client.post(
|
||||
"/nodes/internal/fetch", json={"url": "https://example.com/"}, headers=member["headers"]
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["reason"] == "host_not_allowed"
|
||||
|
||||
|
||||
def test_internal_fetch_without_donors_is_503(client, member):
|
||||
response = client.post(
|
||||
"/nodes/internal/fetch",
|
||||
json={"url": "https://www.reddit.com/r/python.json", "cache": False},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error"]["reason"] in ("no_nodes", "kill_switch")
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,70 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from tests.api.sync.conftest import comment_record, post_record, sync_ok, unique, user_record
|
||||
|
||||
|
||||
def seed_thread(client, admin, origin):
|
||||
sync_ok(client, admin, "users", origin, [user_record("u1", unique("cu1")), user_record("u2", unique("cu2"))])
|
||||
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1"), post_record("p2", "u1")])
|
||||
|
||||
|
||||
def test_unknown_references_are_rejected_with_specific_reasons(client, admin, origin):
|
||||
seed_thread(client, admin, origin)
|
||||
body = sync_ok(
|
||||
client, admin, "comments", origin,
|
||||
[
|
||||
comment_record("c1", author="nobody"),
|
||||
comment_record("c2", post="nopost"),
|
||||
comment_record("c3", parent_uid="noparent"),
|
||||
],
|
||||
)
|
||||
assert [r["reason"] for r in body["results"]] == ["unknown_author", "unknown_post", "unknown_parent"]
|
||||
|
||||
|
||||
def test_create_with_nested_reply_in_one_batch(client, admin, origin):
|
||||
seed_thread(client, admin, origin)
|
||||
body = sync_ok(
|
||||
client, admin, "comments", origin,
|
||||
[comment_record("c1", author="u2"), comment_record("c2", author="u1", parent_uid="c1")],
|
||||
)
|
||||
assert [r["status"] for r in body["results"]] == ["created", "created"]
|
||||
parent, reply = (get_table("comments").find_one(uid=r["local_uid"]) for r in body["results"])
|
||||
post = get_table("posts").find_one(uid=reply["post_uid"])
|
||||
assert reply["parent_uid"] == parent["uid"]
|
||||
assert reply["target_type"] == "post" and reply["target_uid"] == post["uid"]
|
||||
assert parent["deleted_at"] is None
|
||||
|
||||
|
||||
def test_parent_on_a_different_post_is_rejected(client, admin, origin):
|
||||
seed_thread(client, admin, origin)
|
||||
sync_ok(client, admin, "comments", origin, [comment_record("c1", post="p1")])
|
||||
body = sync_ok(client, admin, "comments", origin, [comment_record("c2", post="p2", parent_uid="c1")])
|
||||
assert body["results"][0]["reason"] == "parent_mismatch"
|
||||
|
||||
|
||||
def test_edit_replay_and_tombstone(client, admin, origin):
|
||||
seed_thread(client, admin, origin)
|
||||
created = sync_ok(client, admin, "comments", origin, [comment_record("c1")])["results"][0]
|
||||
assert sync_ok(client, admin, "comments", origin, [comment_record("c1")])["results"][0]["status"] == "unchanged"
|
||||
edited = sync_ok(
|
||||
client, admin, "comments", origin,
|
||||
[comment_record("c1", content="edited comment", updated_at="2025-02-01T00:00:00Z")],
|
||||
)["results"][0]
|
||||
assert edited["status"] == "updated"
|
||||
assert get_table("comments").find_one(uid=created["local_uid"])["content"] == "edited comment"
|
||||
sync_ok(
|
||||
client, admin, "comments", origin,
|
||||
[comment_record("c1", content="edited comment", updated_at="2025-03-01T00:00:00Z", deleted=True)],
|
||||
)
|
||||
row = get_table("comments").find_one(uid=created["local_uid"])
|
||||
assert row["deleted_at"] and row["deleted_by"] == "sync"
|
||||
|
||||
|
||||
def test_synced_comment_renders_on_the_post_page(client, admin, origin):
|
||||
seed_thread(client, admin, origin)
|
||||
sync_ok(client, admin, "comments", origin, [comment_record("c1", content="comment visible on the page")])
|
||||
link = get_table("sync_links").find_one(origin=origin, kind="posts", origin_uid="p1")
|
||||
slug = get_table("posts").find_one(uid=link["local_uid"])["slug"]
|
||||
page = client.get(f"/posts/{slug}")
|
||||
assert page.status_code == 200 and "comment visible on the page" in page.text
|
||||
@@ -0,0 +1,98 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from devplacepy.database import get_setting, get_table, set_setting
|
||||
from devplacepy.main import app
|
||||
from devplacepy.utils import register_account
|
||||
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def unique(prefix: str) -> str:
|
||||
_counter[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
|
||||
|
||||
|
||||
def make_account(role: str = "Member") -> dict:
|
||||
name = unique("syncmb")
|
||||
uid, _, _ = register_account(
|
||||
name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True
|
||||
)
|
||||
if role != "Member":
|
||||
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
||||
row = get_table("users").find_one(uid=uid)
|
||||
return {
|
||||
"uid": uid,
|
||||
"username": name,
|
||||
"headers": {"X-API-KEY": row["api_key"], "Accept": "application/json"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
previous = {key: get_setting(key, "") for key in ("sync_enabled", "sync_origins")}
|
||||
set_setting("sync_enabled", "1")
|
||||
set_setting("sync_origins", "")
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
for key, value in previous.items():
|
||||
set_setting(key, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(client):
|
||||
return make_account("Admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def origin():
|
||||
return f"{unique('o')}.example.org"
|
||||
|
||||
|
||||
def sync(client, admin, kind, origin, records, **extra):
|
||||
response = client.post(
|
||||
f"/sync/{kind}",
|
||||
json={"origin": origin, "records": records, **extra},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def sync_ok(client, admin, kind, origin, records, **extra) -> dict:
|
||||
response = sync(client, admin, kind, origin, records, **extra)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def user_record(uid="u1", username="syncuser", **extra) -> dict:
|
||||
return {
|
||||
"origin_uid": uid,
|
||||
"username": username,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def post_record(uid="p1", author="u1", **extra) -> dict:
|
||||
return {
|
||||
"origin_uid": uid,
|
||||
"author_uid": author,
|
||||
"content": "a synced post with enough content",
|
||||
"created_at": "2025-01-02T00:00:00Z",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def comment_record(uid="c1", post="p1", author="u1", **extra) -> dict:
|
||||
return {
|
||||
"origin_uid": uid,
|
||||
"post_uid": post,
|
||||
"author_uid": author,
|
||||
"content": "a synced comment",
|
||||
"created_at": "2025-01-03T00:00:00Z",
|
||||
**extra,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from tests.api.sync.conftest import post_record, sync_ok, unique, user_record
|
||||
|
||||
|
||||
def seed_author(client, admin, origin, uid="u1"):
|
||||
body = sync_ok(client, admin, "users", origin, [user_record(uid, unique("author"))])
|
||||
return body["results"][0]["local_uid"]
|
||||
|
||||
|
||||
def test_unknown_author_is_rejected_with_reason(client, admin, origin):
|
||||
body = sync_ok(client, admin, "posts", origin, [post_record(author="missing")])
|
||||
result = body["results"][0]
|
||||
assert result["status"] == "rejected" and result["reason"] == "unknown_author"
|
||||
assert get_table("sync_links").count(origin=origin) == 0
|
||||
|
||||
|
||||
def test_create_normalizes_and_has_no_side_effects(client, admin, origin):
|
||||
author = seed_author(client, admin, origin)
|
||||
notifications_before = get_table("notifications").count()
|
||||
body = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", title=" Hello World ", content="line one\r\nline two ok", topic="nonsense", stars=50)],
|
||||
)
|
||||
result = body["results"][0]
|
||||
assert result["status"] == "created" and result["ignored"] == ["stars"]
|
||||
row = get_table("posts").find_one(uid=result["local_uid"])
|
||||
assert row["user_uid"] == author
|
||||
assert row["title"] == "Hello World"
|
||||
assert row["content"] == "line one\nline two ok"
|
||||
assert row["topic"] == "random"
|
||||
assert row["stars"] == 0
|
||||
assert row["created_at"] == "2025-01-02T00:00:00+00:00"
|
||||
assert row["slug"].endswith("hello-world")
|
||||
assert row["deleted_at"] is None
|
||||
assert get_table("notifications").count() == notifications_before
|
||||
assert get_table("users").find_one(uid=author)["xp"] == 0
|
||||
|
||||
|
||||
def test_replay_update_stale_and_tombstone(client, admin, origin):
|
||||
seed_author(client, admin, origin)
|
||||
created = sync_ok(client, admin, "posts", origin, [post_record("p1")])["results"][0]
|
||||
assert sync_ok(client, admin, "posts", origin, [post_record("p1")])["results"][0]["status"] == "unchanged"
|
||||
edited = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", content="edited synced content", updated_at="2025-03-01T00:00:00Z")],
|
||||
)["results"][0]
|
||||
assert edited["status"] == "updated" and edited["local_uid"] == created["local_uid"]
|
||||
assert get_table("posts").find_one(uid=created["local_uid"])["content"] == "edited synced content"
|
||||
stale = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", content="older content here", updated_at="2025-01-05T00:00:00Z")],
|
||||
)["results"][0]
|
||||
assert stale["status"] == "stale"
|
||||
deleted = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", content="edited synced content", updated_at="2025-04-01T00:00:00Z", deleted=True)],
|
||||
)["results"][0]
|
||||
assert deleted["status"] == "updated"
|
||||
row = get_table("posts").find_one(uid=created["local_uid"])
|
||||
assert row["deleted_at"] and row["deleted_by"] == "sync"
|
||||
revived = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", content="edited synced content", updated_at="2025-05-01T00:00:00Z")],
|
||||
)["results"][0]
|
||||
assert revived["status"] == "updated"
|
||||
assert get_table("posts").find_one(uid=created["local_uid"])["deleted_at"] is None
|
||||
|
||||
|
||||
def test_locally_moderated_post_is_not_resurrected(client, admin, origin):
|
||||
seed_author(client, admin, origin)
|
||||
created = sync_ok(client, admin, "posts", origin, [post_record("p1")])["results"][0]
|
||||
get_table("posts").update(
|
||||
{"uid": created["local_uid"], "deleted_at": "2025-02-01T00:00:00+00:00", "deleted_by": admin["uid"]},
|
||||
["uid"],
|
||||
)
|
||||
again = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", content="remote edit arrives", updated_at="2025-03-01T00:00:00Z")],
|
||||
)["results"][0]
|
||||
assert again["status"] == "unchanged" and again["reason"] == "locally_deleted"
|
||||
assert get_table("posts").find_one(uid=created["local_uid"])["content"] != "remote edit arrives"
|
||||
|
||||
|
||||
def test_purged_local_copy_is_not_recreated(client, admin, origin):
|
||||
seed_author(client, admin, origin)
|
||||
created = sync_ok(client, admin, "posts", origin, [post_record("p1")])["results"][0]
|
||||
get_table("posts").delete(uid=created["local_uid"])
|
||||
again = sync_ok(
|
||||
client, admin, "posts", origin,
|
||||
[post_record("p1", content="changed after purge", updated_at="2025-03-01T00:00:00Z")],
|
||||
)["results"][0]
|
||||
assert again["status"] == "rejected" and again["reason"] == "purged_locally"
|
||||
assert get_table("posts").count(uid=created["local_uid"]) == 0
|
||||
|
||||
|
||||
def test_invalid_post_reports_field_errors(client, admin, origin):
|
||||
seed_author(client, admin, origin)
|
||||
body = sync_ok(client, admin, "posts", origin, [post_record("p1", content="short")])
|
||||
result = body["results"][0]
|
||||
assert result["status"] == "rejected" and result["reason"] == "invalid"
|
||||
assert any(error.startswith("content:") for error in result["errors"])
|
||||
|
||||
|
||||
def test_future_timestamps_are_clamped(client, admin, origin):
|
||||
seed_author(client, admin, origin)
|
||||
body = sync_ok(client, admin, "posts", origin, [post_record("p1", created_at="2999-01-01T00:00:00Z")])
|
||||
row = get_table("posts").find_one(uid=body["results"][0]["local_uid"])
|
||||
assert row["created_at"] < "2100"
|
||||
|
||||
|
||||
def test_post_shows_up_on_its_detail_page(client, admin, origin):
|
||||
seed_author(client, admin, origin)
|
||||
body = sync_ok(client, admin, "posts", origin, [post_record("p1", title="Visible synced post")])
|
||||
slug = get_table("posts").find_one(uid=body["results"][0]["local_uid"])["slug"]
|
||||
page = client.get(f"/posts/{slug}")
|
||||
assert page.status_code == 200 and "Visible synced post" in page.text
|
||||
@@ -0,0 +1,39 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from tests.api.sync.conftest import sync_ok, unique, user_record
|
||||
|
||||
|
||||
def test_admin_settings_save_normalizes_sync_origins(client, admin):
|
||||
previous = get_setting("sync_origins", "")
|
||||
try:
|
||||
response = client.post(
|
||||
"/admin/settings",
|
||||
json={"sync_origins": "A.example.org, b.example.org\nA.example.org"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
set_setting_cache_free = get_setting("sync_origins", "")
|
||||
assert set_setting_cache_free == "a.example.org,b.example.org"
|
||||
refused = client.post(
|
||||
"/admin/settings", json={"sync_origins": "bad origin!"}, headers=admin["headers"]
|
||||
)
|
||||
assert refused.status_code in (400, 422)
|
||||
finally:
|
||||
set_setting("sync_origins", previous)
|
||||
|
||||
|
||||
def test_admin_settings_page_shows_sync_controls(client, admin):
|
||||
page = client.get("/admin/settings", headers={"X-API-KEY": admin["headers"]["X-API-KEY"]})
|
||||
assert page.status_code == 200
|
||||
assert 'name="sync_enabled"' in page.text and 'name="sync_origins"' in page.text
|
||||
|
||||
|
||||
def test_clearing_sync_origins_removes_the_allowlist(client, admin, origin):
|
||||
set_setting("sync_origins", "only.example.org")
|
||||
try:
|
||||
client.post("/admin/settings", json={"sync_origins": ""}, headers=admin["headers"])
|
||||
assert get_setting("sync_origins", "x") == ""
|
||||
sync_ok(client, admin, "users", origin, [user_record("u1", unique("any"))])
|
||||
finally:
|
||||
set_setting("sync_origins", "")
|
||||
@@ -0,0 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.sync.spec import SPEC
|
||||
|
||||
|
||||
def test_spec_is_public_and_matches_code(client):
|
||||
response = client.get("/sync/spec.json")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["kinds"] == list(SPEC.kinds)
|
||||
assert body["statuses"] == list(SPEC.statuses)
|
||||
assert body["limits"]["max_batch_records"] == SPEC.limits.max_batch_records
|
||||
assert set(body["records"]) == set(SPEC.kinds)
|
||||
assert "username" in body["records"]["users"]["properties"]
|
||||
assert "stars" not in body["records"]["posts"]["properties"]
|
||||
@@ -0,0 +1,173 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table, set_setting
|
||||
from tests.api.sync.conftest import make_account, sync, sync_ok, unique, user_record
|
||||
|
||||
|
||||
def test_requires_authentication(client, origin):
|
||||
response = client.post(
|
||||
"/sync/users", json={"origin": origin, "records": [user_record()]}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_member_is_refused(client, origin):
|
||||
member = make_account()
|
||||
response = sync(client, member, "users", origin, [user_record()])
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_kill_switch_refuses_when_disabled(client, admin, origin):
|
||||
set_setting("sync_enabled", "0")
|
||||
try:
|
||||
response = sync(client, admin, "users", origin, [user_record()])
|
||||
assert response.status_code == 503
|
||||
finally:
|
||||
set_setting("sync_enabled", "1")
|
||||
|
||||
|
||||
def test_origin_allowlist(client, admin, origin):
|
||||
set_setting("sync_origins", "trusted.example.org")
|
||||
try:
|
||||
assert sync(client, admin, "users", origin, [user_record()]).status_code == 403
|
||||
allowed = sync(client, admin, "users", "trusted.example.org", [user_record(unique("a"), unique("allow"))])
|
||||
assert allowed.status_code == 200
|
||||
finally:
|
||||
set_setting("sync_origins", "")
|
||||
|
||||
|
||||
def test_invalid_envelope_is_422(client, admin, origin):
|
||||
assert sync(client, admin, "users", origin, []).status_code == 422
|
||||
assert sync(client, admin, "users", "bad origin!", [user_record()]).status_code == 422
|
||||
|
||||
|
||||
def test_create_then_replay_is_idempotent(client, admin, origin):
|
||||
name = unique("bob")
|
||||
first = sync_ok(client, admin, "users", origin, [user_record("u1", name, bio="hi")])
|
||||
result = first["results"][0]
|
||||
assert result["status"] == "created" and result["local_uid"]
|
||||
again = sync_ok(client, admin, "users", origin, [user_record("u1", name, bio="hi")])
|
||||
assert again["results"][0]["status"] == "unchanged"
|
||||
assert again["results"][0]["local_uid"] == result["local_uid"]
|
||||
assert get_table("users").count(username=name) == 1
|
||||
|
||||
|
||||
def test_update_changes_profile_and_keeps_local_uid(client, admin, origin):
|
||||
name = unique("upd")
|
||||
created = sync_ok(client, admin, "users", origin, [user_record("u1", name)])
|
||||
uid = created["results"][0]["local_uid"]
|
||||
updated = sync_ok(
|
||||
client,
|
||||
admin,
|
||||
"users",
|
||||
origin,
|
||||
[user_record("u1", name, bio="new bio", updated_at="2025-06-01T00:00:00Z")],
|
||||
)
|
||||
assert updated["results"][0]["status"] == "updated"
|
||||
assert get_table("users").find_one(uid=uid)["bio"] == "new bio"
|
||||
|
||||
|
||||
def test_older_update_is_stale(client, admin, origin):
|
||||
name = unique("stale")
|
||||
sync_ok(client, admin, "users", origin, [user_record("u1", name, updated_at="2025-06-01T00:00:00Z")])
|
||||
older = sync_ok(
|
||||
client, admin, "users", origin,
|
||||
[user_record("u1", name, bio="old", updated_at="2025-02-01T00:00:00Z")],
|
||||
)
|
||||
assert older["results"][0]["status"] == "stale"
|
||||
|
||||
|
||||
def test_username_is_normalized_and_collisions_get_a_suffix(client, admin, origin):
|
||||
taken = unique("taken")
|
||||
make_account_row = get_table("users")
|
||||
sync_ok(client, admin, "users", origin, [user_record("u0", taken)])
|
||||
other = sync_ok(client, admin, "users", origin, [user_record("u1", taken)])
|
||||
local = make_account_row.find_one(uid=other["results"][0]["local_uid"])
|
||||
assert local["username"] != taken and local["username"].startswith(taken[:10])
|
||||
odd = sync_ok(client, admin, "users", origin, [user_record("u2", "Ünï cøde!")])
|
||||
created = make_account_row.find_one(uid=odd["results"][0]["local_uid"])
|
||||
assert created["username"].replace("_", "").replace("-", "").isalnum()
|
||||
|
||||
|
||||
def test_counters_credentials_and_role_are_ignored_and_reported(client, admin, origin):
|
||||
body = sync_ok(
|
||||
client, admin, "users", origin,
|
||||
[user_record("u1", unique("priv"), xp=999, level=50, stars=7, role="Admin", email="x@y.z", password_hash="p")],
|
||||
)
|
||||
result = body["results"][0]
|
||||
assert set(result["ignored"]) == {"xp", "level", "stars", "role", "email", "password_hash"}
|
||||
row = get_table("users").find_one(uid=result["local_uid"])
|
||||
assert row["role"] == "Member" and row["xp"] == 0 and row["level"] == 1
|
||||
assert row["email"].endswith("@sync.invalid")
|
||||
|
||||
|
||||
def test_synced_account_cannot_log_in(client, admin, origin):
|
||||
body = sync_ok(client, admin, "users", origin, [user_record("u1", unique("nologin"))])
|
||||
row = get_table("users").find_one(uid=body["results"][0]["local_uid"])
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={"email": row["email"], "password": "anything1"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(client, admin, origin):
|
||||
name = unique("dry")
|
||||
body = sync_ok(client, admin, "users", origin, [user_record("u1", name)], dry_run=True)
|
||||
assert body["dry_run"] is True and body["results"][0]["status"] == "created"
|
||||
assert get_table("users").count(username=name) == 0
|
||||
assert get_table("sync_links").count(origin=origin) == 0
|
||||
|
||||
|
||||
def test_one_bad_record_does_not_fail_the_batch(client, admin, origin):
|
||||
body = sync_ok(
|
||||
client, admin, "users", origin,
|
||||
[user_record("u1", unique("good")), {"origin_uid": "", "username": "x"}, user_record("u3", unique("good"), created_at="nonsense")],
|
||||
)
|
||||
assert [r["status"] for r in body["results"]] == ["created", "rejected", "rejected"]
|
||||
assert body["counts"]["created"] == 1 and body["counts"]["rejected"] == 2
|
||||
assert all(r["errors"] for r in body["results"][1:])
|
||||
|
||||
|
||||
def test_deleted_flag_deactivates_account(client, admin, origin):
|
||||
name = unique("gone")
|
||||
created = sync_ok(client, admin, "users", origin, [user_record("u1", name)])
|
||||
sync_ok(client, admin, "users", origin, [user_record("u1", name, deleted=True, updated_at="2025-05-01T00:00:00Z")])
|
||||
assert not get_table("users").find_one(uid=created["results"][0]["local_uid"])["is_active"]
|
||||
|
||||
|
||||
def test_tombstone_for_unknown_record_creates_nothing(client, admin, origin):
|
||||
body = sync_ok(client, admin, "users", origin, [user_record("u1", unique("ghost"), deleted=True)])
|
||||
assert body["results"][0]["status"] == "unchanged"
|
||||
assert body["results"][0]["reason"] == "tombstone_for_unknown"
|
||||
assert get_table("sync_links").count(origin=origin) == 0
|
||||
|
||||
|
||||
def test_same_origin_uid_in_different_origins_are_distinct(client, admin, origin):
|
||||
first = sync_ok(client, admin, "users", origin, [user_record("u1", unique("one"))])
|
||||
second = sync_ok(client, admin, "users", f"b.{origin}", [user_record("u1", unique("two"))])
|
||||
assert first["results"][0]["local_uid"] != second["results"][0]["local_uid"]
|
||||
|
||||
|
||||
def test_form_encoded_body_with_json_records_is_accepted(client, admin, origin):
|
||||
import json
|
||||
|
||||
name = unique("form")
|
||||
response = client.post(
|
||||
"/sync/users",
|
||||
data={"origin": origin, "records": json.dumps([user_record("u1", name)]), "dry_run": "true"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["results"][0]["status"] == "created"
|
||||
assert get_table("users").count(username=name) == 0
|
||||
|
||||
|
||||
def test_malformed_records_string_is_422(client, admin, origin):
|
||||
response = client.post(
|
||||
"/sync/users",
|
||||
data={"origin": origin, "records": "not json"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert response.status_code in (400, 422)
|
||||
Reference in New Issue
Block a user