Files
flowdeck/tests/test_automations.py
T
bruno d125eb399e
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s
fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
2026-09-30 22:04:13 -04:00

354 lines
13 KiB
Python

"""FlowDeck — v5.1.0 automations: rules engine (trigger + condition + action).
Covers migration v5, the automations CRUD API, the if-this-then-that engine
(condition matching + action execution on collection-page events), manual/button
runs, and the cron due logic.
"""
import os
import tempfile
import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient
@pytest.fixture
def client():
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
from app.db import get_conn, init_db
from app.main import app
init_db()
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
conn.commit()
tc = TestClient(app)
yield login_test_client(tc)
os.unlink(db_path)
def _make_collection(client, name="Autos DB"):
r = client.post("/db/api", json={"name": name, "schema": [
{"id": 1, "name": "Status", "prop_type": "select"},
{"id": 2, "name": "Priority", "prop_type": "text"},
]})
return r.json()["id"]
def _make_page(client, coll_id, title="Page", props=None):
r = client.post(f"/db/{coll_id}/pages/api", json={"title": title, "properties": props or {}})
return r.json()["id"]
# ── Migration v5 ──
def test_automations_tables_created_by_migration(client):
from app.db import get_conn
with get_conn() as conn:
tables = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert "automations" in tables
assert "automation_runs" in tables
cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
assert {"trigger_type", "event", "condition_json", "actions_json", "enabled"} <= cols
runs = {r[1] for r in conn.execute("PRAGMA table_info(automation_runs)").fetchall()}
assert {"automation_id", "status", "detail", "trigger_source"} <= runs
# ── Condition matching (service) ──
def test_condition_matching_service():
from app.services.automations import evaluate_conditions
assert evaluate_conditions("[]", {"Status": "Done"}) is True
assert evaluate_conditions('[{"property":"Status","op":"eq","value":"Done"}]', {"Status": "Done"}) is True
assert evaluate_conditions('[{"property":"Status","op":"eq","value":"Done"}]', {"Status": "Todo"}) is False
assert evaluate_conditions('[{"property":"Priority","op":"is_empty"}]', {"Status": "Done"}) is True
assert evaluate_conditions('[{"property":"Priority","op":"is_not_empty"}]', {"Priority": "High"}) is True
assert evaluate_conditions('[{"property":"Title","op":"contains","value":"urgent"}]', {"Title": "very urgent"}) is True
# AND semantics
assert evaluate_conditions(
'[{"property":"Status","op":"eq","value":"Done"},{"property":"Priority","op":"neq","value":"Low"}]',
{"Status": "Done", "Priority": "High"},
) is True
assert evaluate_conditions(
'[{"property":"Status","op":"eq","value":"Done"},{"property":"Priority","op":"neq","value":"Low"}]',
{"Status": "Done", "Priority": "Low"},
) is False
# changed only valid with before_props
assert evaluate_conditions('[{"property":"Status","op":"changed"}]', {"Status": "Done"},
{"Status": "Todo"}) is True
assert evaluate_conditions('[{"property":"Status","op":"changed"}]', {"Status": "Done"},
{"Status": "Done"}) is False
# ── CRUD API ──
def test_automation_crud(client):
# create
r = client.post("/workspace/automations", json={
"name": "Notify when done",
"trigger_type": "event",
"event": "page.updated",
"collection_id": None,
"condition_json": '[{"property":"Status","op":"eq","value":"Done"}]',
"actions_json": '[{"type":"notify","message":"Reacted to Done"}]',
})
assert r.status_code == 200, r.text
aid = r.json()["id"]
# list
d = client.get("/workspace/automations").json()
assert len(d["automations"]) == 1
assert d["automations"][0]["name"] == "Notify when done"
# get
assert client.get(f"/workspace/automations/{aid}").json()["id"] == aid
# update
r = client.put(f"/workspace/automations/{aid}", json={
"name": "Renamed",
"trigger_type": "event",
"event": "page.created",
"collection_id": None,
"condition_json": "[]",
"actions_json": "[]",
})
assert r.status_code == 200
assert client.get(f"/workspace/automations/{aid}").json()["name"] == "Renamed"
# validation: name required
r = client.post("/workspace/automations", json={"name": " "})
assert r.status_code == 400
# validation: bad actions JSON
r = client.post("/workspace/automations", json={
"name": "Bad", "trigger_type": "event", "event": "page.created",
"actions_json": "not json",
})
assert r.status_code == 400
# delete
assert client.delete(f"/workspace/automations/{aid}").status_code == 200
# After delete: the app's HTML 404 handler redirects non-API GETs to /workspaces.
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT id FROM automations WHERE id=?", (aid,)).fetchone()
assert row is None
def test_automation_scope_collection_filter(client):
c1 = _make_collection(client, "C1")
c2 = _make_collection(client, "C2")
only_c1 = client.post("/workspace/automations", json={
"name": "Only C1",
"trigger_type": "event",
"event": "page.created",
"collection_id": c1,
"condition_json": "[]",
"actions_json": '[{"type":"notify","message":"page in C1"}]',
}).json()["id"]
p_c1 = _make_page(client, c1, props={"Status": "Todo"})
_make_page(client, c2, props={"Status": "Todo"})
from app.db import get_conn
with get_conn() as conn:
runs_c1 = conn.execute(
"SELECT * FROM automation_runs WHERE automation_id=? AND status='fired'",
(only_c1,),
).fetchall()
# Only page in C1 should have triggered the automation.
assert len(runs_c1) == 1
assert runs_c1[0]["page_id"] == p_c1
# ── Event engine: fire on collection-page events ──
def test_event_trigger_set_property_action(client):
coll = _make_collection(client)
pid = _make_page(client, coll, props={"Status": "Todo"})
client.post("/workspace/automations", json={
"name": "Mark archived",
"trigger_type": "event",
"event": "page.updated",
"collection_id": coll,
"condition_json": '[{"property":"Status","op":"eq","value":"Archived"}]',
"actions_json": '[{"type":"set_property","property":"Priority","value":"Low"}]',
})
assert client.put(f"/db/pages/{pid}/api", json={
"properties": {"Status": "Archived", "Priority": "High"},
}).status_code == 200
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?", (pid,)
).fetchone()
props = __import__("json").loads(row["property_values_json"])
assert props["Priority"] == "Low" # action overrode the value
assert props["Status"] == "Archived" # original value preserved
# The action should not re-fire (DB-level write, not via API)
with get_conn() as conn:
runs = conn.execute(
"SELECT COUNT(*) AS c FROM automation_runs WHERE status='fired'"
).fetchone()["c"]
assert runs == 1
def test_event_trigger_skipped_when_condition_fails(client):
coll = _make_collection(client)
pid = _make_page(client, coll, props={"Status": "Todo"})
client.post("/workspace/automations", json={
"name": "Skip unless Done",
"trigger_type": "event",
"event": "page.updated",
"collection_id": coll,
"condition_json": '[{"property":"Status","op":"eq","value":"Done"}]',
"actions_json": '[{"type":"notify","message":"done!"}]',
})
client.put(f"/db/pages/{pid}/api", json={"properties": {"Status": "Todo"}})
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT status FROM automation_runs WHERE automation_id=(SELECT id FROM automations LIMIT 1)"
).fetchone()
assert row["status"] == "skipped"
def test_page_deleted_fires_event(client):
coll = _make_collection(client)
pid = _make_page(client, coll)
client.post("/workspace/automations", json={
"name": "On delete",
"trigger_type": "event",
"event": "page.deleted",
"collection_id": None,
"condition_json": "[]",
"actions_json": '[{"type":"notify","message":"page deleted"}]',
})
assert client.delete(f"/db/pages/{pid}/api").status_code == 200
from app.db import get_conn
with get_conn() as conn:
runs = conn.execute("SELECT * FROM automation_runs WHERE status='fired'").fetchall()
assert len(runs) == 1
assert runs[0]["trigger_source"] == "event"
def test_create_page_action(client):
coll = _make_collection(client)
target = _make_collection(client, "Target")
_make_page(client, coll, props={"Status": "Go"})
client.post("/workspace/automations", json={
"name": "Spawn a page",
"trigger_type": "event",
"event": "page.updated",
"collection_id": coll,
"condition_json": '[{"property":"Status","op":"contains","value":"Go"}]',
"actions_json": json_str([{"type": "create_page", "collection_id": target,
"title": "Generated from {{title}}",
"properties": {"Status": "Todo"}}]),
})
from app.db import get_conn
with get_conn() as conn:
src = conn.execute(
"SELECT id FROM collection_pages WHERE collection_id=? ORDER BY id DESC LIMIT 1",
(coll,),
).fetchone()["id"]
client.put(f"/db/pages/{src}/api", json={"properties": {"Status": "Go"}})
with get_conn() as conn:
rows = conn.execute(
"SELECT title FROM collection_pages WHERE collection_id=? ORDER BY id DESC",
(target,),
).fetchall()
assert any("Generated from" in (r["title"] or "") for r in rows)
# ── Manual / button run ──
def test_button_run_endpoint(client):
coll = _make_collection(client)
pid = _make_page(client, coll, props={"Status": "Todo"})
auto_id = client.post("/workspace/automations", json={
"name": "Button action",
"trigger_type": "event",
"event": "page.updated",
"collection_id": None,
"condition_json": "[]",
"actions_json": '[{"type":"set_property","property":"Status","value":"Clicked"}]',
}).json()["id"]
r = client.post(f"/api/automations/{auto_id}/run", json={"page_id": pid})
assert r.status_code == 200
assert r.json()["status"] == "fired"
from app.db import get_conn
with get_conn() as conn:
props = __import__("json").loads(conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?", (pid,)
).fetchone()["property_values_json"])
assert props["Status"] == "Clicked"
# history recorded
d = client.get(f"/workspace/automations/{auto_id}/runs").json()
assert len(d["runs"]) == 1
assert d["runs"][0]["trigger_source"] == "button"
def test_manual_run_skipped_for_disabled(client):
auto_id = client.post("/workspace/automations", json={
"name": "Disabled",
"trigger_type": "event",
"event": "page.created",
"collection_id": None,
"condition_json": "[]",
"actions_json": "[]",
"enabled": False,
}).json()["id"]
r = client.post(f"/workspace/automations/{auto_id}/run", json={})
assert r.json()["status"] == "skipped"
# ── Cron due logic ──
def test_cron_due():
from datetime import datetime, timedelta
from app.services.automations import cron_due
now = datetime(2026, 9, 7, 10, 15, 0)
# every 15 minutes: due when >= 15 minutes elapsed, otherwise not
last = (now - timedelta(minutes=15)).isoformat()
assert cron_due("*/15 * * * *", last, now) is True
last = (now - timedelta(minutes=10)).isoformat()
assert cron_due("*/15 * * * *", last, now) is False
# no last run → always due
assert cron_due("*/30 * * * *", None, now) is True
# fixed minute
assert cron_due("15 * * * *", None, now) is True
assert cron_due("45 * * * *", None, now) is False
# @hourly / @daily
assert cron_due("@hourly", None, now) is True
assert cron_due("@daily", (now - timedelta(hours=25)).isoformat(), now) is True
assert cron_due("@daily", (now - timedelta(hours=2)).isoformat(), now) is False
assert cron_due(" ", None, now) is False
def json_str(obj):
import json
return json.dumps(obj)