| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- """The projectdelete bundle: admin-only project deletion with a STRICTLY
- ownership-based cascade. The deletion takes the project row and the rows
- it owns (its tasks, its goals, and those rows' children); rows in OTHER
- projects that merely reference the deleted project (co-developed /
- reference links, stack membership) are NEVER taken — only the pointer
- rows naming it are tidied away. Covers the API wire (auth, 404, the
- live-task guard), the cascade's correctness, the cross-project survival
- law, standalone-task immunity, and the page's typed-name confirmation.
- """
- from harness import * # noqa: F401,F403 — the shared wire helpers
- def sql_one(query, params=()):
- """The first column of the first row, or None."""
- conn = db()
- try:
- row = conn.execute(query, params).fetchone()
- return row[0] if row is not None else None
- finally:
- conn.close()
- def sql_all(query, params=()):
- conn = db()
- try:
- return conn.execute(query, params).fetchall()
- finally:
- conn.close()
- def sql_exec(*statements):
- """Runs (sql, params...) statements in one commit; params are the
- trailing tuple entry when present."""
- conn = db()
- try:
- for statement in statements:
- sql = statement[0]
- params = statement[1] if len(statement) > 1 else ()
- if not isinstance(params, (tuple, list)):
- params = (params,)
- conn.execute(sql, tuple(params))
- conn.commit()
- finally:
- conn.close()
- def make_project(name):
- """Creates a project row straight in the DB, returning its id."""
- sql_exec(("insert into projects (name, git_url, default_branch, created_at)"
- " values (?, ?, 'main', strftime('%s','now'))",
- (name, "https://example.com/%s.git" % name)))
- return int(sql_one("select id from projects where name = ?", (name,)))
- def delete_api(auth, project_id):
- """DELETE /api/projects/{id} with the caller's statum session."""
- status, body = http("DELETE", "/api/projects/%d" % project_id, None, {
- "X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
- return status, parse_json(body)
- def parse_json(text):
- """A JSON object body, or {} when the body is empty or not JSON."""
- try:
- parsed = json.loads(text)
- return parsed if isinstance(parsed, dict) else {}
- except (ValueError, TypeError):
- return {}
- def run(ctx):
- auth, fake = ctx.auth, ctx.fake
- print("== the API wire: auth, 404, method ==")
- # Unauthenticated: no slot header at all.
- status, body = http("DELETE", "/api/projects/1")
- body = parse_json(body)
- check(status == 401 and body.get("ok") is False,
- "DELETE without a session answers 401 JSON")
- # Unknown project id: 404 for the admin.
- status, body = delete_api(auth, 999999)
- check(status == 404 and "no project exists" in body.get("error", "").lower(),
- "DELETE of an unknown project answers 404")
- # A non-admin user (created through the users page) is refused 403
- # even with a genuine session.
- users_state = slot_public(auth, "/users", "users")
- act(auth, users_state["create"], {
- "username": "nonadmin", "email": "nonadmin@example.com",
- "forename": "Non", "surname": "Admin",
- "date_of_birth": "1990-01-01",
- "password": "nonadmin1", "confirm_password": "nonadmin1"})
- plain = login("nonadmin", "nonadmin1")
- victim_id = make_project("auth-probe")
- status, body = delete_api(plain, victim_id)
- check(status == 403, "DELETE by a non-admin answers 403")
- check(sql_one("select count(*) from projects where id = ?", (victim_id,)) == 1,
- "the refused DELETE left the project row alone")
- sql_exec(("delete from projects where id = ?", (victim_id,)))
- print("== cascade: the project and its OWNED rows go, in full ==")
- # The doomed project owns: two plain tasks (with comments, blocks,
- # rule attachments, capability requirements, sessions + events) and
- # one goal (with a goal-run task, goal links, goal-scoped events).
- doomed = make_project("doomed")
- keeper = make_project("keeper")
- other = make_project("other-host") # hosts cross-project survivors
- standalone_host = make_project("standalone-host")
- sql_exec(
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (?, 0, 'task', 'implement', 'human', 'Doomed draft',"
- " 'x', 'draft', 5, strftime('%s','now'))", (doomed,)),
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (?, 0, 'task', 'implement', 'human', 'Doomed done',"
- " 'x', 'done', 5, strftime('%s','now'))", (doomed,)),
- ("insert into goals (project_id, title, brief, branch_name, status,"
- " creator, created_at)"
- " values (?, 'Doomed goal', 'Everything goes.', 'goal/doomed',"
- " 'complete', 'human', strftime('%s','now'))", (doomed,)),
- )
- doomed_goal = int(sql_one("select id from goals where project_id = ?", (doomed,)))
- sql_exec(
- # The goal's own goal-run task (owned via project_id AND goal_id).
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (?, ?, 'goal_run', 'orchestrate', 'goal', 'Goal run',"
- " 'x', 'done', 3, strftime('%s','now'))", (doomed, doomed_goal)),
- # A goal_links row on the doomed goal pointing at `keeper`.
- ("insert into goal_links (goal_id, project_id, relationship)"
- " values (?, ?, 'reference')", (doomed_goal, keeper)),
- )
- doomed_tasks = [int(row[0]) for row in sql_all(
- "select id from tasks where project_id = ?", (doomed,))]
- # Child rows of the first doomed task: comment, block, rule attach,
- # capability attach, a session and events (session-carried AND
- # task-scoped).
- first_task = doomed_tasks[0]
- sql_exec(
- ("insert into rules (name, body, created_at)"
- " values ('doomed-rule', 'body', strftime('%s','now'))",),
- ("insert into capabilities (name, created_at)"
- " values ('doomed-cap', strftime('%s','now'))",),
- ("insert into task_comments (task_id, author, body, created_at)"
- " values (?, 'agent', 'a comment', strftime('%s','now'))", (first_task,)),
- ("insert into task_blocks (task_id, question, created_at)"
- " values (?, 'asked?', strftime('%s','now'))", (first_task,)),
- ("insert into task_rules (rule_id, task_id, created_at)"
- " values ((select id from rules where name = 'doomed-rule'), ?,"
- " strftime('%s','now'))", (first_task,)),
- ("insert into task_capabilities (capability_id, task_id)"
- " values ((select id from capabilities where name = 'doomed-cap'), ?)",
- (first_task,)),
- ("insert into saddles (name, host, status, token)"
- " values ('doomed-saddle', '127.0.0.1:9', 'idle', 't')",),
- ("insert into sessions (task_id, saddle_id, started_at)"
- " values (?, (select id from saddles where name = 'doomed-saddle'),"
- " strftime('%s','now'))", (first_task,)),
- )
- session_id = int(sql_one("select id from sessions where task_id = ?", (first_task,)))
- sql_exec(
- ("insert into events (session_id, seq, kind, actor, scope_type,"
- " scope_id, payload, created_at)"
- " values (?, 1, 'turn_text', 'model', 'task', ?, '{}',"
- " strftime('%s','now'))", (session_id, first_task)),
- ("insert into events (session_id, seq, kind, actor, scope_type,"
- " scope_id, payload, created_at)"
- " values (null, null, 'comment', 'user', 'task', ?, '{}',"
- " strftime('%s','now'))", (first_task,)),
- ("insert into events (session_id, seq, kind, actor, scope_type,"
- " scope_id, payload, created_at)"
- " values (null, null, 'state_change', 'user', 'goal', ?, '{}',"
- " strftime('%s','now'))", (doomed_goal,)),
- ("insert into events (session_id, seq, kind, actor, scope_type,"
- " scope_id, payload, created_at)"
- " values (null, null, 'state_change', 'user', 'project', ?, '{}',"
- " strftime('%s','now'))", (doomed,)),
- )
- # Project-owned rows: a note, an understanding revision, a rule
- # attachment, the outgoing link to `keeper` and stack membership.
- sql_exec(
- ("insert into project_notes (project_id, source_task_id, body,"
- " created_at) values (?, ?, 'note body', strftime('%s','now'))",
- (doomed, first_task)),
- ("insert into project_understandings (project_id, body, commit_hash,"
- " task_id, created_at) values (?, 'understanding body', 'abc123', ?,"
- " strftime('%s','now'))", (doomed, first_task)),
- ("insert into rule_projects (rule_id, project_id, created_at)"
- " values ((select id from rules where name = 'doomed-rule'), ?,"
- " strftime('%s','now'))", (doomed,)),
- ("insert into project_links (parent_project_id, linked_project_id,"
- " relationship) values (?, ?, 'co-developed')", (doomed, keeper)),
- ("insert into stacks (name, summary, created_at)"
- " values ('doomed-stack', 's', strftime('%s','now'))",),
- ("insert into stack_projects (stack_id, project_id, relationship,"
- " created_at) values ((select id from stacks where name ="
- " 'doomed-stack'), ?, 'reference', strftime('%s','now'))", (doomed,)),
- )
- print("== the survival law: OTHER projects' linked work is NEVER taken ==")
- # `other-host` links to the doomed project (both directions of the
- # pointer graph) and owns a task + goal whose rows merely NAME the
- # doomed project through goal_links.
- sql_exec(
- ("insert into project_links (parent_project_id, linked_project_id,"
- " relationship) values (?, ?, 'reference')", (other, doomed)),
- ("insert into project_links (parent_project_id, linked_project_id,"
- " relationship) values (?, ?, 'co-developed')", (other, doomed)),
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (?, 0, 'task', 'implement', 'human', 'Other project task',"
- " 'x', 'done', 5, strftime('%s','now'))", (other,)),
- ("insert into goals (project_id, title, brief, branch_name, status,"
- " creator, created_at)"
- " values (?, 'Other goal', 'b', 'goal/other', 'active', 'human',"
- " strftime('%s','now'))", (other,)),
- )
- other_goal = int(sql_one("select id from goals where project_id = ?", (other,)))
- other_task = int(sql_one(
- "select id from tasks where project_id = ? and kind = 'task'", (other,)))
- sql_exec(
- # other's goal links TO the doomed project (goal_links naming it).
- ("insert into goal_links (goal_id, project_id, relationship)"
- " values (?, ?, 'co-developed')", (other_goal, doomed)),
- # other's task depends on one of the doomed tasks as an
- # auto-enqueue predecessor — the reference must CLEAR, not dangle.
- ("update tasks set enqueue_after_task_id = ? where id = ?",
- (first_task, other_task)),
- # A standalone task (project 0) must never be touched.
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (0, 0, 'task', 'implement', 'human', 'Standalone task',"
- " 'x', 'draft', 5, strftime('%s','now'))",),
- # The keeper project links to the doomed project too — its link
- # row must go, the project must stay.
- ("insert into project_links (parent_project_id, linked_project_id,"
- " relationship) values (?, ?, 'reference')", (keeper, doomed)),
- # A goal on the KEEPER project linking the doomed project: the
- # goal survives, only its link row goes.
- ("insert into goals (project_id, title, brief, branch_name, status,"
- " creator, created_at)"
- " values (?, 'Keeper goal', 'b', 'goal/keeper', 'active', 'human',"
- " strftime('%s','now'))", (keeper,)),
- ("insert into goal_links (goal_id, project_id, relationship)"
- " values ((select id from goals where project_id = ?), ?,"
- " 'reference')", (keeper, doomed)),
- )
- standalone_task = int(sql_one(
- "select id from tasks where project_id = 0 and title = 'Standalone task'"))
- print("== the page: counts, admin-only control, typed-name gate ==")
- state = slot_public(auth, "/projects/%d" % doomed, "project")
- check(state["is_admin"] is True and state.get("delete") is not None,
- "an admin's project page carries the delete action")
- check(state["delete_counts"] == "1 goal, 3 tasks",
- "the dialog counts what will go ('%s')" % state["delete_counts"])
- check(state["active_tasks"] == 0,
- "no live tasks under the doomed project (guard open)")
- plain_state = slot_public(plain, "/projects/%d" % doomed, "project")
- check(plain_state.get("delete") is None and plain_state.get("is_admin") is False,
- "a non-admin's project page hides the delete action entirely")
- # Wrong typed name refuses: nothing deleted.
- result = act(auth, state["delete"],
- {"project_id": str(doomed), "confirm_name": "doome"})
- check("exact name" in (action_notify(result) or ""),
- "a wrong typed name refuses the deletion")
- check(sql_one("select count(*) from projects where id = ?", (doomed,)) == 1,
- "the refused confirmation left the project in place")
- print("== the guard: live tasks refuse ==")
- guard_project = make_project("guarded")
- sql_exec(
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (?, 0, 'task', 'implement', 'human', 'Working task', 'x',"
- " 'working', 5, strftime('%s','now'))", (guard_project,)),
- )
- guard_state = slot_public(auth, "/projects/%d" % guard_project, "project")
- check(guard_state["active_tasks"] == 1,
- "the page counts the live task (guard visible up front)")
- status, body = delete_api(auth, guard_project)
- check(status == 409 and "queued, working" in body.get("error", ""),
- "the API refuses a live project with 409")
- result = act(auth, guard_state["delete"],
- {"project_id": str(guard_project), "confirm_name": "guarded"})
- notify = action_notify(result) or ""
- check("still has 1 task" in notify or "still has 1 tasks" in notify,
- "the action refuses too, naming the live count ('%s')" % notify)
- check(sql_one("select count(*) from projects where id = ?", (guard_project,)) == 1,
- "the guarded project survived")
- # Drop the live task; now it goes.
- sql_exec(("update tasks set status = 'done' where project_id = ?", (guard_project,)))
- status, body = delete_api(auth, guard_project)
- check(status == 200 and body.get("ok") is True,
- "after the task settles, the same delete succeeds")
- print("== the delete: owned rows gone, survivors intact ==")
- status, body = delete_api(auth, doomed)
- check(status == 200 and body.get("ok") is True,
- "the doomed project deletes cleanly over the API")
- def count(query, params=()):
- return sql_one(query, params) or 0
- # Gone: the project row, its tasks, its goal and every owned child.
- check(count("select count(*) from projects where id = ?", (doomed,)) == 0,
- "the project row is gone")
- check(count("select count(*) from tasks where project_id = ?", (doomed,)) == 0,
- "every owned task is gone (goal runs included)")
- check(count("select count(*) from goals where project_id = ?", (doomed,)) == 0,
- "every owned goal is gone")
- check(count("select count(*) from task_comments where task_id in"
- " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
- "the owned tasks' comments are gone")
- check(count("select count(*) from task_blocks where task_id in"
- " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
- "the owned tasks' blocks are gone")
- check(count("select count(*) from task_rules where task_id in"
- " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
- "the owned tasks' rule attachments are gone")
- check(count("select count(*) from task_capabilities where task_id in"
- " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
- "the owned tasks' capability requirements are gone")
- check(count("select count(*) from sessions where task_id in"
- " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
- "the owned tasks' sessions are gone")
- check(count("select count(*) from events where session_id = ?", (session_id,)) == 0,
- "the session-carried events are gone")
- check(count("select count(*) from events where scope_type = 'task' and"
- " scope_id in (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
- "the task-scoped events are gone")
- check(count("select count(*) from events where scope_type = 'goal' and"
- " scope_id = ?", (doomed_goal,)) == 0,
- "the goal-scoped events are gone")
- check(count("select count(*) from events where scope_type = 'project' and"
- " scope_id = ?", (doomed,)) == 0,
- "the project-scoped events are gone")
- check(count("select count(*) from goal_links where goal_id = ?", (doomed_goal,)) == 0,
- "the doomed goal's link rows are gone")
- check(count("select count(*) from project_notes where project_id = ?", (doomed,)) == 0,
- "the project's notes are gone")
- check(count("select count(*) from project_understandings where project_id = ?",
- (doomed,)) == 0,
- "the project's understanding revisions are gone")
- check(count("select count(*) from rule_projects where project_id = ?", (doomed,)) == 0,
- "the project's rule attachments are gone")
- check(count("select count(*) from project_links where parent_project_id = ?",
- (doomed,)) == 0,
- "the project's outgoing links are gone")
- check(count("select count(*) from stack_projects where project_id = ?",
- (doomed,)) == 0,
- "the project's stack memberships are gone")
- # Intact: everything NOT owned by the doomed project.
- check(count("select count(*) from projects where id = ?", (other,)) == 1
- and count("select count(*) from projects where id = ?", (keeper,)) == 1,
- "the other projects' rows survive")
- check(count("select count(*) from tasks where id = ?", (other_task,)) == 1,
- "CRITICAL: another project's task that linked to the doomed project SURVIVES")
- check(count("select count(*) from goals where id = ?", (other_goal,)) == 1,
- "CRITICAL: another project's goal that linked to the doomed project SURVIVES")
- check(count("select count(*) from tasks where id = ?", (standalone_task,)) == 1,
- "standalone tasks (project 0) are untouched")
- check(sql_one("select enqueue_after_task_id from tasks where id = ?",
- (other_task,)) == 0,
- "the survivor's enqueue-after reference to a deleted task is CLEARED (not dangling)")
- check(count("select count(*) from project_links where parent_project_id = ?"
- " and linked_project_id = ?", (other, doomed)) == 0,
- "other project's link rows NAMING the doomed project are tidied away (pointer only)")
- check(count("select count(*) from project_links where parent_project_id = ?"
- " and linked_project_id = ?", (keeper, doomed)) == 0,
- "the keeper's link row naming the doomed project is tidied away")
- check(count("select count(*) from goal_links where project_id = ?", (doomed,)) == 0,
- "goal link rows in OTHER projects naming the doomed project are tidied (pointer only)")
- check(count("select count(*) from goal_links where goal_id = ?",
- (other_goal,)) == 0,
- "the other project's goal lost only its link row to the doomed project")
- keeper_goal = int(sql_one("select id from goals where project_id = ?", (keeper,)))
- check(count("select count(*) from goals where id = ?", (keeper_goal,)) == 1,
- "the keeper's own goal survived the pointer tidy")
- # Fleet-owned rows the cascade must never take.
- check(count("select count(*) from rules where name = 'doomed-rule'") == 1,
- "the rule DEFINITIONS survive (only attachments went)")
- check(count("select count(*) from capabilities where name = 'doomed-cap'") == 1,
- "the capability DEFINITIONS survive (only requirements went)")
- check(count("select count(*) from stacks where name = 'doomed-stack'") == 1,
- "the stack itself survives (only its membership row went)")
- check(count("select count(*) from saddles where name = 'doomed-saddle'") == 1,
- "the saddle registration survives")
- print("== the page after the delete ==")
- # The detail page of a deleted project reports not-found.
- state = slot_public(auth, "/projects/%d" % doomed, "project")
- check(state["exists"] is False,
- "the deleted project's page reports not found")
- names = [p["name"] for p in slot_public(auth, "/projects", "projects")["projects"]]
- check("doomed" not in names and "keeper" in names,
- "the projects list shows the survivors only")
- print("== the UI path: the action deletes and redirects ==")
- # End-to-end through the page action (the dialog's form): a fresh
- # project with one goal and one task goes, the reply navigates to
- # /projects with a success notify.
- ui_doomed = make_project("ui-doomed")
- sql_exec(
- ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
- " description, status, priority, created_at)"
- " values (?, 0, 'task', 'implement', 'human', 'UI task', 'x',"
- " 'done', 5, strftime('%s','now'))", (ui_doomed,)),
- ("insert into goals (project_id, title, brief, branch_name, status,"
- " creator, created_at)"
- " values (?, 'UI goal', 'b', 'goal/ui', 'complete', 'human',"
- " strftime('%s','now'))", (ui_doomed,)),
- )
- ui_state = slot_public(auth, "/projects/%d" % ui_doomed, "project")
- check(ui_state["delete_counts"] == "1 goal, 1 task",
- "the UI project's counts read '1 goal, 1 task' ('%s')"
- % ui_state["delete_counts"])
- result = act(auth, ui_state["delete"],
- {"project_id": str(ui_doomed), "confirm_name": "ui-doomed"})
- notify = action_notify(result) or ""
- check("deleted" in notify and "untouched" in notify,
- "the action reports the delete and the survival guarantee ('%s')" % notify)
- check(any(d.get("type") == "navigate" and d.get("uri") == "/projects"
- for d in result), "the action redirects to the projects list")
- check(count("select count(*) from projects where id = ?", (ui_doomed,)) == 0
- and count("select count(*) from tasks where project_id = ?", (ui_doomed,)) == 0
- and count("select count(*) from goals where project_id = ?", (ui_doomed,)) == 0,
- "the UI delete took the project with its task and goal")
- # Idempotence of the wire: deleting the same project again is a 404.
- status, body = delete_api(auth, doomed)
- check(status == 404, "deleting an already-deleted project answers 404")
- # Housekeeping: the leftover helper rows this bundle created.
- sql_exec(
- ("delete from goal_links where goal_id in (select id from goals where"
- " project_id in (?, ?))", (keeper, standalone_host)),
- ("delete from goals where project_id in (?, ?)", (keeper, standalone_host)),
- ("delete from tasks where id = ?", (standalone_task,)),
- ("delete from tasks where project_id = ?", (other,)),
- ("delete from goal_links where goal_id = ?", (other_goal,)),
- ("delete from goals where id = ?", (other_goal,)),
- ("delete from project_links where parent_project_id in (?, ?)",
- (other, keeper)),
- ("delete from stack_projects where stack_id ="
- " (select id from stacks where name = 'doomed-stack')",),
- ("delete from stacks where name = 'doomed-stack'",),
- ("delete from rules where name = 'doomed-rule'",),
- ("delete from capabilities where name = 'doomed-cap'",),
- ("delete from saddles where name = 'doomed-saddle'",),
- ("delete from projects where id in (?, ?, ?)", (other, keeper,
- standalone_host)),
- )
|