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
|
||||
Reference in New Issue
Block a user