- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS` (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) - `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header (`adminFetch` prouve que `/api/admin` était déjà couvert) - reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`), `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch - tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de route du 403 middleware — 4 tests d'anonymat ajustés - suite **1026/1026** · `ruff check app tests` OK
385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""FlowDeck — v7.1.0 Calendar sync + Meeting Notes.
|
|
|
|
Covers migration 27, calendar link CRUD (encryption, auth, isolation),
|
|
bidirectional Google sync (pull/push/idempotence/conflict LWW + notif),
|
|
CalDAV XML parsing, free/busy, meeting audio upload + manual transcript +
|
|
offline AI summary firing ``meeting.summarized``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
|
|
import pytest
|
|
from conftest import anon_csrf
|
|
|
|
from app.db import get_conn
|
|
from app.services import calendar_sync as cal
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _login(client):
|
|
from app.auth.session import SessionManager
|
|
login = f"v71_{secrets.token_hex(4)}"
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V71', ?, 0)",
|
|
(login, f"{login}@test.com"),
|
|
)
|
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
|
conn.commit()
|
|
return SessionManager.create_session({"id": uid, "login": login}), uid
|
|
|
|
|
|
def _cookies(session):
|
|
return {"flowdeck_session": session}
|
|
|
|
|
|
def _mkcollection(client, name="Sprint Cal"):
|
|
r = client.post("/db/api", json={"name": name,
|
|
"schema": [{"name": "Due", "type": "date"}]})
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["id"]
|
|
|
|
|
|
def _date_prop_id(cid):
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id FROM collection_properties WHERE collection_id=?"
|
|
" AND prop_type='date'", (cid,)).fetchone()
|
|
return str(row["id"])
|
|
|
|
|
|
def _mkrow(cid, title, day, external_id=""):
|
|
pid = _date_prop_id(cid)
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_pages (collection_id, title, position,
|
|
property_values_json, external_event_id)
|
|
VALUES (?,?,0,?,?)""",
|
|
(cid, title, json.dumps({pid: day}), external_id))
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
def _mkpage(title="Meeting"):
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format)"
|
|
" VALUES ('test', ?, '', 'blocks')", (title,))
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
def _mklink(client, session, cid, provider="google", creds=None, **kw):
|
|
body = {"provider": provider, "collection_id": cid,
|
|
"credentials": creds or {"access_token": "tok123"}}
|
|
body.update(kw)
|
|
r = client.post("/api/v2/calendar-links", json=body, cookies=_cookies(session))
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
# ── migration ──────────────────────────────────────────────────────────────
|
|
|
|
def test_migration_27_tables(client):
|
|
with get_conn() as conn:
|
|
tables = {r[0] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
|
for t in ("calendar_links", "meeting_transcripts"):
|
|
assert t in tables
|
|
with get_conn() as conn:
|
|
cols = {r[1] for r in conn.execute(
|
|
"PRAGMA table_info(collection_pages)").fetchall()}
|
|
assert "external_event_id" in cols
|
|
with get_conn() as conn:
|
|
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
assert v >= 27
|
|
|
|
|
|
# ── links CRUD ─────────────────────────────────────────────────────────────
|
|
|
|
def test_link_crud_and_encryption(client):
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
link = _mklink(client, session, cid)
|
|
lid = link["id"]
|
|
assert "tokens_enc" not in link # never leaked
|
|
with get_conn() as conn:
|
|
stored = conn.execute("SELECT tokens_enc FROM calendar_links WHERE id=?",
|
|
(lid,)).fetchone()[0]
|
|
assert "tok123" not in stored # encrypted at rest
|
|
assert cal._decrypt_tokens(stored)["access_token"] == "tok123"
|
|
r = client.get("/api/v2/calendar-links", cookies=_cookies(session))
|
|
assert any(x["id"] == lid for x in r.json()["links"])
|
|
r = client.delete(f"/api/v2/calendar-links/{lid}", cookies=_cookies(session))
|
|
assert r.status_code == 200
|
|
r = client.get("/api/v2/calendar-links", cookies=_cookies(session))
|
|
assert r.json()["links"] == []
|
|
|
|
|
|
def test_link_validation_and_auth(client):
|
|
anon_csrf(client)
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
r = client.post("/api/v2/calendar-links",
|
|
json={"provider": "exchange", "collection_id": cid,
|
|
"credentials": {}},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
r = client.post("/api/v2/calendar-links",
|
|
json={"provider": "caldav", "collection_id": cid,
|
|
"credentials": {}},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400 # url required
|
|
r = client.post("/api/v2/calendar-links",
|
|
json={"provider": "google", "collection_id": 999999,
|
|
"credentials": {"access_token": "x"}},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
r = client.post("/api/v2/calendar-links",
|
|
json={"provider": "google", "collection_id": cid,
|
|
"credentials": {"access_token": "x"}})
|
|
assert r.status_code == 401
|
|
# isolation: another user cannot delete the link
|
|
link = _mklink(client, session, cid)
|
|
s2, _ = _login(client)
|
|
r = client.delete(f"/api/v2/calendar-links/{link['id']}", cookies=_cookies(s2))
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ── sync: pull / push / idempotence / conflicts ────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_pull_creates_rows(client, monkeypatch):
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
link = _mklink(client, session, cid)
|
|
|
|
async def fake_list(tokens, calendar_id, tmin, tmax):
|
|
assert tokens["access_token"] == "tok123"
|
|
return [{"id": "g1", "title": "Kickoff", "start": "2026-10-06",
|
|
"description": "", "updated": "2026-09-28T10:00:00Z"},
|
|
{"id": "g2", "title": "Demo", "start": "2026-10-07T14:00:00",
|
|
"description": "", "updated": "2026-09-28T10:00:00Z"}]
|
|
monkeypatch.setattr(cal, "google_list_events", fake_list)
|
|
stats = await cal.sync_link(link["id"])
|
|
assert stats == {"pulled": 2, "pushed": 0, "conflicts": 0}
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT title, external_event_id, property_values_json"
|
|
" FROM collection_pages WHERE collection_id=?", (cid,)).fetchall()
|
|
assert {r["external_event_id"] for r in rows} == {"g1", "g2"}
|
|
pid = _date_prop_id(cid)
|
|
vals = json.loads([r for r in rows if r["title"] == "Kickoff"][0]["property_values_json"])
|
|
assert vals[pid] == "2026-10-06"
|
|
# second pass: idempotent
|
|
stats = await cal.sync_link(link["id"])
|
|
assert stats["pulled"] == 0 and stats["conflicts"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_push_new_local_row(client, monkeypatch):
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
link = _mklink(client, session, cid)
|
|
pushed = []
|
|
|
|
async def fake_list(tokens, calendar_id, tmin, tmax):
|
|
return []
|
|
async def fake_push(tokens, calendar_id, event, remote_id=""):
|
|
pushed.append((event, remote_id))
|
|
return "g9"
|
|
monkeypatch.setattr(cal, "google_list_events", fake_list)
|
|
monkeypatch.setattr(cal, "google_push_event", fake_push)
|
|
_mkrow(cid, "Local task", "2026-10-08")
|
|
stats = await cal.sync_link(link["id"])
|
|
assert stats["pushed"] == 1
|
|
assert pushed[0][0]["title"] == "Local task"
|
|
with get_conn() as conn:
|
|
xid = conn.execute("SELECT external_event_id FROM collection_pages"
|
|
" WHERE collection_id=? AND title='Local task'",
|
|
(cid,)).fetchone()[0]
|
|
assert xid == "g9"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_conflict_lww_and_notif(client, monkeypatch):
|
|
session, uid = _login(client)
|
|
cid = _mkcollection(client)
|
|
link = _mklink(client, session, cid)
|
|
rid = _mkrow(cid, "Planning", "2026-10-05", external_id="g5")
|
|
# local edit after link's last_sync
|
|
pid = _date_prop_id(cid)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE collection_pages SET property_values_json=?,"
|
|
" updated_at='2026-09-28 12:00:00' WHERE id=?",
|
|
(json.dumps({pid: "2026-10-09"}), rid))
|
|
conn.execute("UPDATE calendar_links SET last_sync='2026-09-28 11:00:00' WHERE id=?",
|
|
(link["id"],))
|
|
conn.commit()
|
|
|
|
async def fake_list(tokens, calendar_id, tmin, tmax):
|
|
return [{"id": "g5", "title": "Planning", "start": "2026-10-06",
|
|
"description": "", "updated": "2026-09-28T13:00:00Z"}] # remote newer
|
|
monkeypatch.setattr(cal, "google_list_events", fake_list)
|
|
stats = await cal.sync_link(link["id"])
|
|
assert stats["conflicts"] == 1
|
|
with get_conn() as conn:
|
|
vals = json.loads(conn.execute("SELECT property_values_json FROM collection_pages"
|
|
" WHERE id=?", (rid,)).fetchone()[0])
|
|
notif = conn.execute("SELECT * FROM notifications WHERE user_id=? AND ntype='calendar'",
|
|
(uid,)).fetchone()
|
|
assert vals[pid] == "2026-10-06" # remote (newer) won
|
|
assert notif is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_expired_token_maps_502(client, monkeypatch):
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
link = _mklink(client, session, cid)
|
|
|
|
async def fake_list(tokens, calendar_id, tmin, tmax):
|
|
raise cal.SyncError("google token expired — relink the calendar")
|
|
monkeypatch.setattr(cal, "google_list_events", fake_list)
|
|
r = client.post(f"/api/v2/calendar-links/{link['id']}/sync",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 502
|
|
|
|
|
|
def test_caldav_parser_unit(client):
|
|
xml = """<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:response><D:href>/cal/abc.ics</D:href>
|
|
<D:propstat><D:prop><C:calendar-data>BEGIN:VCALENDAR
|
|
UID:evt-1
|
|
DTSTART:20261006T090000Z
|
|
SUMMARY:Standup
|
|
DESCRIPTION:daily sync
|
|
END:VCALENDAR</C:calendar-data></D:prop></D:propstat></D:response>
|
|
</D:multistatus>"""
|
|
events = cal._parse_caldav_events(xml)
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == "evt-1"
|
|
assert events[0]["title"] == "Standup"
|
|
assert events[0]["start"] == "2026-10-06"
|
|
|
|
|
|
# ── free/busy ──────────────────────────────────────────────────────────────
|
|
|
|
def test_freebusy_basic(client):
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
_mkrow(cid, "Busy task", "2026-10-05") # a Monday
|
|
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-05&to=2026-10-07",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
by_date = {d["date"]: d for d in body["days"]}
|
|
assert by_date["2026-10-05"]["busy"] is True
|
|
assert by_date["2026-10-06"]["busy"] is False
|
|
assert "2026-10-06" in body["free_weekdays"]
|
|
assert "2026-10-05" not in body["free_weekdays"]
|
|
|
|
|
|
def test_freebusy_validation(client):
|
|
anon_csrf(client)
|
|
session, _ = _login(client)
|
|
cid = _mkcollection(client)
|
|
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
r = client.get("/db/999999/calendar/freebusy?from=2026-10-01&to=2026-10-02",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-01&to=2026-10-02")
|
|
assert r.status_code == 401
|
|
|
|
|
|
# ── meetings ───────────────────────────────────────────────────────────────
|
|
|
|
def test_meeting_upload_and_manual_flow(client):
|
|
session, uid = _login(client)
|
|
pid = _mkpage()
|
|
# audio only, no STT backend → stored, not transcribed
|
|
r = client.post("/api/v2/meetings/transcribe",
|
|
files={"audio": ("rec.mp3", b"ID3" + b"\x00" * 100, "audio/mpeg")},
|
|
data={"page_id": str(pid)},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 201, r.text
|
|
tid = r.json()["id"]
|
|
assert r.json()["transcribed"] is False
|
|
# manual transcript from client
|
|
r = client.post(f"/api/v2/meetings/transcripts/{tid}/text",
|
|
json={"transcript": "We decided to ship on Friday. Alice owns the release."},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 200
|
|
# automation catches meeting.summarized
|
|
r = client.post("/workspace/automations",
|
|
json={"name": "Post-meeting", "trigger_type": "event",
|
|
"event": "page.created", "actions": []},
|
|
cookies=_cookies(session))
|
|
aid = r.json()["id"]
|
|
client.post(f"/workspace/automations/{aid}/steps",
|
|
json={"kind": "trigger", "config": {"event": "meeting.summarized"}},
|
|
cookies=_cookies(session))
|
|
client.post(f"/workspace/automations/{aid}/steps",
|
|
json={"kind": "action",
|
|
"config": {"type": "notify", "message": "recap ready"}},
|
|
cookies=_cookies(session))
|
|
r = client.post(f"/api/v2/meetings/transcripts/{tid}/summarize",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["summary"]
|
|
assert r.json()["offline"] is True
|
|
with get_conn() as conn:
|
|
runs = conn.execute("SELECT * FROM automation_runs WHERE automation_id=?",
|
|
(aid,)).fetchall()
|
|
assert len(runs) == 1 and runs[0]["status"] == "fired"
|
|
|
|
|
|
def test_meeting_upload_validation(client):
|
|
session, _ = _login(client)
|
|
pid = _mkpage()
|
|
r = client.post("/api/v2/meetings/transcribe",
|
|
files={"audio": ("rec.exe", b"data", "application/octet-stream")},
|
|
data={"page_id": str(pid)},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
r = client.post("/api/v2/meetings/transcribe",
|
|
data={"page_id": str(pid), "transcript": "hello"},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 201 # manual-only transcript allowed (client-side STT)
|
|
|
|
|
|
def test_meeting_transcribe_inline_manual(client):
|
|
session, _ = _login(client)
|
|
pid = _mkpage()
|
|
r = client.post("/api/v2/meetings/transcribe",
|
|
files={"audio": ("rec.wav", b"RIFF" + b"\x00" * 50, "audio/wav")},
|
|
data={"page_id": str(pid),
|
|
"transcript": "Inline notes from the call."},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 201
|
|
assert r.json()["transcribed"] is True
|
|
|
|
|
|
def test_meeting_summarize_empty_400(client):
|
|
session, _ = _login(client)
|
|
pid = _mkpage()
|
|
r = client.post("/api/v2/meetings/transcribe",
|
|
files={"audio": ("rec.mp3", b"ID3abc", "audio/mpeg")},
|
|
data={"page_id": str(pid)},
|
|
cookies=_cookies(session))
|
|
tid = r.json()["id"]
|
|
r = client.post(f"/api/v2/meetings/transcripts/{tid}/summarize",
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_meeting_page_not_found(client):
|
|
session, _ = _login(client)
|
|
r = client.post("/api/v2/meetings/transcribe",
|
|
files={"audio": ("rec.mp3", b"ID3abc", "audio/mpeg")},
|
|
data={"page_id": "999999"},
|
|
cookies=_cookies(session))
|
|
assert r.status_code == 404
|