- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée - A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé - A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401 - A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections) - A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace) - A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent - tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints."""
|
|
import time
|
|
|
|
from conftest import anon
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _token(user_id, login):
|
|
from app.auth.session import SessionManager
|
|
return SessionManager.create_session({"id": user_id, "login": login,
|
|
"full_name": login.title(), "is_admin": 0})
|
|
|
|
|
|
def _user_ws(client, login="syncer"):
|
|
"""Create user + workspace, return (session_cookie, user_id, workspace_id)."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)",
|
|
(login, login.title(), f"{login}@test.co"),
|
|
)
|
|
uid = cur.lastrowid
|
|
ws = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("Sync WS", uid)
|
|
)
|
|
conn.commit()
|
|
return _token(uid, login), uid, ws.lastrowid
|
|
|
|
|
|
def _insert_page(ws_id, title="P", content="[]", content_format="blocks"):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
|
|
"VALUES ('', ?, ?, ?, ?, 'Private')",
|
|
(ws_id, title, content, content_format),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
def _page_version(pid):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
|
|
return row["sync_version"]
|
|
|
|
|
|
def _page_updated_epoch(pid) -> float:
|
|
"""Epoch (float) of the page's stored updated_at (second granularity)."""
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT strftime('%s', updated_at) AS s FROM pages WHERE id=?",
|
|
(pid,)).fetchone()
|
|
return float(row["s"])
|
|
|
|
|
|
# ── authentication & routing ───────────────────────────────────────────────
|
|
|
|
|
|
def test_sync_requires_auth(client):
|
|
anon(client)
|
|
assert client.get("/api/v2/sync/status").status_code == 401
|
|
assert client.post("/api/v2/sync/batch", json={"mutations": []}).status_code == 401
|
|
assert client.get("/api/v2/sync/delta").status_code == 401
|
|
|
|
|
|
def test_delta_requires_workspace(client):
|
|
sess, _, _ = _user_ws(client)
|
|
resp = client.get("/api/v2/sync/delta", cookies={"flowdeck_session": sess})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_delta_forbidden_other_user_workspace(client):
|
|
session, _, ws_id = _user_ws(client, "owner_a")
|
|
sess2, uid2, _ = _user_ws(client, "owner_b")
|
|
# owner_b has no access to owner_a's workspace
|
|
resp = client.get(
|
|
f"/api/v2/sync/delta?since=0&workspace_id={ws_id}",
|
|
cookies={"flowdeck_session": sess2},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ── delta ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_delta_returns_changes_since(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
pid = _insert_page(ws_id, "D1", "[]")
|
|
since = _page_updated_epoch(pid)
|
|
time.sleep(1.1) # CURRENT_TIMESTAMP has 1s granularity → ensure next second
|
|
_insert_page(ws_id, "D2", "[]")
|
|
|
|
resp = client.get(
|
|
f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}",
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
assert resp.status_code == 200
|
|
changes = resp.json()["changes"]
|
|
assert len(changes) == 1
|
|
assert changes[0]["change_type"] == "page_created"
|
|
assert changes[0]["data"]["title"] == "D2"
|
|
|
|
# full history from since=0
|
|
resp2 = client.get(
|
|
f"/api/v2/sync/delta?since=0&workspace_id={ws_id}",
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
titles = {c["data"]["title"] for c in resp2.json()["changes"] if c["change_type"].startswith("page_")}
|
|
assert {"D1", "D2"} <= titles
|
|
|
|
|
|
def test_delta_includes_soft_deleted(client):
|
|
from app.db import get_conn
|
|
session, _, ws_id = _user_ws(client)
|
|
pid = _insert_page(ws_id, "ToDelete")
|
|
time.sleep(1.1) # 1s granularity: ensure the delete lands in a later second
|
|
since = _page_updated_epoch(pid)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,))
|
|
conn.commit()
|
|
|
|
resp = client.get(
|
|
f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}",
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
changes = resp.json()["changes"]
|
|
assert any(c["change_type"] == "page_deleted" for c in changes)
|
|
|
|
|
|
# ── batch: page mutations ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_batch_page_create(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={
|
|
"device_id": "dev-1",
|
|
"mutations": [{
|
|
"id": "m1",
|
|
"type": "page_create",
|
|
"client_timestamp": time.time(),
|
|
"payload": {"title": "Offline Page", "content": "[]",
|
|
"content_format": "blocks", "workspace_id": ws_id},
|
|
}],
|
|
},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["results"][0]["status"] == "synced"
|
|
pid = body["results"][0]["page_id"]
|
|
assert body["results"][0]["server_version"] == 1
|
|
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT title, workspace_id FROM pages WHERE id=?", (pid,)).fetchone()
|
|
assert row["title"] == "Offline Page"
|
|
assert row["workspace_id"] == ws_id
|
|
|
|
|
|
def test_batch_page_update_synced(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
pid = _insert_page(ws_id, "Versioned", "[]")
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m1", "type": "page_update", "client_timestamp": time.time(),
|
|
"payload": {"page_id": pid, "base_version": 1,
|
|
"title": "Versioned v2", "content": "[1,2,3]"},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
r = resp.json()["results"][0]
|
|
assert r["status"] == "synced"
|
|
assert r["server_version"] == 2 # trigger bumped 1 → 2
|
|
|
|
|
|
def test_batch_page_update_edit_edit_conflict_lww(client):
|
|
"""Server page already moved to v2 → client base v1 → LWW applied + conflict."""
|
|
session, _, ws_id = _user_ws(client)
|
|
pid = _insert_page(ws_id, "Conflict", "[]")
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET content='[server edit]' WHERE id=?", (pid,))
|
|
conn.commit()
|
|
assert _page_version(pid) == 2
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m2", "type": "page_update", "client_timestamp": time.time(),
|
|
"payload": {"page_id": pid, "base_version": 1, "content": "[client edit]"},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
r = resp.json()["results"][0]
|
|
assert r["status"] == "conflict"
|
|
assert r["conflict"]["type"] == "edit_edit"
|
|
assert r["conflict"]["server_version"] == 2
|
|
assert r["server_version"] == 3 # LWW applied
|
|
# content overwritten by client (last write wins)
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
content = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()["content"]
|
|
assert content == "[client edit]"
|
|
|
|
|
|
def test_batch_page_update_edit_delete_orphan(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
pid = _insert_page(ws_id, "Deleted", "[old]")
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,))
|
|
conn.commit()
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m3", "type": "page_update", "client_timestamp": time.time(),
|
|
"payload": {"page_id": pid, "base_version": 1, "content": "[client]"},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
r = resp.json()["results"][0]
|
|
assert r["status"] == "conflict"
|
|
assert r["conflict"]["type"] == "edit_delete"
|
|
orphan_id = r["conflict"]["new_page_id"]
|
|
with get_conn() as conn:
|
|
orphan = conn.execute("SELECT content, deleted_at FROM pages WHERE id=?", (orphan_id,)).fetchone()
|
|
original = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
|
|
assert orphan["content"] == "[client]"
|
|
assert not orphan["deleted_at"] # orphan is live
|
|
assert original["deleted_at"] # original stays deleted
|
|
|
|
|
|
def test_batch_page_create_create_create_conflict(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
_insert_page(ws_id, "Same Title", "[]")
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m4", "type": "page_create", "client_timestamp": time.time(),
|
|
"payload": {"title": "Same Title", "content": "[]", "workspace_id": ws_id},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
r = resp.json()["results"][0]
|
|
assert r["status"] == "conflict"
|
|
assert r["conflict"]["type"] == "create_create"
|
|
assert r["conflict"]["renamed"] is True
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
title = conn.execute("SELECT title FROM pages WHERE id=?", (r["page_id"],)).fetchone()["title"]
|
|
assert title == "Same Title (copie offline)"
|
|
|
|
|
|
def test_batch_page_delete_idempotent(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
pid = _insert_page(ws_id, "D")
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m5", "type": "page_delete", "client_timestamp": time.time(),
|
|
"payload": {"page_id": pid},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
assert resp.json()["results"][0]["status"] == "synced"
|
|
# second delete is still ok (idempotent)
|
|
resp2 = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m6", "type": "page_delete", "client_timestamp": time.time(),
|
|
"payload": {"page_id": pid},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
assert resp2.json()["results"][0]["status"] == "synced"
|
|
|
|
|
|
def test_batch_unknown_type_failed(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "m7", "type": "nope", "client_timestamp": time.time(), "payload": {},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
r = resp.json()["results"][0]
|
|
assert r["status"] == "failed"
|
|
|
|
|
|
# ── batch: collections ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_batch_collection_crud(client):
|
|
from app.db import get_conn
|
|
session, _, _ = _user_ws(client)
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "c1", "type": "collection_create", "client_timestamp": time.time(),
|
|
"payload": {"name": "DB Off", "schema": [{"name": "Type", "prop_type": "text"}]},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
r = resp.json()["results"][0]
|
|
assert r["status"] == "synced"
|
|
cid = r["collection_id"]
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "c2", "type": "collection_update", "client_timestamp": time.time(),
|
|
"payload": {"collection_id": cid, "name": "DB Off v2"},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
assert resp.json()["results"][0]["server_version"] == 2
|
|
|
|
resp = client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "dev-1", "mutations": [{
|
|
"id": "c3", "type": "collection_delete", "client_timestamp": time.time(),
|
|
"payload": {"collection_id": cid},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
assert resp.json()["results"][0]["status"] == "synced"
|
|
with get_conn() as conn:
|
|
assert conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone() is None
|
|
|
|
|
|
# ── status & audit ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_sync_status_zero(client):
|
|
session, _, ws_id = _user_ws(client)
|
|
resp = client.get(
|
|
f"/api/v2/sync/status?workspace_id={ws_id}",
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
body = resp.json()
|
|
assert body["pending_count"] == 0
|
|
assert body["is_syncing"] is False
|
|
|
|
|
|
def test_batch_records_audit_rows(client):
|
|
session, uid, ws_id = _user_ws(client)
|
|
client.post(
|
|
"/api/v2/sync/batch",
|
|
json={"device_id": "audit-dev", "mutations": [{
|
|
"id": "a1", "type": "page_create", "client_timestamp": 123.0,
|
|
"payload": {"title": "Audited", "content": "[]", "workspace_id": ws_id},
|
|
}]},
|
|
cookies={"flowdeck_session": session},
|
|
)
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT user_id, device_id, type, status FROM offline_sync_queue ORDER BY id"
|
|
).fetchone()
|
|
assert row["user_id"] == uid
|
|
assert row["device_id"] == "audit-dev"
|
|
assert row["type"] == "page_create"
|
|
assert row["status"] == "synced"
|