Files
windmill/backend/tests/wac_approval_resume.rs
Ruben FiszelandClaude Opus 5 9cef724ff2 feat: bind WAC approval urls to a named wait_for_approval step (#10317)
* feat: bind WAC approval urls to a named wait_for_approval step

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject duplicate WAC approval step keys instead of renaming them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject WAC approval links minted for a step that is not awaiting approval

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: bind WAC approval links to the awaiting step and stop step key aliasing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject empty approval keys and scope minted-key writes to the workspace

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: enforce WAC approval binding at consumption and reject colliding keys

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: make WAC approval binding and collision checks atomic, harden TS step keys

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: decrement WAC suspend atomically instead of from a pre-lock snapshot

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: add sqlx cache entry for the atomic WAC suspend decrement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: omit empty approver param from python get_approval_urls

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: pin the suspend-snapshot decrement and the colliding-mint race

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: drop the suspend-snapshot interleave test, it cannot both be stable and discriminate

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject step keys that cannot be minted as a URL path segment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:41:48 +02:00

130 lines
4.6 KiB
Rust

//! WIN-2241: each sequential `wait_for_approval()` in a WAC workflow must
//! resolve to its own approval, not the workflow's first. Guards approved,
//! cancelled and timed-out steps sharing one parent job.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::wac::{WacCheckpoint, WacPendingSteps};
use windmill_worker::wac_executor::prepare_checkpoint_for_resume;
async fn insert_resume_row(
db: &Pool<Postgres>,
job_id: Uuid,
resume_id: i32,
approver: &str,
approved: bool,
value: Value,
) -> anyhow::Result<()> {
// resume_id is deliberately arbitrary (a stand-in for the random ids the
// interactive approval channels store) — the fix must not depend on it.
sqlx::query(
"INSERT INTO resume_job (id, job, flow, value, approver, resume_id, approved) \
VALUES ($1, $2, $2, $3, $4, $5, $6)",
)
.bind(Uuid::new_v4())
.bind(job_id)
.bind(value)
.bind(approver)
.bind(resume_id)
.bind(approved)
.execute(db)
.await?;
Ok(())
}
fn pending_approval(mut prior: WacCheckpoint, key: &str) -> WacCheckpoint {
prior.pending_steps = Some(WacPendingSteps {
mode: "approval".to_string(),
keys: vec![key.to_string()],
job_ids: Default::default(),
});
prior
}
#[sqlx::test]
async fn wac_sequential_approvals_read_own_row(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
// FK target for resume_job.flow and v2_job_status.id (written by save_checkpoint).
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, 'test-workspace', now())",
)
.bind(job_id)
.execute(&db)
.await?;
// Step A: approved.
insert_resume_row(&db, job_id, 1, "alice", true, json!({"n": 1})).await?;
let ckpt = pending_approval(WacCheckpoint::default(), "apprA");
let ckpt = prepare_checkpoint_for_resume(&db, &job_id, ckpt).await?;
assert_eq!(ckpt.completed_steps["apprA"]["approved"], json!(true));
assert_eq!(ckpt.completed_steps["apprA"]["approver"], json!("alice"));
assert_eq!(ckpt.completed_steps["apprA"]["value"], json!({"n": 1}));
// Step B: cancelled. Carrying `ckpt` forward preserves consumed_resume_row_ids,
// so B must resolve to its own row, not A's stale approved=true.
insert_resume_row(&db, job_id, 2, "bob", false, json!({"n": 2})).await?;
let ckpt = pending_approval(ckpt, "apprB");
let ckpt = prepare_checkpoint_for_resume(&db, &job_id, ckpt).await?;
assert_eq!(
ckpt.completed_steps["apprB"]["approved"],
json!(false),
"2nd approval must read its own (cancelled) row, not the 1st's"
);
assert_eq!(ckpt.completed_steps["apprB"]["approver"], json!("bob"));
// Step C: timed out — no resume_job row. Falls to the approved=false default.
let ckpt = pending_approval(ckpt, "apprC");
let ckpt = prepare_checkpoint_for_resume(&db, &job_id, ckpt).await?;
assert_eq!(ckpt.completed_steps["apprC"]["approved"], json!(false));
assert_eq!(ckpt.completed_steps["apprC"]["approver"], json!(null));
Ok(())
}
/// A row carrying another step's bound resume_id belongs to that step. The API
/// refuses such resumes but cannot do so atomically with the insert, so
/// consumption must skip them however they got in — while leaving the random ids
/// the interactive channels sign fully eligible.
#[sqlx::test]
async fn wac_approval_skips_another_steps_bound_row(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, 'test-workspace', now())",
)
.bind(job_id)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO v2_job_status (id, workflow_as_code_status) VALUES ($1, $2)",
)
.bind(job_id)
.bind(sqlx::types::Json(json!({
"_minted_approval_keys": { "legal": true, "finance": true }
})))
.execute(&db)
.await?;
// Oldest row is bound to `finance`; the later one is a plain Slack-style resume.
insert_resume_row(
&db,
job_id,
windmill_common::wac::approval_resume_id("finance") as i32,
"bob",
true,
json!({"n": "finance"}),
)
.await?;
insert_resume_row(&db, job_id, 4242, "alice", true, json!({"n": "slack"})).await?;
let ckpt = pending_approval(WacCheckpoint::default(), "legal");
let ckpt = prepare_checkpoint_for_resume(&db, &job_id, ckpt).await?;
assert_eq!(
ckpt.completed_steps["legal"]["approver"],
json!("alice"),
"`legal` must skip finance's bound row and take the unbound one"
);
Ok(())
}