- 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)
554 lines
23 KiB
Python
554 lines
23 KiB
Python
"""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
|