Files
windmill/backend/tests/script_auto_parent_archived.rs
T
Ruben FiszelandClaude Opus 5 0b37226078 fix: chain redeploys onto a retired path's version history (#11029)
* fix: chain auto_parent onto the archived lineage tip at a path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTAmRTjG2KAH4g4mco6T5

* fix: check descendants unscoped and drop the hash-guard move

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTAmRTjG2KAH4g4mco6T5

* fix: widen retired-path adoption, lock it, drop inherited grants

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMhCSFeWSezY61g6woQiz2

* fix: check lineage linearity unscoped, under the parent lock

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTAmRTjG2KAH4g4mco6T5

* fix: do not name a lineage conflict the caller cannot read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTAmRTjG2KAH4g4mco6T5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:44:22 +02:00

368 lines
12 KiB
Rust

//! 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<Postgres>,
) -> 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<Option<Vec<i64>>> = 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<Postgres>,
) -> 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<Option<Vec<i64>>> = 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<Postgres>,
) -> 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<Vec<i64>>, 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<Postgres>) -> 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(())
}