mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 00:04:10 +00:00
* 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>
368 lines
12 KiB
Rust
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(())
|
|
}
|