//! Regression test for `auto_parent` when all versions at a script path are //! archived (e.g. after a rename). //! //! The CLI's `wmill sync push` sends `parent_hash` together with //! `auto_parent: true`, delegating parent resolution to the backend. When every //! version at the target path is archived, there is no active head, so the //! stale `parent_hash` (an archived ancestor) used to leak into the lineage //! check and produce a spurious //! `lineage must be linear: no 2 scripts can have the same parent` error //! whenever that archived hash already had a child from the prior rename. //! //! Resolution only ever adopts an archived version nothing else descends from, //! so here it finds none and leaves `parent_hash` at `None`, starting a fresh //! lineage instead of failing. use serde_json::json; 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)) } fn new_script(path: &str, content: &str) -> serde_json::Value { json!({ "path": path, "summary": "", "description": "", "content": content, "language": "deno", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] } }) } #[sqlx::test(fixtures("base"))] async fn test_auto_parent_starts_fresh_lineage_when_all_versions_archived( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); let original_path = "u/test-user/script_archived_parent"; let renamed_path = "u/test-user/script_renamed"; // 1. Create the initial version at the original path. let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script( original_path, "export async function main() { return 1; }", )) .send() .await?; assert_eq!(resp.status(), 201); let original_hash: String = resp.text().await?; // 2. Rename the script (new path, parent_hash pointing at v1). This // archives the original hash and gives the new version a // `parent_hashes[1]` equal to `original_hash`, so the original path now // has only archived versions. let mut rename = new_script(renamed_path, "export async function main() { return 2; }"); rename["parent_hash"] = json!(original_hash); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&rename) .send() .await?; assert_eq!( resp.status(), 201, "rename should succeed: {}", resp.text().await? ); // Sanity: the original path has no active (non-archived) version. let active_at_original: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND archived = false AND workspace_id = $2)", ) .bind(original_path) .bind("test-workspace") .fetch_one(&db) .await?; assert!( !active_at_original, "all versions at the original path should be archived after rename" ); // 3. Reproduce `wmill sync push`: push back to the original path with the // stale archived `parent_hash` AND `auto_parent: true`. Before the fix // this returned 400 "lineage must be linear" because the archived hash // already had a child (the renamed version) sharing the same parent. let mut push = new_script(original_path, "export async function main() { return 3; }"); push["parent_hash"] = json!(original_hash); push["auto_parent"] = json!(true); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&push) .send() .await?; let status = resp.status(); let body = resp.text().await?; assert_eq!( status, 201, "auto_parent push to a path with only archived versions should start a \ fresh lineage, got {status}: {body}" ); // 4. There is now exactly one active version at the original path and it is // a fresh lineage with no parent (rather than attaching to the archived // ancestor). let active: Vec>> = sqlx::query_scalar( "SELECT parent_hashes FROM script \ WHERE path = $1 AND archived = false AND workspace_id = $2", ) .bind(original_path) .bind("test-workspace") .fetch_all(&db) .await?; assert_eq!( active.len(), 1, "exactly one active version expected at the original path" ); assert!( active[0].is_none(), "fresh lineage should have no parent_hashes, got {:?}", active[0] ); Ok(()) } /// A path whose only versions are archived and childless — what archiving a path /// leaves behind, and what a sync push that applies a deletion before the matching /// update sees. Resolving `auto_parent` to no parent there hashes the deploy exactly /// as the path's first version was hashed, so an unchanged push is rejected as a /// duplicate of it. #[sqlx::test(fixtures("base"))] async fn test_auto_parent_adopts_archived_head_instead_of_colliding( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); let path = "u/test-user/script_archived_head"; let body = new_script(path, "export async function main() { return 1; }"); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&body) .send() .await?; assert_eq!(resp.status(), 201); let first_hash: i64 = sqlx::query_scalar("SELECT hash FROM script WHERE path = $1 AND workspace_id = $2") .bind(path) .bind("test-workspace") .fetch_one(&db) .await?; let resp = authed( client().post(format!("{base}/scripts/archive/p/{path}")), "SECRET_TOKEN", ) .send() .await?; assert_eq!(resp.status(), 200, "archiving the path should succeed"); // Byte-identical to the first deploy: the collision this guards against needs the // pushed body to hash the same way the original one did. let resp = authed( client().post(format!("{base}/scripts/create?skip_if_noop=true")), "SECRET_TOKEN", ) .json(&{ let mut push = body.clone(); push["auto_parent"] = json!(true); push }) .send() .await?; let status = resp.status(); let response_body = resp.text().await?; assert_eq!( status, 201, "re-pushing an archived path must not collide with its own first version, \ got {status}: {response_body}" ); let active: Vec>> = sqlx::query_scalar( "SELECT parent_hashes FROM script \ WHERE path = $1 AND archived = false AND workspace_id = $2", ) .bind(path) .bind("test-workspace") .fetch_all(&db) .await?; assert_eq!(active.len(), 1, "exactly one active version expected"); assert_eq!( active[0].as_deref(), Some(&[first_hash][..]), "the revived version should continue the archived lineage" ); Ok(()) } /// The same redeploy from a caller that names no parent at all — the shape a retried /// `wmill sync push` takes, once the archive it applied has committed and the path has /// dropped out of the listing it diffs against. /// /// The adopted version supplies the lineage and nothing else: a path is reusable by a /// different script, which must not start life holding grants nobody gave it. #[sqlx::test(fixtures("base"))] async fn test_parentless_redeploy_adopts_the_lineage_but_not_the_grants( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); let path = "u/test-user/script_retired_parentless"; let body = new_script(path, "export async function main() { return 1; }"); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&body) .send() .await?; assert_eq!(resp.status(), 201); let first_hash: i64 = sqlx::query_scalar("SELECT hash FROM script WHERE path = $1 AND workspace_id = $2") .bind(path) .bind("test-workspace") .fetch_one(&db) .await?; sqlx::query("UPDATE script SET extra_perms = $1 WHERE hash = $2 AND workspace_id = $3") .bind(json!({ "u/someone_else": true })) .bind(first_hash) .bind("test-workspace") .execute(&db) .await?; let resp = authed( client().post(format!("{base}/scripts/archive/p/{path}")), "SECRET_TOKEN", ) .send() .await?; assert_eq!(resp.status(), 200, "archiving the path should succeed"); // Byte-identical to the first deploy and naming no parent: hashed as a first deploy it // lands on the archived row. let resp = authed( client().post(format!("{base}/scripts/create?skip_if_noop=true")), "SECRET_TOKEN", ) .json(&body) .send() .await?; let status = resp.status(); let response_body = resp.text().await?; assert_eq!( status, 201, "a parentless redeploy of a retired path must not collide with its own first \ version, got {status}: {response_body}" ); let live: Vec<(Option>, serde_json::Value)> = sqlx::query_as( "SELECT parent_hashes, extra_perms FROM script \ WHERE path = $1 AND archived = false AND workspace_id = $2", ) .bind(path) .bind("test-workspace") .fetch_all(&db) .await?; assert_eq!(live.len(), 1, "exactly one live version expected"); assert_eq!( live[0].0.as_deref(), Some(&[first_hash][..]), "the redeploy should continue the retired lineage" ); assert_eq!( live[0].1, json!({}), "an adopted version's grants must not carry over to whatever reuses its path" ); Ok(()) } /// A soft delete keeps the row, and with it the hash the version was deployed under, so a /// tombstone has to stay adoptable: skip it and the redeploy hashes straight back onto it. #[sqlx::test(fixtures("base"))] async fn test_redeploy_chains_past_a_deleted_version(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); let path = "u/test-user/script_deleted_version"; let body = new_script(path, "export async function main() { return 1; }"); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&body) .send() .await?; assert_eq!(resp.status(), 201); let deleted_hash = resp.text().await?; let resp = authed( client().post(format!("{base}/scripts/delete/h/{deleted_hash}")), "SECRET_TOKEN", ) .send() .await?; assert_eq!(resp.status(), 200, "deleting the version should succeed"); let resp = authed( client().post(format!("{base}/scripts/create?skip_if_noop=true")), "SECRET_TOKEN", ) .json(&body) .send() .await?; let status = resp.status(); let response_body = resp.text().await?; assert_eq!( status, 201, "redeploying the content of a deleted version must not collide with its \ tombstone, got {status}: {response_body}" ); Ok(()) }