feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped

- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
This commit is contained in:
2026-09-30 20:02:57 -04:00
parent d074689b18
commit 1706ad1ee9
260 changed files with 10667 additions and 353788 deletions
+343
View File
@@ -0,0 +1,343 @@
"""FlowDeck — v6.8.0 Sites & public Forms.
Covers migration 24, site CRUD + pages + stats, public rendering (/s/),
password/expiry gating, sitemap, form config + anonymous submission
(validation, rate limit, honeypot, embed) and auth guards.
"""
from __future__ import annotations
import json
import secrets
from app.db import get_conn
def _login(client):
from app.auth.session import SessionManager
login = f"v68_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V68', ?, 0)",
(login, f"{login}@test.com"),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login})
return session, uid
def _cookies(session):
return {"flowdeck_session": session}
def _make_page(title="Hello", content="world", fmt="markdown"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?, ?, ?, ?)",
("test", title, content, fmt),
)
pid = cur.lastrowid
conn.commit()
return pid
def _make_collection(name="Contacts"):
with get_conn() as conn:
cur = conn.execute("INSERT INTO collections (name) VALUES (?)", (name,))
cid = cur.lastrowid
conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, position)"
" VALUES (?, 'Name', 'text', 0)",
(cid,),
)
conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, position)"
" VALUES (?, 'Email', 'email', 1)",
(cid,),
)
conn.commit()
return cid
def _create_site(client, session, pid, **kw):
body = {"root_page_id": pid}
body.update(kw)
r = client.post("/api/v2/sites", json=body, cookies=_cookies(session))
assert r.status_code == 201, r.text
return r.json()
# ── migration ──────────────────────────────────────────────────────────────
def test_migration_24_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 ("sites", "site_pages", "site_views", "form_responses"):
assert t in tables
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
assert "form_config_json" in cols
with get_conn() as conn:
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v >= 24
# ── sites CRUD ─────────────────────────────────────────────────────────────
def test_site_crud(client):
session, _uid = _login(client)
pid = _make_page("My Site Root")
site = _create_site(client, session, pid, slug="my-site")
assert site["slug"] == "my-site"
sid = site["id"]
r = client.get("/api/v2/sites", cookies=_cookies(session))
assert r.status_code == 200
assert any(s["id"] == sid for s in r.json())
assert "X-Total-Count" in r.headers
r = client.get(f"/api/v2/sites/{sid}", cookies=_cookies(session))
assert r.status_code == 200
assert r.json()["pages"][0]["id"] == pid
r = client.patch(f"/api/v2/sites/{sid}", json={"title": "New title", "theme": "light"},
cookies=_cookies(session))
assert r.status_code == 200
assert r.json()["title"] == "New title"
r = client.delete(f"/api/v2/sites/{sid}", cookies=_cookies(session))
assert r.status_code == 200
r = client.get(f"/api/v2/sites/{sid}", cookies=_cookies(session))
assert r.status_code == 404
def test_site_slug_validation_and_conflict(client):
session, _ = _login(client)
pid = _make_page("Root")
r = client.post("/api/v2/sites", json={"root_page_id": pid, "slug": "BAD SLUG!!"},
cookies=_cookies(session))
assert r.status_code == 400
_create_site(client, session, pid, slug="taken-slug")
pid2 = _make_page("Root 2")
r = client.post("/api/v2/sites", json={"root_page_id": pid2, "slug": "taken-slug"},
cookies=_cookies(session))
assert r.status_code == 409
def test_site_requires_auth(client):
pid = _make_page("Root")
r = client.post("/api/v2/sites", json={"root_page_id": pid})
assert r.status_code == 401
def test_site_isolation_between_users(client):
s1, _ = _login(client)
s2, _ = _login(client)
pid = _make_page("Root")
site = _create_site(client, s1, pid, slug="private-site")
r = client.get(f"/api/v2/sites/{site['id']}", cookies=_cookies(s2))
assert r.status_code == 404
def test_site_pages_add_remove(client):
session, _ = _login(client)
pid = _make_page("Root")
site = _create_site(client, session, pid, slug="nav-site")
sid = site["id"]
pid2 = _make_page("Second page")
r = client.post(f"/api/v2/sites/{sid}/pages", json={"page_id": pid2},
cookies=_cookies(session))
assert r.status_code == 200
assert len(r.json()["pages"]) == 2
r = client.delete(f"/api/v2/sites/{sid}/pages/{pid2}", cookies=_cookies(session))
assert r.status_code == 200
r = client.delete(f"/api/v2/sites/{sid}/pages/{pid}", cookies=_cookies(session))
assert r.status_code == 400 # root protected
# ── public rendering ───────────────────────────────────────────────────────
def test_public_site_home_and_subpage(client):
session, _ = _login(client)
pid = _make_page("Welcome Home", "hello public")
site = _create_site(client, session, pid, slug="public-home")
r = client.get("/s/public-home")
assert r.status_code == 200
assert "Welcome Home" in r.text
assert "hello public" in r.text
# sub-page by slug
pid2 = _make_page("Second Page", "second body")
client.post(f"/api/v2/sites/{site['id']}/pages", json={"page_id": pid2},
cookies=_cookies(session))
r = client.get("/s/public-home/second-page")
assert r.status_code == 200
assert "second body" in r.text
# unknown page
r = client.get("/s/public-home/nope")
assert r.status_code == 404
def test_public_site_blocks_render(client):
session, _ = _login(client)
content = json.dumps([{"type": "heading_1", "content": "Big Title"},
{"type": "paragraph", "content": "para body"}])
pid = _make_page("Blocks", content, fmt="blocks")
_create_site(client, session, pid, slug="blocks-site")
r = client.get("/s/blocks-site")
assert r.status_code == 200
assert "Big Title" in r.text
def test_public_site_404(client):
r = client.get("/s/does-not-exist")
assert r.status_code == 404
def test_site_views_counted(client):
from app.routers.sites import _reset_form_rate # noqa - ensure router loaded
_ = _reset_form_rate
session, _ = _login(client)
pid = _make_page("Root")
site = _create_site(client, session, pid, slug="stats-site")
client.get("/s/stats-site")
client.get("/s/stats-site")
r = client.get(f"/api/v2/sites/{site['id']}/stats", cookies=_cookies(session))
assert r.status_code == 200
assert r.json()["total_views"] >= 2
def test_site_password_gate(client):
session, _ = _login(client)
pid = _make_page("Secret", "top secret body")
site = _create_site(client, session, pid, slug="secret-site")
client.patch(f"/api/v2/sites/{site['id']}", json={"password": "s3cr3t"},
cookies=_cookies(session))
# anonymous client without cookies
from fastapi.testclient import TestClient
from app.main import app
anon = TestClient(app)
r = anon.get("/s/secret-site")
assert r.status_code == 401
r = anon.post("/s/secret-site/auth", json={"password": "wrong"})
assert r.status_code == 401
r = anon.post("/s/secret-site/auth", json={"password": "s3cr3t"})
assert r.status_code == 200
r = anon.get("/s/secret-site")
assert r.status_code == 200
assert "top secret body" in r.text
def test_site_expiry(client):
session, _ = _login(client)
pid = _make_page("Root")
_create_site(client, session, pid, slug="old-site",
expires_at="2000-01-01T00:00:00Z")
r = client.get("/s/old-site")
assert r.status_code == 410
def test_site_sitemap(client):
session, _ = _login(client)
pid = _make_page("Root")
_create_site(client, session, pid, slug="map-site")
r = client.get("/s/map-site/sitemap.xml")
assert r.status_code == 200
assert "/s/map-site" in r.text
def test_site_noindex_meta(client):
session, _ = _login(client)
pid = _make_page("Root")
_create_site(client, session, pid, slug="noindex-site", noindex=True)
r = client.get("/s/noindex-site")
assert "noindex" in r.text
# ── forms ──────────────────────────────────────────────────────────────────
def _enable_form(client, session, cid, **kw):
cfg = {"enabled": True, "fields": ["Name", "Email"], "required": ["Name"]}
cfg.update(kw)
r = client.put(f"/api/v2/collections/{cid}/form", json=cfg, cookies=_cookies(session))
assert r.status_code == 200, r.text
return r.json()["form"]
def test_form_config_crud(client):
session, _ = _login(client)
cid = _make_collection()
r = client.get(f"/api/v2/collections/{cid}/form", cookies=_cookies(session))
assert r.status_code == 200
form = _enable_form(client, session, cid)
assert form["enabled"] is True
assert form["public_token"].startswith("f_")
def test_public_form_get_and_submit_json(client):
from app.routers.sites import _reset_form_rate
_reset_form_rate()
session, _ = _login(client)
cid = _make_collection()
form = _enable_form(client, session, cid)
token = form["public_token"]
r = client.get(f"/f/{token}")
assert r.status_code == 200
assert "Name" in r.text
r = client.post(f"/f/{token}", json={"Name": "Alice", "Email": "[email protected]"})
assert r.status_code == 200, r.text
with get_conn() as conn:
row = conn.execute(
"SELECT title FROM collection_pages WHERE collection_id=? ORDER BY id DESC LIMIT 1",
(cid,)).fetchone()
assert row is not None
def test_public_form_required_and_404(client):
from app.routers.sites import _reset_form_rate
_reset_form_rate()
session, _ = _login(client)
cid = _make_collection()
form = _enable_form(client, session, cid)
r = client.post(f"/f/{form['public_token']}", json={"Email": "[email protected]"})
assert r.status_code == 400
r = client.get("/f/f_doesnotexist123")
assert r.status_code == 404
r = client.post("/f/f_doesnotexist123", json={"Name": "x"})
assert r.status_code == 404
def test_public_form_honeypot(client):
from app.routers.sites import _reset_form_rate
_reset_form_rate()
session, _ = _login(client)
cid = _make_collection()
form = _enable_form(client, session, cid)
r = client.post(f"/f/{form['public_token']}",
json={"Name": "Spammer", "__hp": "bot"})
assert r.status_code == 400
def test_public_form_rate_limit(client):
from app.routers.sites import _reset_form_rate
_reset_form_rate()
session, _ = _login(client)
cid = _make_collection()
form = _enable_form(client, session, cid)
token = form["public_token"]
last = None
for i in range(21):
last = client.post(f"/f/{token}", json={"Name": f"U{i}"})
assert last.status_code == 429
def test_public_form_embed_mode(client):
from app.routers.sites import _reset_form_rate
_reset_form_rate()
session, _ = _login(client)
cid = _make_collection()
form = _enable_form(client, session, cid, title="Contact Us")
r = client.get(f"/f/{form['public_token']}?embed=1")
assert r.status_code == 200
assert "<h1>" not in r.text # chrome stripped in embed
+329
View File
@@ -0,0 +1,329 @@
"""FlowDeck — v6.9.0 semantic search + Ask AI.
Covers migration 25, chunking/hashing/cosine units, indexing (idempotent,
excluded/deleted skipped, orphans purged), vector recall on partial overlap,
hybrid RRF + ACL/workspace isolation + pagination headers, ask (offline
extractive with citations, auth, cache, rate limit, ACL) and index-status.
"""
from __future__ import annotations
import json
import math
import secrets
import struct
from app.db import get_conn
from app.services import semantic_search as sem
# ── helpers ────────────────────────────────────────────────────────────────
def _login(client, admin: bool = False):
from app.auth.session import SessionManager
login = f"v69_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V69', ?, ?)",
(login, f"{login}@test.com", 1 if admin else 0),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login})
return session, {"id": uid, "login": login}
def _cookies(session):
return {"flowdeck_session": session}
def _mkpage(title="Doc", body="hello world", workspace_id=None, fmt="blocks"):
content = (json.dumps([{"type": "paragraph", "content": body}])
if fmt == "blocks" else body)
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format)"
" VALUES (?, ?, ?, ?, ?)",
("test", workspace_id, title, content, fmt),
)
pid = cur.lastrowid
conn.commit()
return pid
def _mkcollection(name="DB", description="desc"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, description) VALUES (?, ?)", (name, description))
cid = cur.lastrowid
conn.commit()
return cid
def _mkworkspace(owner_id, name="Team"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, owner_id))
wid = cur.lastrowid
conn.commit()
return wid
# ── migration ──────────────────────────────────────────────────────────────
def test_migration_25_tables(client):
with get_conn() as conn:
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert "semantic_embeddings" in tables
assert "semantic_index_state" in tables
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
assert "search_excluded" in cols
with get_conn() as conn:
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v >= 25
# ── units: chunk / embed / cosine ──────────────────────────────────────────
def test_chunk_text_short_and_empty(client):
assert sem.chunk_text("") == []
assert sem.chunk_text("hello") == ["hello"]
def test_chunk_text_splits_long(client):
text = " ".join(f"word{i}" for i in range(800))
chunks = sem.chunk_text(text)
assert len(chunks) > 1
assert all(len(c) <= sem.CHUNK_SIZE for c in chunks)
# overlap: a middle word appears in two consecutive chunks
assert any(w in chunks[0] and w in chunks[1] for w in chunks[1].split()[:20])
def test_embed_deterministic_and_normalized(client):
a = sem.embed_text("hello world")
b = sem.embed_text("hello world")
assert a == b
assert len(a) == sem.DIM * 4
vals = struct.unpack(f"<{sem.DIM}f", a)
assert math.isclose(sum(v * v for v in vals), 1.0, rel_tol=1e-5)
def test_cosine_identical_and_disjoint(client):
a = sem.embed_text("alpha beta")
assert math.isclose(sem.cosine(a, a), 1.0, rel_tol=1e-5)
# disjoint single tokens collide with p≈1-(255/256)^2 ≈ tiny; use longer texts
c = sem.embed_text("alpha beta gamma delta")
d = sem.embed_text("epsilon zeta eta theta")
assert 0.0 <= sem.cosine(c, d) < sem.cosine(c, c)
# ── indexing ───────────────────────────────────────────────────────────────
def test_index_page_and_idempotent(client):
pid = _mkpage("Guide", "kubernetes deployment scaling tips")
n = sem.index_resource("page", pid)
assert n >= 1
with get_conn() as conn:
count = conn.execute(
"SELECT COUNT(*) FROM semantic_embeddings WHERE resource_type='page'"
" AND resource_id=?", (pid,)).fetchone()[0]
assert count == n
n2 = sem.index_resource("page", pid)
assert n2 == n
with get_conn() as conn:
count2 = conn.execute(
"SELECT COUNT(*) FROM semantic_embeddings WHERE resource_type='page'"
" AND resource_id=?", (pid,)).fetchone()[0]
assert count2 == n # no duplicates
def test_index_skips_excluded(client):
pid = _mkpage("Secret", "hidden content here")
with get_conn() as conn:
conn.execute("UPDATE pages SET search_excluded=1 WHERE id=?", (pid,))
conn.commit()
assert sem.index_resource("page", pid) == 0
def test_purge_removes_deleted(client):
pid = _mkpage("Gone", "bye bye content")
assert sem.index_resource("page", pid) >= 1
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at='2026-01-01 00:00:00' WHERE id=?", (pid,))
conn.commit()
assert sem.purge_orphans() >= 1
assert sem.index_resource("page", pid) == 0
def test_index_pending_picks_stale(client):
pid = _mkpage("Fresh", "brand new content words")
out = sem.index_pending(limit=50)
assert out["indexed"] >= 1
with get_conn() as conn:
row = conn.execute(
"SELECT indexed_at FROM semantic_index_state WHERE resource_type='page'"
" AND resource_id=?", (pid,)).fetchone()
assert row is not None
# ── vector + hybrid ────────────────────────────────────────────────────────
def test_vector_partial_overlap_recall(client):
pid = _mkpage("Ops", "alpha beta gamma delta")
sem.index_resource("page", pid)
# FTS AND would need all terms; vector matches on shared "alpha".
hits = sem.vector_search("alpha zeta omicron", limit=10)
assert any(h["resource_id"] == pid for h in hits)
def test_hybrid_finds_by_keyword(client):
session, user = _login(client)
pid = _mkpage("Kubernetes Guide", "deploy pods and services")
sem.index_resource("page", pid)
r = client.get("/api/v2/search/hybrid?q=kubernetes", cookies=_cookies(session))
assert r.status_code == 200, r.text
ids = [x["id"] for x in r.json()["results"] if x["type"] == "page"]
assert pid in ids
assert "X-Total-Count" in r.headers
def test_hybrid_requires_auth(client):
r = client.get("/api/v2/search/hybrid?q=test")
assert r.status_code == 401
def test_hybrid_empty_query_400(client):
session, _ = _login(client)
r = client.get("/api/v2/search/hybrid?q=", cookies=_cookies(session))
assert r.status_code == 400
def test_hybrid_pagination(client):
session, _ = _login(client)
for i in range(3):
sem.index_resource("page", _mkpage(f"Pagetopic {i}", f"pagetopic body {i}"))
r = client.get("/api/v2/search/hybrid?q=pagetopic&limit=2&offset=0",
cookies=_cookies(session))
assert r.status_code == 200
body = r.json()
assert len(body["results"]) <= 2
assert body["limit"] == 2 and body["offset"] == 0
def test_hybrid_workspace_isolation(client):
# Workspaces are open-by-default (viewer fallback); restriction is opt-in
# via permission_type='restricted' + explicit grants.
s1, u1 = _login(client)
_s2, _u2 = _login(client)
wid = _mkworkspace(u1["id"])
pid = _mkpage("Team Secrets", "sekretwords vault", workspace_id=wid)
with get_conn() as conn:
conn.execute("UPDATE pages SET permission_type='restricted' WHERE id=?", (pid,))
conn.commit()
sem.index_resource("page", pid)
r = client.get("/api/v2/search/hybrid?q=sekretwords", cookies=_cookies(s1))
assert pid in [x["id"] for x in r.json()["results"] if x["type"] == "page"]
r = client.get("/api/v2/search/hybrid?q=sekretwords", cookies=_cookies(_s2))
assert pid not in [x["id"] for x in r.json()["results"] if x["type"] == "page"]
def test_hybrid_excluded_page_absent(client):
session, _ = _login(client)
pid = _mkpage("Hidden", "cloakwords invisible")
with get_conn() as conn:
conn.execute("UPDATE pages SET search_excluded=1 WHERE id=?", (pid,))
conn.commit()
sem.purge_orphans()
r = client.get("/api/v2/search/hybrid?q=cloakwords", cookies=_cookies(session))
assert pid not in [x["id"] for x in r.json()["results"] if x["type"] == "page"]
def test_hybrid_finds_collections(client):
session, _ = _login(client)
cid = _mkcollection("Customer CRM", "tracks zalonowords leads")
sem.index_resource("collection", cid)
r = client.get("/api/v2/search/hybrid?q=zalonowords", cookies=_cookies(session))
assert r.status_code == 200
assert cid in [x["id"] for x in r.json()["results"] if x["type"] == "collection"]
# ── ask ────────────────────────────────────────────────────────────────────
def test_ask_offline_with_citations(client):
sem.reset_state()
session, _ = _login(client)
pid = _mkpage("Deploy Guide",
"To deploy the app, run the deploy script. Then verify the pods are ready.")
sem.index_resource("page", pid)
r = client.post("/api/v2/search/ask", json={"question": "how to deploy the app"},
cookies=_cookies(session))
assert r.status_code == 200, r.text
body = r.json()
assert body["offline"] is True
assert any(c["id"] == pid for c in body["citations"])
assert f"[[fdpage:{pid}]]" in body["answer_markdown"]
def test_ask_requires_auth(client):
r = client.post("/api/v2/search/ask", json={"question": "hi"})
assert r.status_code == 401
def test_ask_empty_400(client):
sem.reset_state()
session, _ = _login(client)
r = client.post("/api/v2/search/ask", json={"question": " "},
cookies=_cookies(session))
assert r.status_code == 400
def test_ask_cached(client):
sem.reset_state()
session, _ = _login(client)
pid = _mkpage("Caching", "the cache stores answers for reuse and speed")
sem.index_resource("page", pid)
payload = {"question": "what does the cache store"}
r1 = client.post("/api/v2/search/ask", json=payload, cookies=_cookies(session))
assert r1.json()["cached"] is False
r2 = client.post("/api/v2/search/ask", json=payload, cookies=_cookies(session))
assert r2.json()["cached"] is True
assert r2.json()["answer_markdown"] == r1.json()["answer_markdown"]
def test_ask_acl_other_user(client):
sem.reset_state()
s1, u1 = _login(client)
s2, _u2 = _login(client)
wid = _mkworkspace(u1["id"])
pid = _mkpage("Private Ops", "quagmirewords runbook steps", workspace_id=wid)
with get_conn() as conn:
conn.execute("UPDATE pages SET permission_type='restricted' WHERE id=?", (pid,))
conn.commit()
sem.index_resource("page", pid)
r = client.post("/api/v2/search/ask", json={"question": "quagmirewords runbook"},
cookies=_cookies(s1))
assert pid in [c["id"] for c in r.json()["citations"]]
r = client.post("/api/v2/search/ask", json={"question": "quagmirewords runbook"},
cookies=_cookies(s2))
assert pid not in [c["id"] for c in r.json()["citations"]]
def test_ask_rate_limit(client):
sem.reset_state()
session, _ = _login(client)
last = None
for _ in range(31):
last = client.post("/api/v2/search/ask", json={"question": "ping"},
cookies=_cookies(session))
assert last.status_code == 429
def test_index_status(client):
session, _ = _login(client)
pid = _mkpage("Status", "statuswords check")
sem.index_resource("page", pid)
r = client.get("/api/v2/search/index-status", cookies=_cookies(session))
assert r.status_code == 200
body = r.json()
assert body["vectors"] >= 1
assert body["model"] == "hash-256"
+553
View File
@@ -0,0 +1,553 @@
"""FlowDeck — v7.0.0 Automations v2 (steps) + Workers lite.
Covers migration 26, steps CRUD + validation + auth, trigger modes any/all,
chained actions with interpolation, condition/delay steps, new actions
(slack/email/forge_issue/agent_trigger, secret encryption), native DB button,
legacy no-double-run compat, workers CRUD/run/sandbox/budget/fork/usage/cron.
"""
from __future__ import annotations
import secrets
import pytest
from app.db import get_conn
from app.services import automations as auto_svc
# ── helpers ────────────────────────────────────────────────────────────────
def _login(client):
from app.auth.session import SessionManager
login = f"v70_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V70', ?, 0)",
(login, f"{login}@test.com"),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login})
return session, uid
def _cookies(session):
return {"flowdeck_session": session}
def _mkcollection(client, name="Tasks"):
r = client.post("/db/api", json={"name": name,
"schema": [{"name": "Status", "type": "text"}]})
assert r.status_code == 200, r.text
return r.json()["id"]
def _mkrow(client, cid, title="Row"):
r = client.post(f"/db/{cid}/pages/api", json={"title": title})
assert r.status_code == 200, r.text
return r.json()["id"]
def _mkauto(client, session, **kw):
body = {"name": "Auto", "trigger_type": "event", "event": "page.created",
"actions": []}
body.update(kw)
r = client.post("/workspace/automations", json=body, cookies=_cookies(session))
assert r.status_code == 200, r.text
return r.json()["id"]
def _add_step(client, session, aid, kind, config, position=None):
body = {"kind": kind, "config": config}
if position is not None:
body["position"] = position
r = client.post(f"/workspace/automations/{aid}/steps", json=body,
cookies=_cookies(session))
assert r.status_code == 200, r.text
return r.json()["id"]
def _runs(aid):
with get_conn() as conn:
return conn.execute(
"SELECT * FROM automation_runs WHERE automation_id=? ORDER BY id", (aid,)
).fetchall()
# ── migration ──────────────────────────────────────────────────────────────
def test_migration_26_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 ("automation_steps", "workers", "worker_runs"):
assert t in tables
with get_conn() as conn:
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
prop_cols = {r[1] for r in conn.execute(
"PRAGMA table_info(collection_properties)").fetchall()}
assert "trigger_mode" in auto_cols
assert "button_automation_id" in prop_cols
with get_conn() as conn:
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v >= 26
# ── steps CRUD ─────────────────────────────────────────────────────────────
def test_steps_crud_and_order(client):
session, _ = _login(client)
aid = _mkauto(client, session)
s1 = _add_step(client, session, aid, "trigger", {"event": "page.created"}, position=0)
s2 = _add_step(client, session, aid, "action",
{"type": "notify", "message": "hi"}, position=1)
r = client.get(f"/workspace/automations/{aid}/steps", cookies=_cookies(session))
assert [s["id"] for s in r.json()["steps"]] == [s1, s2]
r = client.put(f"/workspace/automations/steps/{s2}",
json={"kind": "action", "config": {"type": "notify", "message": "yo"}},
cookies=_cookies(session))
assert r.status_code == 200
r = client.delete(f"/workspace/automations/steps/{s2}", cookies=_cookies(session))
assert r.status_code == 200
r = client.get(f"/workspace/automations/{aid}/steps", cookies=_cookies(session))
assert len(r.json()["steps"]) == 1
def test_steps_validation_and_auth(client):
session, _ = _login(client)
aid = _mkauto(client, session)
r = client.post(f"/workspace/automations/{aid}/steps",
json={"kind": "nope", "config": {}}, cookies=_cookies(session))
assert r.status_code == 400
r = client.post(f"/workspace/automations/{aid}/steps",
json={"kind": "action", "config": {"type": "nope"}},
cookies=_cookies(session))
assert r.status_code == 400
r = client.post(f"/workspace/automations/{aid}/steps",
json={"kind": "trigger", "config": {}})
assert r.status_code == 401
r = client.post("/workspace/automations/999999/steps",
json={"kind": "trigger", "config": {"event": "x"}},
cookies=_cookies(session))
assert r.status_code == 404
def test_trigger_mode_endpoint(client):
session, _ = _login(client)
aid = _mkauto(client, session)
r = client.put(f"/workspace/automations/{aid}/mode", json={"mode": "all"},
cookies=_cookies(session))
assert r.json()["trigger_mode"] == "all"
r = client.put(f"/workspace/automations/{aid}/mode", json={"mode": "sometimes"},
cookies=_cookies(session))
assert r.status_code == 400
# ── multi-trigger any/all ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_mode_any_two_triggers(client):
auto_svc.reset_all_pending()
session, _ = _login(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "trigger", {"event": "page.created"})
_add_step(client, session, aid, "trigger", {"event": "form.submitted"})
_add_step(client, session, aid, "action", {"type": "notify", "message": "fired"})
await auto_svc.fire_event("page.created", {"collection_id": 0})
await auto_svc.fire_event("form.submitted", {"collection_id": 0})
assert len([r for r in _runs(aid) if r["status"] == "fired"]) == 2
@pytest.mark.asyncio
async def test_mode_all_needs_every_trigger(client):
auto_svc.reset_all_pending()
session, _ = _login(client)
aid = _mkauto(client, session)
client.put(f"/workspace/automations/{aid}/mode", json={"mode": "all"},
cookies=_cookies(session))
_add_step(client, session, aid, "trigger", {"event": "page.created"})
_add_step(client, session, aid, "trigger", {"event": "form.submitted"})
_add_step(client, session, aid, "action", {"type": "notify", "message": "both"})
await auto_svc.fire_event("page.created", {"collection_id": 0})
assert _runs(aid) == []
await auto_svc.fire_event("form.submitted", {"collection_id": 0})
assert len([r for r in _runs(aid) if r["status"] == "fired"]) == 1
@pytest.mark.asyncio
async def test_form_submitted_trigger_end_to_end(client):
auto_svc.reset_all_pending()
session, _ = _login(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "trigger", {"event": "form.submitted"})
_add_step(client, session, aid, "action", {"type": "notify", "message": "form in"})
await auto_svc.fire_event("form.submitted", {"collection_id": 0, "row_id": 5})
runs = _runs(aid)
assert len(runs) == 1 and runs[0]["status"] == "fired"
# ── chains: order, interpolation, conditions, delay ────────────────────────
@pytest.mark.asyncio
async def test_chained_actions_interpolation(client):
session, uid = _login(client)
cid = _mkcollection(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "trigger", {"event": "page.created"})
_add_step(client, session, aid, "action",
{"type": "create_page", "collection_id": cid, "title": "Copy of {{title}}"})
_add_step(client, session, aid, "action",
{"type": "notify", "message": "made [[Copy of {{title}}]]"})
res = await auto_svc.run_automation(aid, "manual",
{"collection_id": cid, "title": "Alpha"})
assert res["status"] == "fired"
with get_conn() as conn:
row = conn.execute("SELECT title FROM collection_pages WHERE collection_id=?",
(cid,)).fetchone()
assert row and row["title"] == "Copy of Alpha"
@pytest.mark.asyncio
async def test_condition_step_blocks(client):
session, _ = _login(client)
cid = _mkcollection(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "trigger", {"event": "page.created"})
_add_step(client, session, aid, "condition",
{"property": "Status", "op": "eq", "value": "Done"})
_add_step(client, session, aid, "action", {"type": "notify", "message": "x"})
res = await auto_svc.run_automation(
aid, "event", {"collection_id": cid, "properties": {"Status": "Todo"}})
assert res["status"] == "skipped"
assert _runs(aid)[0]["status"] == "skipped"
res = await auto_svc.run_automation(
aid, "event", {"collection_id": cid, "properties": {"Status": "Done"}})
assert res["status"] == "fired"
@pytest.mark.asyncio
async def test_delay_step(client):
import time as _t
session, _ = _login(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "action", {"type": "notify", "message": "a"})
_add_step(client, session, aid, "delay", {"seconds": 1})
_add_step(client, session, aid, "action", {"type": "notify", "message": "b"})
start = _t.time()
res = await auto_svc.run_automation(aid, "manual", {})
assert res["status"] == "fired"
assert _t.time() - start >= 1.0
assert "delay 1s" in res["detail"]
# ── new actions ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_slack_action(client, monkeypatch):
session, _ = _login(client)
aid = _mkauto(client, session)
seen = {}
async def fake(url, text):
seen["url"] = url
seen["text"] = text
return "slack → (200)"
monkeypatch.setattr(auto_svc, "_post_slack", fake)
_add_step(client, session, aid, "action",
{"type": "slack", "webhook_url": "https://hooks.test/x", "text": "Hi {{title}}"})
res = await auto_svc.run_automation(aid, "manual", {"title": "Bob"})
assert res["status"] == "fired"
assert seen == {"url": "https://hooks.test/x", "text": "Hi Bob"}
@pytest.mark.asyncio
async def test_slack_secret_encrypted_at_rest(client, monkeypatch):
session, _ = _login(client)
aid = _mkauto(client, session)
seen = {}
async def fake(url, text):
seen["url"] = url
return "ok"
monkeypatch.setattr(auto_svc, "_post_slack", fake)
_add_step(client, session, aid, "action",
{"type": "slack", "webhook_url": "https://hooks.test/secret"})
with get_conn() as conn:
stored = conn.execute(
"SELECT config_json FROM automation_steps WHERE automation_id=?", (aid,)).fetchone()[0]
assert "hooks.test/secret" not in stored # encrypted at rest
res = await auto_svc.run_automation(aid, "manual", {})
assert res["status"] == "fired"
assert seen["url"] == "https://hooks.test/secret" # decrypted on execute
@pytest.mark.asyncio
async def test_email_action_no_smtp_skips(client):
session, uid = _login(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "action",
{"type": "email", "to": f"user:{uid}", "subject": "S", "body": "B"})
res = await auto_svc.run_automation(aid, "manual", {"created_by": uid})
# user has email but SMTP unconfigured in tests → skipped, not error
assert res["status"] == "fired"
assert "email" in res["detail"]
@pytest.mark.asyncio
async def test_forge_issue_action(client, monkeypatch):
session, _ = _login(client)
aid = _mkauto(client, session)
async def fake(provider, owner, repo, title, body, labels=None, user_id=None):
return f"{provider} issue #7 in {owner}/{repo}"
monkeypatch.setattr(auto_svc, "_create_forge_issue", fake)
_add_step(client, session, aid, "action",
{"type": "forge_issue", "provider": "gitea", "owner": "o", "repo": "r",
"title": "Bug {{title}}"})
res = await auto_svc.run_automation(aid, "manual", {"title": "X"})
assert res["status"] == "fired"
assert "gitea issue #7 in o/r" in res["detail"]
@pytest.mark.asyncio
async def test_forge_issue_needs_token(client):
session, uid = _login(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "action",
{"type": "forge_issue", "provider": "github", "owner": "o", "repo": "r",
"title": "T"})
res = await auto_svc.run_automation(aid, "manual", {"created_by": uid})
assert res["status"] == "error"
assert "token" in res["detail"].lower()
@pytest.mark.asyncio
async def test_agent_trigger_action(client, monkeypatch):
session, _ = _login(client)
aid = _mkauto(client, session)
async def fake(agent_id, user_id, workspace_id, message, context):
return f"agent {agent_id} ran: {message}"
monkeypatch.setattr(auto_svc, "_run_linked_agent", fake)
with get_conn() as conn:
conn.execute("INSERT INTO agents (name, system_instructions) VALUES ('A', 'Do X')")
agid = conn.execute("SELECT id FROM agents WHERE name='A'").fetchone()["id"]
conn.commit()
_add_step(client, session, aid, "action",
{"type": "agent_trigger", "agent_id": agid, "message": "go"})
res = await auto_svc.run_automation(aid, "manual", {})
assert res["status"] == "fired"
assert f"agent {agid} ran: go" in res["detail"]
@pytest.mark.asyncio
async def test_agent_trigger_missing_agent_errors(client):
session, _ = _login(client)
aid = _mkauto(client, session)
_add_step(client, session, aid, "action", {"type": "agent_trigger", "agent_id": 999999})
res = await auto_svc.run_automation(aid, "manual", {})
assert res["status"] == "error"
# ── native button ──────────────────────────────────────────────────────────
def _make_button(client, cid, aid, name="Ship it"):
r = client.post(f"/db/{cid}/properties/api",
json={"name": name, "prop_type": "button"})
assert r.status_code == 200, r.text
pid = r.json()["id"]
with get_conn() as conn:
conn.execute("UPDATE collection_properties SET button_automation_id=? WHERE id=?",
(aid, pid))
conn.commit()
return pid
def test_press_button_runs_automation(client):
session, _ = _login(client)
cid = _mkcollection(client)
rid = _mkrow(client, cid)
aid = _mkauto(client, session)
_add_step(client, session, aid, "action", {"type": "notify", "message": "shipped"})
prop_id = _make_button(client, cid, aid)
r = client.post("/api/automations/press-button",
json={"collection_id": cid, "row_id": rid, "property_id": prop_id})
assert r.status_code == 200, r.text
assert r.json()["status"] == "fired"
assert len([x for x in _runs(aid) if x["status"] == "fired"]) == 1
def test_press_button_validation(client):
session, _ = _login(client)
cid = _mkcollection(client)
rid = _mkrow(client, cid)
r = client.post(f"/db/{cid}/properties/api",
json={"name": "Plain", "prop_type": "text"})
text_pid = r.json()["id"]
r = client.post("/api/automations/press-button",
json={"collection_id": cid, "row_id": rid, "property_id": text_pid})
assert r.status_code == 400 # not a button
r = client.post("/api/automations/press-button",
json={"collection_id": cid, "row_id": rid, "property_id": 999999})
assert r.status_code == 400 # unknown
aid = _mkauto(client, session)
unlinked = _make_button(client, cid, aid, name="Unlinked")
with get_conn() as conn:
conn.execute("UPDATE collection_properties SET button_automation_id=NULL WHERE id=?",
(unlinked,))
conn.commit()
r = client.post("/api/automations/press-button",
json={"collection_id": cid, "row_id": rid, "property_id": unlinked})
assert r.status_code == 400 # no linked automation
# ── legacy compat: no double run ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_legacy_automation_single_run(client):
session, _ = _login(client)
cid = _mkcollection(client)
r = client.post("/workspace/automations",
json={"name": "Legacy", "trigger_type": "event", "event": "page.created",
"collection_id": cid,
"actions": [{"type": "notify", "message": "legacy"}]},
cookies=_cookies(session))
aid = r.json()["id"]
await auto_svc.fire_event("page.created", {"collection_id": cid})
assert len([x for x in _runs(aid) if x["status"] == "fired"]) == 1
# ── workers ────────────────────────────────────────────────────────────────
def _mkworker(client, session, **kw):
body = {"name": "W", "code_py": "result['x'] = 1"}
body.update(kw)
r = client.post("/api/v2/workers", json=body, cookies=_cookies(session))
assert r.status_code == 201, r.text
return r.json()
def test_workers_crud_and_auth(client):
session, _ = _login(client)
w = _mkworker(client, session, name="Hello")
assert w["slug"].startswith("hello") or w["slug"]
wid = w["id"]
r = client.get(f"/api/v2/workers/{wid}", cookies=_cookies(session))
assert r.status_code == 200
assert r.json()["code_py"] == "result['x'] = 1" # owner sees code
r = client.patch(f"/api/v2/workers/{wid}", json={"shared": True},
cookies=_cookies(session))
assert r.json()["shared"] == 1
r = client.get("/api/v2/workers", cookies=_cookies(session))
assert "X-Total-Count" in r.headers
r = client.post("/api/v2/workers", json={"name": "X", "code_py": "x = 1"})
assert r.status_code == 401
r = client.delete(f"/api/v2/workers/{wid}", cookies=_cookies(session))
assert r.status_code == 200
def test_worker_rejects_bad_code(client):
session, _ = _login(client)
r = client.post("/api/v2/workers",
json={"name": "Bad", "code_py": "import os\nresult['x']=1"},
cookies=_cookies(session))
assert r.status_code == 400
r = client.post("/api/v2/workers",
json={"name": "Bad2", "code_py": "open('/etc/passwd').read()"},
cookies=_cookies(session))
assert r.status_code == 400
r = client.post("/api/v2/workers",
json={"name": "Bad3", "code_py": "def broken(:\n pass"},
cookies=_cookies(session))
assert r.status_code == 400
def test_worker_run_ok(client):
session, _ = _login(client)
w = _mkworker(client, session, code_py="log('hello'); result['total'] = sum([1, 2, 3])")
r = client.post(f"/api/v2/workers/{w['id']}/run", json={"ctx": {}},
cookies=_cookies(session))
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "ok"
assert body["result"] == {"total": 6}
r = client.get(f"/api/v2/workers/{w['id']}/runs", cookies=_cookies(session))
assert r.json()["runs"][0]["status"] == "ok"
def test_worker_run_error(client):
session, _ = _login(client)
w = _mkworker(client, session, code_py="1 / 0")
r = client.post(f"/api/v2/workers/{w['id']}/run", json={}, cookies=_cookies(session))
assert r.json()["status"] == "error"
assert "ZeroDivision" in r.json()["error"]
def test_worker_run_timeout(client, monkeypatch):
from app.services import workers as wsvc
monkeypatch.setattr(wsvc, "RUN_TIMEOUT_S", 1)
session, _ = _login(client)
w = _mkworker(client, session, code_py="while True:\n pass")
r = client.post(f"/api/v2/workers/{w['id']}/run", json={}, cookies=_cookies(session))
assert r.json()["status"] == "timeout"
def test_worker_budget_enforced(client):
session, _ = _login(client)
w = _mkworker(client, session, code_py="result['x'] = 1")
with get_conn() as conn:
conn.execute("UPDATE workers SET daily_budget_s=1 WHERE id=?", (w["id"],))
conn.execute("INSERT INTO worker_runs (worker_id, status, duration_ms)"
" VALUES (?, 'ok', 60000)", (w["id"],))
conn.commit()
r = client.post(f"/api/v2/workers/{w['id']}/run", json={}, cookies=_cookies(session))
assert r.status_code == 429
def test_worker_fork_and_privacy(client):
s1, _ = _login(client)
s2, _ = _login(client)
w = _mkworker(client, s1, name="Private")
r = client.post(f"/api/v2/workers/{w['id']}/fork", cookies=_cookies(s2))
assert r.status_code == 403 # private
client.patch(f"/api/v2/workers/{w['id']}", json={"shared": True},
cookies=_cookies(s1))
r = client.post(f"/api/v2/workers/{w['id']}/fork", cookies=_cookies(s2))
assert r.status_code == 201
assert r.json()["from"] == w["id"]
def test_worker_private_run_forbidden(client):
s1, _ = _login(client)
s2, _ = _login(client)
w = _mkworker(client, s1)
r = client.post(f"/api/v2/workers/{w['id']}/run", json={}, cookies=_cookies(s2))
assert r.status_code == 403
def test_workers_usage(client):
session, _ = _login(client)
w = _mkworker(client, session)
client.post(f"/api/v2/workers/{w['id']}/run", json={}, cookies=_cookies(session))
r = client.get("/api/v2/workers-usage", cookies=_cookies(session))
assert r.json()["used_seconds_today"] >= 0
@pytest.mark.asyncio
async def test_run_due_workers(client):
from app.services import workers as wsvc
session, _ = _login(client)
w = _mkworker(client, session, code_py="result['cron'] = True",
schedule_cron="@hourly")
fired = await wsvc.run_due_workers()
assert fired >= 1
with get_conn() as conn:
row = conn.execute("SELECT status FROM worker_runs WHERE worker_id=? ORDER BY id DESC",
(w["id"],)).fetchone()
assert row and row["status"] == "ok"
def test_worker_code_hidden_from_strangers(client):
s1, _ = _login(client)
s2, _ = _login(client)
w = _mkworker(client, s1)
client.patch(f"/api/v2/workers/{w['id']}", json={"shared": True},
cookies=_cookies(s1))
r = client.get(f"/api/v2/workers/{w['id']}", cookies=_cookies(s2))
assert r.status_code == 200
assert "code_py" not in r.json() # no include_code for non-owner
+381
View File
@@ -0,0 +1,381 @@
"""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 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):
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):
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
+612
View File
@@ -0,0 +1,612 @@
"""FlowDeck — v7.2.0 Enterprise: SCIM 2.0, TOTP 2FA, WebAuthn, audit, agent governance.
Covers migration 28, SCIM CRUD + suspend/revoke, SCIM token admin, TOTP
setup/activate/login gating + backup codes, domain claims with SSO
enforcement, passkey routes, unified audit log (+CSV) and agent policies
with the human approval gate.
"""
from __future__ import annotations
import json
import secrets
from app.db import get_conn
# ── helpers ────────────────────────────────────────────────────────────────
def _make_user(login=None, is_admin=0, email=None):
login = login or f"v72_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?,?,?,?)",
(login, login, email if email is not None else f"{login}@test.com", is_admin))
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
return uid, login
def _session(uid, login):
from app.auth.session import SessionManager
return SessionManager.create_session({"id": uid, "login": login})
def _admin_client(client):
uid, login = _make_user(is_admin=1)
return client, {"flowdeck_session": _session(uid, login)}
def _mk_scim_token(client, cookies, name="IT"):
r = client.post("/api/v2/scim/tokens", json={"name": name}, cookies=cookies)
assert r.status_code == 201, r.text
return r.json()["token"]
# ── migration 28 ───────────────────────────────────────────────────────────
def test_migration_28_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 ("scim_tokens", "domain_claims", "webauthn_credentials",
"agent_policies", "agent_approvals"):
assert t in names, f"missing {t}"
def test_migration_28_user_columns(client):
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
assert {"totp_secret_enc", "totp_backup_hashes", "is_active"} <= cols
# ── SCIM tokens ────────────────────────────────────────────────────────────
def test_scim_token_requires_auth(client):
assert client.get("/scim/v2/Users").status_code == 401
def test_scim_token_admin_only(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.post("/api/v2/scim/tokens", json={"name": "x"}, cookies=c).status_code == 403
def test_scim_token_create_and_list(client):
client, c = _admin_client(client)
r = client.post("/api/v2/scim/tokens", json={"name": "Okta"}, cookies=c)
assert r.status_code == 201, r.text
body = r.json()
assert body["token"].startswith("scim_")
lst = client.get("/api/v2/scim/tokens", cookies=c)
assert lst.status_code == 200
assert any(t["name"] == "Okta" for t in lst.json()["tokens"])
# raw token is never stored in clear
with get_conn() as conn:
row = conn.execute("SELECT token_hash FROM scim_tokens ORDER BY id DESC").fetchone()
assert body["token"] not in row["token_hash"]
def test_scim_token_revoke(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
r = client.delete("/api/v2/scim/tokens/1", cookies=c)
assert r.status_code == 200
assert client.get("/scim/v2/Users",
headers={"Authorization": f"Bearer {token}"}).status_code == 401
# ── SCIM /Users ────────────────────────────────────────────────────────────
def test_scim_list_requires_bearer(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
r = client.get("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json()["schemas"] == ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]
def test_scim_create_user(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
r = client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"},
json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "jane.smith",
"name": {"formatted": "Jane Smith"},
"emails": [{"value": "[email protected]"}]})
assert r.status_code == 201, r.text
body = r.json()
assert body["userName"] == "jane.smith"
assert body["active"] is True
with get_conn() as conn:
row = conn.execute("SELECT email, auth_method FROM users WHERE login=?",
("jane.smith",)).fetchone()
assert row["email"] == "[email protected]"
assert row["auth_method"] == "saml"
def test_scim_create_duplicate_conflict(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
payload = {"userName": "dup.user", "emails": [{"value": "[email protected]"}]}
assert client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"},
json=payload).status_code == 201
assert client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"},
json=payload).status_code == 409
def test_scim_get_user(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, _ = _make_user(login="scim.get.me")
r = client.get(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json()["id"] == str(uid)
assert client.get("/scim/v2/Users/999999",
headers={"Authorization": f"Bearer {token}"}).status_code == 404
def test_scim_patch_deactivate_revokes_sessions(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, login = _make_user(login="scim.suspend.me")
with get_conn() as conn:
conn.execute("INSERT INTO user_sessions (user_id, ip_address, user_agent)"
" VALUES (?, '10.0.0.9', 'pytest')", (uid,))
conn.commit()
r = client.patch(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"},
json={"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "active", "value": False}]})
assert r.status_code == 200, r.text
assert r.json()["active"] is False
with get_conn() as conn:
assert conn.execute("SELECT is_active FROM users WHERE id=?", (uid,)).fetchone()[0] == 0
assert conn.execute("SELECT revoked FROM user_sessions WHERE user_id=?",
(uid,)).fetchone()["revoked"] == 1
def test_scim_put_updates_fields(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, _ = _make_user(login="scim.put.me", email="[email protected]")
r = client.put(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"},
json={"userName": "scim.put.renamed",
"name": {"formatted": "Renamed"},
"emails": [{"value": "[email protected]"}], "active": True})
assert r.status_code == 200, r.text
with get_conn() as conn:
row = conn.execute("SELECT login, email, full_name FROM users WHERE id=?",
(uid,)).fetchone()
assert row["login"] == "scim.put.renamed"
assert row["email"] == "[email protected]"
assert row["full_name"] == "Renamed"
def test_scim_delete_suspends(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, _ = _make_user(login="scim.del.me")
r = client.delete(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 204
with get_conn() as conn:
assert conn.execute("SELECT is_active FROM users WHERE id=?",
(uid,)).fetchone()[0] == 0
# ── TOTP 2FA ───────────────────────────────────────────────────────────────
def test_2fa_status_disabled_by_default(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.get("/auth/2fa/status", cookies=c)
assert r.status_code == 200
assert r.json() == {"enabled": False, "backup_remaining": 0}
def test_2fa_setup_returns_secret_and_uri(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.post("/auth/2fa/setup", cookies=c)
assert r.status_code == 200, r.text
body = r.json()
assert body["secret"]
assert body["otpauth_url"].startswith("otpauth://totp/FlowDeck:")
def test_2fa_activate_rejects_bad_code(client):
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
r = client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": "000000"})
assert r.status_code == 400
assert not t2f.is_enabled(uid)
def test_2fa_activate_success_returns_backup_codes(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
r = client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": pyotp.TOTP(secret).now()})
assert r.status_code == 200, r.text
codes = r.json()["backup_codes"]
assert len(codes) == 10 and len(set(codes)) == 10
assert t2f.is_enabled(uid)
assert t2f.remaining_backup_codes(uid) == 10
def test_2fa_secret_encrypted_at_rest(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": pyotp.TOTP(secret).now()})
with get_conn() as conn:
stored = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?", (uid,)).fetchone()[0]
assert secret not in stored
assert t2f.verify_code(uid, pyotp.TOTP(secret).now()) is True
def test_2fa_verify_totp_and_backup_code(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
codes = client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret,
"code": pyotp.TOTP(secret).now()}).json()["backup_codes"]
assert t2f.verify_code(uid, pyotp.TOTP(secret).now()) is True
assert t2f.verify_code(uid, codes[0]) is True
assert t2f.verify_code(uid, codes[0]) is False # single use
assert t2f.remaining_backup_codes(uid) == 9
def test_2fa_verify_rejects_bad_code(client):
from app.services import two_factor as t2f
uid, _ = _make_user()
assert t2f.verify_code(uid, "123456") is False
def test_2fa_disable(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": pyotp.TOTP(secret).now()})
assert client.post("/auth/2fa/disable", cookies=c).status_code == 200
assert t2f.is_enabled(uid) is False
def test_2fa_routes_require_session(client):
assert client.get("/auth/2fa/status").status_code == 401
assert client.post("/auth/2fa/setup").status_code == 401
def test_2fa_pending_token_roundtrip(client):
from app.services import two_factor as t2f
uid, _ = _make_user()
token = t2f.mint_pending(uid)
assert t2f.redeem_pending(token) == uid
assert t2f.redeem_pending("forged") is None
assert t2f.redeem_pending(t2f.mint_pending(uid), max_age=-1) is None
# ── domain claims ──────────────────────────────────────────────────────────
def test_domain_claim_create_and_list(client):
client, c = _admin_client(client)
r = client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "Corp.Example", "auto_join_role": "viewer",
"enforce_sso": True})
assert r.status_code == 201, r.text
body = r.json()
assert body["domain"] == "corp.example"
assert body["expected_content"].startswith("flowdeck-verify=")
lst = client.get("/api/v2/domain-claims", cookies=c)
assert lst.status_code == 200
# txt token is never leaked by the list endpoint
assert "txt_token" not in lst.json()["domains"][0]
def test_domain_claim_invalid_domain(client):
client, c = _admin_client(client)
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "not-a-domain"}).status_code == 400
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "a/b.example"}).status_code == 400
def test_domain_claim_duplicate(client):
client, c = _admin_client(client)
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "dup.example"}).status_code == 201
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "dup.example"}).status_code == 409
def test_domain_claim_delete(client):
client, c = _admin_client(client)
did = client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "gone.example"}).json()["id"]
assert client.delete(f"/api/v2/domain-claims/{did}", cookies=c).status_code == 200
assert client.get("/api/v2/domain-claims", cookies=c).json()["domains"] == []
def test_domain_routes_require_admin(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.get("/api/v2/domain-claims", cookies=c).status_code == 403
# ── WebAuthn / passkeys ────────────────────────────────────────────────────
def test_webauthn_register_begin(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.post("/auth/webauthn/register/begin", cookies=c)
assert r.status_code == 200, r.text
body = r.json()
assert body["challenge"] and body["rp"]["id"]
assert body["user"]["id"]
def test_webauthn_register_begin_requires_session(client):
assert client.post("/auth/webauthn/register/begin").status_code == 401
def test_webauthn_register_finish_rejects_bad_credential(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
client.post("/auth/webauthn/register/begin", cookies=c)
r = client.post("/auth/webauthn/register/finish", cookies=c,
json={"credential": {"id": "abc", "type": "public-key"}})
assert r.status_code == 400
def test_webauthn_register_finish_expired_challenge(client):
from app.routers import webauthn as wa
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.post("/auth/webauthn/register/finish", cookies=c,
json={"credential": {"id": "abc", "type": "public-key"}})
assert r.status_code == 400
assert "Challenge" in r.json()["detail"]
wa.reset_challenges()
def test_webauthn_login_begin_no_passkeys(client):
uid, login = _make_user()
r = client.post("/auth/webauthn/login/begin", json={"login": login})
assert r.status_code == 400
assert "passkey" in r.json()["detail"].lower()
def test_webauthn_login_begin_unknown_user(client):
r = client.post("/auth/webauthn/login/begin", json={"login": "ghost_user_xyz"})
assert r.status_code == 401
def test_webauthn_login_begin_requires_login(client):
assert client.post("/auth/webauthn/login/begin", json={}).status_code == 400
def test_webauthn_keys_empty_and_delete_404(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.get("/auth/webauthn/keys", cookies=c).json() == {"keys": []}
assert client.delete("/auth/webauthn/keys/9999", cookies=c).status_code == 404
# ── unified audit log ──────────────────────────────────────────────────────
def test_audit_requires_admin(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.get("/api/v2/audit/logs", cookies=c).status_code == 403
assert client.get("/api/v2/audit/logs").status_code == 401
def test_audit_merges_sources(client):
client, c = _admin_client(client)
aid, alogin = _make_user(is_admin=1)
with get_conn() as conn:
conn.execute(
"""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id,
detail, ip_address)
VALUES (?, 'api.page.create', 'page', '12', 'created', '10.0.0.1')""",
(aid,))
conn.execute(
"""INSERT INTO permission_audit_log (performed_by, action, resource_type,
resource_id, target_user_id, old_role, new_role)
VALUES (?, 'role.change', 'workspace', '1', 42, 'viewer', 'editor')""",
(aid,))
conn.execute(
"""INSERT INTO sso_login_history (user_id, provider_name, provider_type,
success, ip_address, sso_identifier)
VALUES (?, 'okta', 'oidc', 1, '10.0.0.2', '[email protected]')""",
(aid,))
conn.commit()
r = client.get("/api/v2/audit/logs", cookies=c)
assert r.status_code == 200, r.text
logs = r.json()["logs"]
sources = {row["source"] for row in logs}
assert {"api", "permissions", "sso"} <= sources
assert all({"at", "source", "actor", "action", "resource", "detail"} <= set(row)
for row in logs)
def test_audit_source_filter(client):
client, c = _admin_client(client)
aid, _ = _make_user(is_admin=1)
with get_conn() as conn:
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'api.x', 'page', '1')""", (aid,))
conn.commit()
r = client.get("/api/v2/audit/logs?source=sso", cookies=c)
assert all(row["source"] == "sso" for row in r.json()["logs"])
assert client.get("/api/v2/audit/logs?source=bogus", cookies=c).status_code == 400
def test_audit_actor_and_action_filters(client):
client, c = _admin_client(client)
uid, _ = _make_user()
with get_conn() as conn:
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'page.create', 'page', '1')""", (uid,))
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'page.delete', 'page', '2')""", (uid,))
conn.commit()
r = client.get(f"/api/v2/audit/logs?actor={uid}&action=create", cookies=c)
assert r.status_code == 200
assert len(r.json()["logs"]) == 1
assert r.json()["logs"][0]["action"] == "page.create"
def test_audit_csv_export(client):
client, c = _admin_client(client)
aid, _ = _make_user(is_admin=1)
with get_conn() as conn:
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'page.create', 'page', '1')""", (aid,))
conn.commit()
r = client.get("/api/v2/audit/logs?format=csv", cookies=c)
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/csv")
assert "attachment" in r.headers["content-disposition"]
text = r.text
assert text.splitlines()[0].startswith("at,source,actor,action")
assert "page.create" in text
def test_audit_pagination(client):
client, c = _admin_client(client)
aid, _ = _make_user(is_admin=1)
with get_conn() as conn:
for i in range(10):
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type,
resource_id)
VALUES (?, 'page.create', 'page', ?)""", (aid, i))
conn.commit()
r = client.get("/api/v2/audit/logs?limit=3&offset=0", cookies=c)
assert len(r.json()["logs"]) == 3
assert r.json()["limit"] == 3
# ── agent governance ───────────────────────────────────────────────────────
def test_policy_defaults(client):
from app.services.agent_policies import get_policy
p = get_policy(999999)
assert p["allowed_tools"] is None
assert p["require_approval"] is False
def test_policy_upsert_and_list(client):
client, c = _admin_client(client)
r = client.post("/api/v2/agent-policies", cookies=c,
json={"workspace_id": None, "allowed_tools": ["search", "read_page"],
"max_steps": 5, "require_approval": True})
assert r.status_code == 201, r.text
body = r.json()
assert json.loads(body["allowed_tools_json"]) == ["search", "read_page"]
assert body["require_approval"] == 1
lst = client.get("/api/v2/agent-policies", cookies=c)
assert len(lst.json()["policies"]) == 1
def test_policy_max_steps_clamped(client):
client, c = _admin_client(client)
r = client.post("/api/v2/agent-policies", cookies=c,
json={"max_steps": 9999, "allowed_tools": None})
assert r.json()["max_steps"] == 50
def test_policy_rejects_bad_tools(client):
client, c = _admin_client(client)
assert client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": "search"}).status_code == 400
def test_check_tool_denies_out_of_scope(client):
from app.services.agent_policies import check_tool, get_policy
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["search"], "max_steps": 5})
uid, _ = _make_user()
out = check_tool(uid, None, "delete_page", is_write=True, conversation_id=0)
assert out["allowed"] is False
assert "policy scope" in out["reason"]
assert get_policy(None)["max_steps"] == 5
def test_check_tool_allows_reads(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["search"], "require_approval": True})
uid, _ = _make_user()
assert check_tool(uid, None, "search", is_write=False)["allowed"] is True
def test_check_tool_requires_approval_for_writes(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["search", "create_page"],
"require_approval": True})
uid, _ = _make_user()
out = check_tool(uid, None, "create_page", is_write=True, conversation_id=0)
assert out["allowed"] is False
assert out["approval_id"]
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(out["approval_id"],)).fetchone()
assert row["status"] == "pending"
assert row["tool"] == "create_page"
assert row["requester_id"] == uid
def test_approval_decision_flow(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["create_page"], "require_approval": True})
uid, _ = _make_user()
aid = check_tool(uid, None, "create_page", is_write=True)["approval_id"]
q = client.get("/api/v2/agent-approvals", cookies=c)
assert any(a["id"] == aid for a in q.json()["approvals"])
r = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c,
json={"approve": True})
assert r.status_code == 200
assert r.json()["status"] == "approved"
# a decided approval cannot be decided again
assert client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c,
json={"approve": False}).status_code == 404
def test_approval_rejection(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["delete_page"], "require_approval": True})
uid, _ = _make_user()
aid = check_tool(uid, None, "delete_page", is_write=True)["approval_id"]
r = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c,
json={"approve": False})
assert r.json()["status"] == "rejected"
def test_governance_routes_require_auth(client):
assert client.get("/api/v2/agent-policies").status_code == 401
assert client.get("/api/v2/agent-approvals").status_code == 401
File diff suppressed because it is too large Load Diff