# retoor from datetime import datetime, timedelta, timezone import pytest from devplacepy.database import get_table, set_setting from devplacepy.utils import generate_uid, register_account from tests.api.sync.conftest import ( comment_record, post_record, sync, sync_ok, unique, user_record, ) def export(client, admin, kind, **params): return client.get(f"/sync/export/{kind}", params=params, headers=admin["headers"]) def pull_all(client, admin, kind, since=None): records, cursor, pages = [], "", 0 while True: params = {"cursor": cursor, "limit": 50} if since: params["since"] = since response = export(client, admin, kind, **params) assert response.status_code == 200, response.text body = response.json() records.extend(body["records"]) pages += 1 cursor = body["next_cursor"] if not body["has_more"]: return records, pages def native_user(prefix="expu"): name = unique(prefix) uid, _, _ = register_account(name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True) return uid, name def native_post(user_uid, content="native post content here", stamp=None, **extra): stamp = stamp or datetime.now(timezone.utc).isoformat() uid = generate_uid() get_table("posts").insert( { "uid": uid, "user_uid": user_uid, "slug": f"s-{uid[-12:]}", "title": "T", "content": content, "topic": "random", "project_uid": None, "image": None, "stars": 0, "created_at": stamp, "updated_at": stamp, "deleted_at": None, "deleted_by": None, **extra, } ) return uid def test_authz_and_kill_switch(client, admin): assert client.get("/sync/export/posts").status_code == 401 member = {"headers": {**admin["headers"]}} from tests.api.sync.conftest import make_account plain = make_account("Member") assert export(client, plain, "posts").status_code == 403 set_setting("sync_enabled", "0") try: assert export(client, admin, "posts").status_code == 503 finally: set_setting("sync_enabled", "1") assert member def test_unknown_and_private_kinds_are_not_exportable(client, admin): for kind in ("votes", "reactions", "poll_votes", "nope"): response = export(client, admin, kind) assert response.status_code == 404 and response.json()["error"]["reason"] == "not_exportable" def test_a_post_exports_in_the_inbound_shape_and_no_private_user_field_leaks(client, admin): uid, name = native_user() post = native_post(uid, "exported post content here") users, _ = pull_all(client, admin, "users") row = next(item for item in users if item["origin_uid"] == uid) assert row["username"] == name and row["deleted"] is False assert not set(row) & {"email", "password_hash", "api_key", "role", "xp", "level"} posts, _ = pull_all(client, admin, "posts") found = next(item for item in posts if item["origin_uid"] == post) assert found["author_uid"] == uid and found["content"] == "exported post content here" from devplacepy.services.sync.records import PostRecord assert PostRecord.model_validate(found).origin_uid == post def test_a_soft_deleted_post_is_a_tombstone_and_only_after_the_since_cursor(client, admin): uid, _ = native_user() post = native_post(uid) before = datetime.now(timezone.utc).isoformat() get_table("posts").update({"uid": post, "deleted_at": datetime.now(timezone.utc).isoformat(), "deleted_by": uid}, ["uid"]) posts, _ = pull_all(client, admin, "posts", since=before) mine = [item for item in posts if item["origin_uid"] == post] assert len(mine) == 1 and mine[0]["deleted"] is True def test_pagination_is_gapless_and_repeat_free(client, admin): uid, _ = native_user() base = datetime.now(timezone.utc) - timedelta(days=1) made = {native_post(uid, f"paged post number {index}", (base + timedelta(seconds=index // 3)).isoformat()) for index in range(120)} records, pages = pull_all(client, admin, "posts") ids = [item["origin_uid"] for item in records] assert len(ids) == len(set(ids)) and made <= set(ids) and pages >= 3 def test_rows_that_arrived_by_sync_are_never_echoed(client, admin, origin): sync_ok(client, admin, "users", origin, [user_record("u1", unique("echo"))]) sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", content="synced in, must not come back out")]) sync_ok(client, admin, "comments", origin, [comment_record("c1", "p1", "u1")]) posts, _ = pull_all(client, admin, "posts") assert not any(item["content"] == "synced in, must not come back out" for item in posts) local_post = get_table("sync_links").find_one(origin=origin, kind="posts", origin_uid="p1")["local_uid"] comments, _ = pull_all(client, admin, "comments") assert not any(item["post_uid"] == local_post for item in comments) def test_private_projects_are_left_out_and_post_links_to_them_are_cut(client, admin): uid, _ = native_user() public, private = generate_uid(), generate_uid() now = datetime.now(timezone.utc).isoformat() for project, is_private in ((public, 0), (private, 1)): get_table("projects").insert( {"uid": project, "user_uid": uid, "slug": f"p-{project[-10:]}", "title": "P", "description": "d", "project_type": "software", "status": "x", "platforms": "", "website_url": "", "repo_url": "", "is_private": is_private, "read_only": 0, "stars": 0, "created_at": now, "updated_at": now, "deleted_at": None, "deleted_by": None} ) linked = native_post(uid, "post that points at a private project", project_uid=private) open_post = native_post(uid, "post that points at a public project", project_uid=public) projects, _ = pull_all(client, admin, "projects") ids = {item["origin_uid"] for item in projects} assert public in ids and private not in ids posts, _ = pull_all(client, admin, "posts") by_id = {item["origin_uid"]: item for item in posts} assert by_id[linked]["project_uid"] is None and by_id[open_post]["project_uid"] == public def test_polls_and_follows_and_attachments_export(client, admin): alice, _ = native_user() bob, _ = native_user() post = native_post(alice, "post that carries a poll for export") now = datetime.now(timezone.utc).isoformat() poll = generate_uid() get_table("polls").insert({"uid": poll, "post_uid": post, "user_uid": alice, "question": "Q?", "created_at": now, "deleted_at": None, "deleted_by": None}) first, second = generate_uid(), generate_uid() get_table("poll_options").insert_many([ {"uid": first, "poll_uid": poll, "label": "yes", "position": 0, "deleted_at": None, "deleted_by": None}, {"uid": second, "poll_uid": poll, "label": "no", "position": 1, "deleted_at": None, "deleted_by": None}, ]) follow = generate_uid() get_table("follows").insert({"uid": follow, "follower_uid": bob, "following_uid": alice, "created_at": now, "deleted_at": None, "deleted_by": None}) attachment = generate_uid() get_table("attachments").insert({"uid": attachment, "target_type": "post", "target_uid": post, "user_uid": alice, "original_filename": "a.png", "stored_name": f"{attachment}.png", "directory": "ab/cd", "file_size": 10, "mime_type": "image/png", "created_at": now, "deleted_at": None}) polls, _ = pull_all(client, admin, "polls") exported = next(item for item in polls if item["origin_uid"] == poll) assert [option["label"] for option in exported["options"]] == ["yes", "no"] from devplacepy.services.sync.records import PollRecord assert PollRecord.model_validate(exported).question == "Q?" follows, _ = pull_all(client, admin, "follows") assert any(item["origin_uid"] == follow and item["follower_uid"] == bob for item in follows) attachments, _ = pull_all(client, admin, "attachments") found = next(item for item in attachments if item["origin_uid"] == attachment) assert found["url"] == f"/static/uploads/attachments/ab/cd/{attachment}.png" and "content_b64" not in found def test_bad_cursor_since_and_limit(client, admin): assert export(client, admin, "posts", cursor="!!!").status_code == 400 assert export(client, admin, "posts", since="yesterday-ish").status_code == 400 body = export(client, admin, "posts", limit=1000).json() assert body["count"] <= 50 def test_the_changed_expression_uses_an_index(local_db): from devplacepy.database import db plan = " ".join( str(row) for row in db.query( "EXPLAIN QUERY PLAN SELECT * FROM posts t WHERE COALESCE(deleted_at, updated_at, created_at) > '2020' " "ORDER BY COALESCE(deleted_at, updated_at, created_at), uid LIMIT 51" ) ) assert "idx_posts_changed" in plan and "TEMP B-TREE" not in plan def test_every_export_is_audited(client, admin): pull_all(client, admin, "follows") rows = list(get_table("audit_log").find(event_key="sync.export", actor_uid=admin["uid"])) assert rows @pytest.mark.parametrize("kind", ["users", "projects", "posts", "comments", "polls", "follows", "attachments"]) def test_every_exportable_kind_answers(client, admin, kind): body = export(client, admin, kind).json() assert body["kind"] == kind and isinstance(body["records"], list)