Files
windmill/backend/tests/fork_deployment_requests.rs
Ruben Fiszel 64c58c824f feat: add deploy restriction rule and fork review requests (#8804)
* feat: add deploy restriction rule and fork review requests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt for fork review requests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review comments on fork review requests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: rename fork review requests to deployment requests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt for deployment request rename

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: inline deployment request panel into deploy layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: place Request deployment button to the left of Deploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: inline fork triggers into main deploy list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: open real trigger detail drawer for inline fork triggers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: email notifications for merge completion and reply pings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update deployment_request + protection_rule tables on workspace id rename

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 972893c3870e4c4a70a35748abed282d88904805

This commit updates the EE repository reference after PR #528 was merged in windmill-ee-private.

Previous ee-repo-ref: 5684d1c17d930b17849c1e5d7577891e64682d45

New ee-repo-ref: 972893c3870e4c4a70a35748abed282d88904805

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-13 17:10:37 +00:00

357 lines
10 KiB
Rust

//! Integration tests for fork deployment requests + comments.
//!
//! Covers: one-open-per-fork constraint, assignee ACL validation, anchor
//! obsolescence when an item in the fork changes, merge-close lifecycle,
//! replies, reply-to-reply rejection, and cancel ACL.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
#[sqlx::test(fixtures("fork_deployment_requests"))]
async fn test_fork_deployment_request_lifecycle(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let fork_base = format!("http://localhost:{port}/api/w/fork-ws");
// ---- 1. eligible_deployers returns admin + deployer, not random ----
let resp = authed(
client().get(format!("{fork_base}/deployment_request/eligible_deployers")),
"FDR_OWNER_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let deployers: Vec<Value> = resp.json().await?;
let usernames: Vec<&str> = deployers
.iter()
.filter_map(|d| d.get("username").and_then(|v| v.as_str()))
.collect();
assert!(usernames.contains(&"fdr-admin"));
assert!(usernames.contains(&"fdr-deployer"));
assert!(!usernames.contains(&"fdr-random"));
// ---- 2. createDeploymentRequest with valid assignees succeeds ----
let resp = authed(
client().post(format!("{fork_base}/deployment_request")),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "assignees": ["fdr-admin", "fdr-deployer"] }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"create: {}",
resp.text().await.unwrap_or_default()
);
let created: Value = resp.json().await?;
let request_id = created.get("id").and_then(|v| v.as_i64()).unwrap();
assert_eq!(
created.get("requested_by").and_then(|v| v.as_str()),
Some("fdr-owner")
);
assert_eq!(
created
.get("assignees")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(2)
);
// ---- 3. second create fails with 409 because one-open constraint ----
let resp = authed(
client().post(format!("{fork_base}/deployment_request")),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "assignees": ["fdr-admin"] }))
.send()
.await?;
assert_eq!(
resp.status(),
409,
"second create should return 409 Conflict: {}",
resp.status()
);
// ---- 4. getOpenRequest returns the request ----
let resp = authed(
client().get(format!("{fork_base}/deployment_request/open")),
"FDR_RANDOM_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let open: Value = resp.json().await?;
assert_eq!(open.get("id").and_then(|v| v.as_i64()), Some(request_id));
// ---- 5. random user posts a general comment (anyone with fork access) ----
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{request_id}/comment"
)),
"FDR_RANDOM_TOKEN",
)
.json(&json!({ "body": "Looks good overall" }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"general comment: {}",
resp.text().await.unwrap_or_default()
);
let top_comment: Value = resp.json().await?;
let top_id = top_comment.get("id").and_then(|v| v.as_i64()).unwrap();
// ---- 6. admin posts an anchored comment ----
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{request_id}/comment"
)),
"FDR_ADMIN_TOKEN",
)
.json(&json!({
"body": "Can you revisit this script?",
"anchor_kind": "script",
"anchor_path": "f/shared/script1"
}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"anchored comment: {}",
resp.text().await.unwrap_or_default()
);
let anchored: Value = resp.json().await?;
let anchored_id = anchored.get("id").and_then(|v| v.as_i64()).unwrap();
assert_eq!(
anchored.get("anchor_kind").and_then(|v| v.as_str()),
Some("script")
);
assert_eq!(
anchored.get("obsolete").and_then(|v| v.as_bool()),
Some(false)
);
// ---- 7. reply to top-level comment ----
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{request_id}/comment"
)),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "body": "Thanks!", "parent_id": top_id }))
.send()
.await?;
assert_eq!(resp.status(), 200);
let reply: Value = resp.json().await?;
let reply_id = reply.get("id").and_then(|v| v.as_i64()).unwrap();
assert_eq!(
reply.get("parent_id").and_then(|v| v.as_i64()),
Some(top_id)
);
// ---- 7b. reply-to-reply is rejected (2-level max) ----
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{request_id}/comment"
)),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "body": "nested!", "parent_id": reply_id }))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"reply-to-reply should be rejected: {}",
resp.status()
);
// ---- 8. anchored comment becomes obsolete when item changes in fork ----
// The CE stub of `mark_anchor_obsolete` is a no-op (the EE module fills
// it in). Simulate the EE effect directly so the CE test can verify
// the GET response renders obsolete comments correctly.
sqlx::query!(
r#"
UPDATE workspace_fork_deployment_request_comment c
SET obsolete = true
FROM workspace_fork_deployment_request r
WHERE c.request_id = r.id
AND r.fork_workspace_id = 'fork-ws'
AND r.closed_at IS NULL
AND c.anchor_kind = 'script'
AND c.anchor_path = 'f/shared/script1'
"#
)
.execute(&db)
.await?;
let resp = authed(
client().get(format!("{fork_base}/deployment_request/open")),
"FDR_OWNER_TOKEN",
)
.send()
.await?;
let open: Value = resp.json().await?;
let comments = open.get("comments").and_then(|v| v.as_array()).unwrap();
let anchored_after = comments
.iter()
.find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(anchored_id))
.unwrap();
assert_eq!(
anchored_after.get("obsolete").and_then(|v| v.as_bool()),
Some(true),
"anchored comment should be obsolete"
);
let top_after = comments
.iter()
.find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(top_id))
.unwrap();
assert_eq!(
top_after.get("obsolete").and_then(|v| v.as_bool()),
Some(false),
"general comment should NOT be obsolete"
);
// ---- 9. non-requester non-admin cannot cancel ----
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{request_id}/cancel"
)),
"FDR_RANDOM_TOKEN",
)
.send()
.await?;
assert!(
!resp.status().is_success(),
"random cancel should fail: {}",
resp.status()
);
// ---- 10. requester can cancel ----
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{request_id}/cancel"
)),
"FDR_OWNER_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"cancel: {}",
resp.text().await.unwrap_or_default()
);
// ---- 11. after cancel a new request can be opened ----
let resp = authed(
client().post(format!("{fork_base}/deployment_request")),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "assignees": ["fdr-admin"] }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"re-create: {}",
resp.text().await.unwrap_or_default()
);
let new_req: Value = resp.json().await?;
let new_req_id = new_req.get("id").and_then(|v| v.as_i64()).unwrap();
// ---- 12. ineligible assignee rejected ----
authed(
client().post(format!(
"{fork_base}/deployment_request/{new_req_id}/cancel"
)),
"FDR_OWNER_TOKEN",
)
.send()
.await?;
let resp = authed(
client().post(format!("{fork_base}/deployment_request")),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "assignees": ["fdr-random"] }))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"ineligible assignee should fail: {}",
resp.status()
);
// ---- 13. merge-close: create a request, close as merged, comments flip ----
let resp = authed(
client().post(format!("{fork_base}/deployment_request")),
"FDR_OWNER_TOKEN",
)
.json(&json!({ "assignees": ["fdr-admin"] }))
.send()
.await?;
let final_req: Value = resp.json().await?;
let final_id = final_req.get("id").and_then(|v| v.as_i64()).unwrap();
authed(
client().post(format!("{fork_base}/deployment_request/{final_id}/comment")),
"FDR_ADMIN_TOKEN",
)
.json(&json!({ "body": "lgtm" }))
.send()
.await?;
let resp = authed(
client().post(format!(
"{fork_base}/deployment_request/{final_id}/close_merged"
)),
"FDR_ADMIN_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
// After close_merged, getOpenRequest returns null.
let resp = authed(
client().get(format!("{fork_base}/deployment_request/open")),
"FDR_OWNER_TOKEN",
)
.send()
.await?;
let after_merge = resp.text().await?;
assert!(
after_merge == "null" || after_merge == "",
"open should be null after merge-close, got: {}",
after_merge
);
// And every comment on the closed request is now obsolete.
let obsolete_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) as \"c!\" FROM workspace_fork_deployment_request_comment WHERE request_id = $1 AND obsolete = false",
final_id,
)
.fetch_one(&db)
.await?;
assert_eq!(
obsolete_count, 0,
"all comments on merged request should be obsolete"
);
Ok(())
}