"""FlowDeck — v7.3.0 Teamspaces + Verified pages + collab polish. Covers migration 29, teamspace CRUD + private 404 + members/roles, verified badge with expiry + notification sweep, wiki home, follows, comment reactions, guest shares (expiry/revocation) and page views, plus the mermaid/equation/progress block renderers. """ from __future__ import annotations import datetime import secrets from conftest import anon from app.db import get_conn # ── helpers ──────────────────────────────────────────────────────────────── def _user(login=None, is_admin=0): login = login or f"v73_{secrets.token_hex(4)}" with get_conn() as conn: conn.execute("INSERT INTO users (login, full_name, email, is_admin) VALUES (?,?,?,?)", (login, login, f"{login}@test.com", is_admin)) uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] conn.commit() from app.auth.session import SessionManager return uid, login, {"flowdeck_session": SessionManager.create_session( {"id": uid, "login": login})} def _workspace(owner_id, name="WS"): with get_conn() as conn: cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, owner_id)) conn.commit() return cur.lastrowid def _member(ws_id, uid, role="editor"): with get_conn() as conn: conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)" " VALUES (?,?,?)", (ws_id, uid, role)) conn.commit() def _page(ws_id, title="Doc", teamspace_id=None, owner_id=None, content="hello"): with get_conn() as conn: cur = conn.execute("INSERT INTO pages (workspace, title, workspace_id, content," " content_format, teamspace_id) VALUES ('',?,?,?,'blocks',?)", (title, ws_id, content, teamspace_id)) conn.commit() return cur.lastrowid def _collection(ws_id, name="Tasks"): """``ws_id=None`` creates a throwaway workspace (blocks tests need only the FK).""" if not ws_id: ws_id = _workspace(_user()[0], f"blkw{secrets.token_hex(3)}") with get_conn() as conn: cur = conn.execute("INSERT INTO collections (name, workspace_id) VALUES (?,?)", (name, ws_id)) conn.commit() return cur.lastrowid def _add_rows(cid, values_by_pid): import json with get_conn() as conn: for vals in values_by_pid: conn.execute("INSERT INTO collection_pages (collection_id, title, position," " property_values_json) VALUES (?,?,0,?)", (cid, f"r{secrets.token_hex(2)}", json.dumps(vals))) conn.commit() # ── migration 29 ─────────────────────────────────────────────────────────── def test_migration_29_tables_exist(client): with get_conn() as conn: names = {r["name"] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} for t in ("teamspaces", "teamspace_members", "page_verifications", "comment_reactions", "page_follows", "guest_shares", "page_views"): assert t in names, f"missing {t}" def test_migration_29_teamspace_columns(client): with get_conn() as conn: for table in ("pages", "collections"): cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} assert "teamspace_id" in cols, table # ── teamspaces ───────────────────────────────────────────────────────────── def test_teamspace_create_and_list(client): uid, login, c = _user() wid = _workspace(uid) r = client.post("/api/v2/wiki/teamspaces", cookies=c, json={"workspace_id": wid, "name": "Engineering"}) assert r.status_code == 201, r.text assert r.json()["name"] == "Engineering" lst = client.get(f"/api/v2/wiki/teamspaces?workspace_id={wid}", cookies=c) assert lst.status_code == 200 spaces = lst.json()["teamspaces"] assert len(spaces) == 1 assert spaces[0]["role"] == "owner" def test_teamspace_creator_is_owner(client): from app.services.wiki import get_teamspace_role uid, _, _ = _user() wid = _workspace(uid) with get_conn() as conn: cur = conn.execute("INSERT INTO teamspaces (workspace_id, name, created_by)" " VALUES (?,?,?)", (wid, "Docs", uid)) conn.commit() assert get_teamspace_role(uid, cur.lastrowid) == "owner" def test_teamspace_duplicate_name_conflict(client): uid, _, c = _user() wid = _workspace(uid) assert client.post("/api/v2/wiki/teamspaces", cookies=c, json={"workspace_id": wid, "name": "Dup"}).status_code == 201 assert client.post("/api/v2/wiki/teamspaces", cookies=c, json={"workspace_id": wid, "name": "Dup"}).status_code == 409 def test_teamspace_requires_name(client): uid, _, c = _user() wid = _workspace(uid) assert client.post("/api/v2/wiki/teamspaces", cookies=c, json={"workspace_id": wid, "name": " "}).status_code == 400 def test_teamspace_requires_auth(client): anon(client) assert client.get("/api/v2/wiki/teamspaces?workspace_id=1").status_code == 401 assert client.post("/api/v2/wiki/teamspaces", json={}).status_code == 401 def test_teamspace_private_is_404_for_outsider(client): owner, _, oc = _user() other, _, _ = _user() wid = _workspace(owner) _member(wid, owner, "admin") tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "Secret", "private": True}).json()["id"] # owner sees it assert client.get(f"/api/v2/wiki/teamspaces/{tsid}", cookies=oc).status_code == 200 # non-member of a private teamspace gets 404 (not 403) with get_conn() as conn: conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role)" " VALUES (?,?,'viewer')", (wid, other)) conn.commit() _, _, otherc = _user() r = client.get(f"/api/v2/wiki/teamspaces/{tsid}", cookies=otherc) assert r.status_code == 404 def test_teamspace_public_visible_to_workspace_members(client): owner, _, oc = _user() wid = _workspace(owner) _member(wid, owner, "admin") tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "Open"}).json()["id"] _member(wid, 999999 - 999999 + 0 or 0) if False else None other_uid, _, otherc = _user() _member(wid, other_uid, "viewer") assert client.get(f"/api/v2/wiki/teamspaces/{tsid}", cookies=otherc).status_code == 200 lst = client.get(f"/api/v2/wiki/teamspaces?workspace_id={wid}", cookies=otherc) assert [t["name"] for t in lst.json()["teamspaces"]] == ["Open"] def test_teamspace_list_empty_for_non_member(client): owner, _, _ = _user() wid = _workspace(owner) _, _, c = _user() assert client.get(f"/api/v2/wiki/teamspaces?workspace_id={wid}", cookies=c).json()[ "teamspaces"] == [] def test_teamspace_members_add_update_remove(client): owner, _, oc = _user() member, _, _ = _user() wid = _workspace(owner) tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "Team"}).json()["id"] r = client.put(f"/api/v2/wiki/teamspaces/{tsid}/members/{member}", cookies=oc, json={"role": "viewer"}) assert r.status_code == 200, r.text assert r.json()["role"] == "viewer" lst = client.get(f"/api/v2/wiki/teamspaces/{tsid}/members", cookies=oc) assert {m["user_id"] for m in lst.json()["members"]} == {owner, member} # update assert client.put(f"/api/v2/wiki/teamspaces/{tsid}/members/{member}", cookies=oc, json={"role": "editor"}).json()["role"] == "editor" # remove assert client.delete(f"/api/v2/wiki/teamspaces/{tsid}/members/{member}", cookies=oc).status_code == 200 assert client.delete(f"/api/v2/wiki/teamspaces/{tsid}/members/{member}", cookies=oc).status_code == 404 def test_teamspace_member_bad_role(client): owner, _, oc = _user() member, _, _ = _user() wid = _workspace(owner) tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "T"}).json()["id"] assert client.put(f"/api/v2/wiki/teamspaces/{tsid}/members/{member}", cookies=oc, json={"role": "godmode"}).status_code == 400 def test_teamspace_member_unknown_user(client): owner, _, oc = _user() wid = _workspace(owner) tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "T"}).json()["id"] assert client.put(f"/api/v2/wiki/teamspaces/{tsid}/members/999999", cookies=oc, json={"role": "editor"}).status_code == 404 def test_teamspace_role_helpers(client): from app.services import wiki owner, _, _ = _user() member, _, _ = _user() wid = _workspace(owner) _member(wid, member, "viewer") with get_conn() as conn: cur = conn.execute("INSERT INTO teamspaces (workspace_id, name, private, created_by)" " VALUES (?,?,0,?)", (wid, "Pub", owner)) conn.commit() tsid = cur.lastrowid assert wiki.can_read_teamspace(member, tsid) is True assert wiki.can_write_teamspace(member, tsid) is False assert wiki.can_write_teamspace(owner, tsid) is True assert wiki.get_teamspace_role(999999, tsid) is None assert wiki.get_teamspace_role(None, tsid) is None # ── verified pages ───────────────────────────────────────────────────────── def test_verify_page_badge(client): uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) r = client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=c, json={"note": "reviewed"}) assert r.status_code == 200, r.text v = r.json()["verification"] assert v["page_id"] == pid assert v["active"] is True assert v["note"] == "reviewed" got = client.get(f"/api/v2/wiki/pages/{pid}/verification", cookies=c) assert got.json()["verification"]["verified_by"] == uid def test_verify_page_requires_editor(client): owner, _, _ = _user() wid = _workspace(owner) _member(wid, owner, "admin") pid = _page(wid) viewer, _, vc = _user() _member(wid, viewer, "viewer") assert client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=vc).status_code == 403 def test_verify_page_admin_override(client): admin, _, ac = _user(is_admin=1) owner, _, _ = _user() wid = _workspace(owner) _member(wid, owner, "admin") pid = _page(wid) assert client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=ac).status_code == 200 def test_verify_page_in_private_teamspace(client): owner, _, oc = _user() wid = _workspace(owner) _member(wid, owner, "admin") tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "Priv", "private": True}).json()["id"] pid = _page(wid, "Secret", teamspace_id=tsid) # owner is teamspace owner → can verify assert client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=oc).status_code == 200 def test_verify_page_unknown(client): uid, _, c = _user() assert client.post("/api/v2/wiki/pages/999999/verify", cookies=c).status_code == 404 def test_unverify_page(client): uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=c) assert client.delete(f"/api/v2/wiki/pages/{pid}/verify", cookies=c).status_code == 200 assert client.delete(f"/api/v2/wiki/pages/{pid}/verify", cookies=c).status_code == 404 assert client.get(f"/api/v2/wiki/pages/{pid}/verification", cookies=c).json()["verification"] is None def test_verified_list_excludes_expired(client): uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") fresh = _page(wid, "Fresh") stale = _page(wid, "Stale") client.post(f"/api/v2/wiki/pages/{fresh}/verify", cookies=c, json={"days": 30}) client.post(f"/api/v2/wiki/pages/{stale}/verify", cookies=c, json={"days": 30}) past = (datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1)).replace(microsecond=0).isoformat() with get_conn() as conn: conn.execute("UPDATE page_verifications SET expires_at=? WHERE page_id=?", (past, stale)) conn.commit() r = client.get(f"/api/v2/wiki/verified?workspace_id={wid}", cookies=c) titles = [p["title"] for p in r.json()["pages"]] assert "Fresh" in titles assert "Stale" not in titles def test_verify_days_clamped(client): from app.services.wiki import verify_page uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) # 0 and 9999 are both clamped into 1..365 assert verify_page(pid, uid, days=9999)["expires_at"] is not None assert verify_page(pid, uid, days=0)["expires_at"] is not None with get_conn() as conn: assert conn.execute("SELECT COUNT(*) AS n FROM page_verifications" " WHERE page_id=?", (pid,)).fetchone()["n"] == 1 def test_verify_replaces_previous_verification(client): from app.services.wiki import verification, verify_page uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) first = verify_page(pid, uid, days=10, note="first") second = verify_page(pid, uid, days=20, note="second") assert first["note"] == "first" assert verification(pid)["note"] == "second" assert second["expires_at"] != first["expires_at"] def test_verified_list_hides_private_teamspace(client): owner, _, oc = _user() wid = _workspace(owner) _member(wid, owner, "admin") tsid = client.post("/api/v2/wiki/teamspaces", cookies=oc, json={"workspace_id": wid, "name": "P", "private": True}).json()["id"] pid = _page(wid, "Hidden", teamspace_id=tsid) client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=oc) other, _, oc2 = _user() _member(wid, other, "viewer") r = client.get(f"/api/v2/wiki/verified?workspace_id={wid}", cookies=oc2) assert "Hidden" not in [p["title"] for p in r.json()["pages"]] def test_expiry_sweep_notifies(client): admin, _, ac = _user(is_admin=1) owner, _, _ = _user() wid = _workspace(owner) _member(wid, owner, "admin") pid = _page(wid, "About to expire") client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=ac, json={"days": 1}) soon = (datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=3)).replace(microsecond=0).isoformat() with get_conn() as conn: conn.execute("UPDATE page_verifications SET expires_at=? WHERE page_id=?", (soon, pid)) conn.commit() r = client.post("/api/v2/wiki/verify-expiry-sweep", cookies=ac) assert r.status_code == 200, r.text assert r.json()["notified"] == 1 with get_conn() as conn: row = conn.execute("SELECT ntype FROM notifications WHERE user_id=? AND" " resource_id=?", (admin, pid)).fetchone() assert row and row["ntype"] == "page.verification_expiring" def test_expiry_sweep_requires_admin(client): uid, _, c = _user() assert client.post("/api/v2/wiki/verify-expiry-sweep", cookies=c).status_code == 403 # ── follows ──────────────────────────────────────────────────────────────── def test_toggle_follow(client): from app.services.wiki import is_following uid, _, c = _user() wid = _workspace(uid) pid = _page(wid) r = client.post(f"/api/v2/wiki/pages/{pid}/follow", cookies=c) assert r.json()["following"] is True assert is_following(pid, uid) is True r = client.post(f"/api/v2/wiki/pages/{pid}/follow", cookies=c) assert r.json()["following"] is False assert is_following(pid, uid) is False def test_list_followers(client): a, _, ac = _user() b, _, bc = _user() wid = _workspace(a) pid = _page(wid) client.post(f"/api/v2/wiki/pages/{pid}/follow", cookies=ac) client.post(f"/api/v2/wiki/pages/{pid}/follow", cookies=bc) r = client.get(f"/api/v2/wiki/pages/{pid}/followers", cookies=ac) assert {f["id"] for f in r.json()["followers"]} == {a, b} def test_follow_unknown_page(client): _, _, c = _user() assert client.post("/api/v2/wiki/pages/999999/follow", cookies=c).status_code == 404 # ── comment reactions ────────────────────────────────────────────────────── def _comment(page_id, uid, body="hi"): with get_conn() as conn: cur = conn.execute("INSERT INTO comments (page_id, user_id, body) VALUES (?,?,?)", (page_id, uid, body)) conn.commit() return cur.lastrowid def test_comment_reaction_toggle(client): uid, _, c = _user() wid = _workspace(uid) pid = _page(wid) cid = _comment(pid, uid) r = client.post(f"/api/v2/wiki/comments/{cid}/reactions", cookies=c, json={"emoji": "👍"}) assert r.status_code == 200, r.text assert r.json()["reactions"]["👍"]["count"] == 1 # same emoji again removes it r = client.post(f"/api/v2/wiki/comments/{cid}/reactions", cookies=c, json={"emoji": "👍"}) assert r.json()["reactions"] == {} def test_comment_reaction_multiple_users(client): a, _, ac = _user() b, _, bc = _user() wid = _workspace(a) pid = _page(wid) cid = _comment(pid, a) client.post(f"/api/v2/wiki/comments/{cid}/reactions", cookies=ac, json={"emoji": "🎉"}) client.post(f"/api/v2/wiki/comments/{cid}/reactions", cookies=bc, json={"emoji": "🎉"}) got = client.get(f"/api/v2/wiki/comments/{cid}/reactions", cookies=ac) data = got.json()["reactions"] assert data["🎉"]["count"] == 2 assert set(data["🎉"]["users"]) == {a, b} def test_comment_reaction_requires_emoji(client): uid, _, c = _user() wid = _workspace(uid) pid = _page(wid) cid = _comment(pid, uid) assert client.post(f"/api/v2/wiki/comments/{cid}/reactions", cookies=c, json={}).status_code == 400 def test_comment_reaction_unknown_comment(client): uid, _, c = _user() assert client.post("/api/v2/wiki/comments/999999/reactions", cookies=c, json={"emoji": "👍"}).status_code == 404 # ── guest shares ─────────────────────────────────────────────────────────── def test_guest_share_flow(client): uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid, "Shared doc", content="the body") r = client.post(f"/api/v2/wiki/pages/{pid}/guests", cookies=c, json={"email": "ext@partner.example", "role": "viewer"}) assert r.status_code == 201, r.text body = r.json() assert body["url"] == f"/g/{body['token']}" # the guest page is reachable with no session at all guest = client.get(f"/g/{body['token']}") assert guest.status_code == 200 assert "Shared doc" in guest.text assert "the body" in guest.text # a view was recorded with get_conn() as conn: assert conn.execute("SELECT views FROM page_views WHERE page_id=?", (pid,)).fetchone()[ "views"] == 1 def test_guest_share_unknown_token(client): assert client.get("/g/g_totally_bogus").status_code == 404 def test_guest_share_expired(client): from app.services.wiki import create_guest_share, resolve_guest_share uid, _, c = _user() wid = _workspace(uid) pid = _page(wid) share = create_guest_share(pid, "a@b.example", "viewer", uid, days=30) assert resolve_guest_share(share["token"]) is not None past = (datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1)).replace(microsecond=0).isoformat() with get_conn() as conn: conn.execute("UPDATE guest_shares SET expires_at=? WHERE id=?", (past, share["id"])) conn.commit() assert resolve_guest_share(share["token"]) is None assert client.get(f"/g/{share['token']}").status_code == 404 def test_guest_share_revoked(client): from app.services.wiki import resolve_guest_share uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) created = client.post(f"/api/v2/wiki/pages/{pid}/guests", cookies=c, json={"email": "x@y.example"}).json() share_id = created["id"] assert resolve_guest_share(created["token"]) is not None assert client.delete(f"/api/v2/wiki/guests/{share_id}", cookies=c).status_code == 200 # revocation is idempotent, and the token stops working assert client.delete(f"/api/v2/wiki/guests/{share_id}", cookies=c).status_code == 200 assert resolve_guest_share(created["token"]) is None assert client.get(f"/g/{created['token']}").status_code == 404 lst = client.get(f"/api/v2/wiki/pages/{pid}/guests", cookies=c) assert lst.json()["guests"][0]["revoked"] == 1 # unknown id → 404 assert client.delete("/api/v2/wiki/guests/999999", cookies=c).status_code == 404 def test_guest_share_bad_role(client): uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) assert client.post(f"/api/v2/wiki/pages/{pid}/guests", cookies=c, json={"email": "x@y.example", "role": "admin"}).status_code == 400 def test_guest_share_requires_session(client): anon(client) assert client.post("/api/v2/wiki/pages/1/guests", json={}).status_code == 401 # ── page views ───────────────────────────────────────────────────────────── def test_page_view_recording_and_stats(client): from app.services.wiki import record_view, view_stats uid, _, _ = _user() wid = _workspace(uid) pid = _page(wid) today = datetime.datetime.now(datetime.UTC) d0 = today.strftime("%Y-%m-%d") d1 = (today - datetime.timedelta(days=1)).strftime("%Y-%m-%d") d2 = (today - datetime.timedelta(days=2)).strftime("%Y-%m-%d") assert record_view(pid, day=d0) == 1 assert record_view(pid, day=d0) == 2 assert record_view(pid, day=d1) == 1 stats = view_stats(pid, days=5) assert stats["total"] == 3 assert len(stats["series"]) == 5 day_map = {d["day"]: d["views"] for d in stats["series"]} assert day_map[d0] == 2 assert day_map[d1] == 1 assert day_map[d2] == 0 # gap filled # outside the window assert view_stats(pid, days=1)["total"] == 2 def test_page_view_stats_outside_window(client): from app.services.wiki import record_view, view_stats uid, _, _ = _user() wid = _workspace(uid) pid = _page(wid) record_view(pid, day="2020-01-01") assert view_stats(pid, days=30)["total"] == 0 assert view_stats(pid, days=30)["total"] == 0 def test_page_views_endpoint(client): from app.services.wiki import record_view uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") pid = _page(wid) record_view(pid) r = client.get(f"/api/v2/wiki/pages/{pid}/views?days=7", cookies=c) assert r.status_code == 200, r.text assert r.json()["total"] == 1 # ── wiki home ────────────────────────────────────────────────────────────── def test_wiki_home(client): uid, _, c = _user() wid = _workspace(uid) _member(wid, uid, "admin") client.post("/api/v2/wiki/teamspaces", cookies=c, json={"workspace_id": wid, "name": "Eng"}) pid = _page(wid, "Runbook") client.post(f"/api/v2/wiki/pages/{pid}/verify", cookies=c) r = client.get(f"/api/v2/wiki/home?workspace_id={wid}", cookies=c) assert r.status_code == 200, r.text body = r.json() assert body["workspace_id"] == wid assert [t["name"] for t in body["teamspaces"]] == ["Eng"] assert body["verified"][0]["page_id"] == pid assert any(p["title"] == "Runbook" for p in body["recents"]) def test_wiki_home_requires_workspace(client): _, _, c = _user() assert client.get("/api/v2/wiki/home", cookies=c).status_code == 400 assert client.get("/api/v2/wiki/home?workspace_id=abc", cookies=c).status_code == 400 assert client.get("/api/v2/wiki/home?workspace_id=999999", cookies=c).status_code == 404 # ── blocks: mermaid / equation / progress ────────────────────────────────── def test_mermaid_fallback_without_mmdc(monkeypatch): from app.services import wiki_blocks monkeypatch.setattr(wiki_blocks, "mmdc_available", lambda: False) out = wiki_blocks.render_mermaid("graph TD; A-->B;") assert 'class="mermaid"' in out assert "graph TD" in out def test_mermaid_uses_svg_when_available(monkeypatch): from app.services import wiki_blocks monkeypatch.setattr(wiki_blocks, "mmdc_available", lambda: True) monkeypatch.setattr(wiki_blocks, "mermaid_to_svg", lambda src, timeout=20: "") out = wiki_blocks.render_mermaid("graph TD; A-->B;") assert out.startswith("" in out def test_mermaid_escapes_source(monkeypatch): from app.services import wiki_blocks monkeypatch.setattr(wiki_blocks, "mmdc_available", lambda: False) out = wiki_blocks.render_mermaid("") assert "