feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- 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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user