test(datatables): pin that freeing a principal takes its tenant with it

The cleanup is one helper called from five routes, and the bug each time was a
route that did not call it. This drives four of them — a group deletion, a
folder deletion, a member leaving, a superadmin deleting the account — against
a real API server and asserts the role stops naming them, while the `*`
wildcard, which no deletion can free, is left alone.
This commit is contained in:
Diego Imbert
2026-09-03 19:50:59 +02:00
parent 2c55c3918c
commit dfc06bf821
@@ -0,0 +1,108 @@
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}"))
}
/// Who the data table's one role currently lets run as it.
async fn tenants(db: &Pool<Postgres>) -> Vec<String> {
let value: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT datatable->'datatables'->'main'->'permissions'->'roles'->'analyst'->'tenants'
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(db)
.await
.unwrap();
serde_json::from_value(value.unwrap()).unwrap()
}
/// A tenant is a name, and a name outlives the principal that held it: whoever
/// takes it next would run as the role it still names. Every route that frees
/// one has to take it off the role, which is easy to miss from any single one of
/// them — so they are pinned together.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn freeing_a_principal_takes_its_datatable_tenant(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
sqlx::query(
r#"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = 'test-workspace'"#,
)
.bind(json!({
"datatables": {
"main": {
"database": { "resource_type": "instance", "resource_path": "dt_main" },
"permissions": { "enabled": true, "roles": {
"admin": { "tenants": [] },
"analyst": { "tenants": [
"*", "u/test-user-2", "u/test-user-3", "g/leaving_group", "f/leaving_folder"
]}
}}
}
}
}))
.execute(&db)
.await?;
for (endpoint, body) in [
("groups/create", json!({ "name": "leaving_group" })),
("folders/create", json!({ "name": "leaving_folder" })),
] {
let resp = authed(client().post(format!("{ws}/{endpoint}")), "SECRET_TOKEN")
.json(&body)
.send()
.await?;
assert_eq!(resp.status(), 200, "{endpoint}: {}", resp.text().await?);
}
let resp = authed(
client().delete(format!("{ws}/groups/delete/leaving_group")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete group: {}", resp.text().await?);
assert!(!tenants(&db).await.contains(&"g/leaving_group".to_string()));
let resp = authed(
client().delete(format!("{ws}/folders/delete/leaving_folder")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete folder: {}", resp.text().await?);
assert!(!tenants(&db).await.contains(&"f/leaving_folder".to_string()));
// Leaving frees the username as surely as an admin removing the member does.
let resp = authed(client().post(format!("{ws}/users/leave")), "SECRET_TOKEN_2")
.send()
.await?;
assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?);
assert!(!tenants(&db).await.contains(&"u/test-user-2".to_string()));
let resp = authed(
client().delete(format!(
"http://localhost:{port}/api/users/delete/test3@windmill.dev"
)),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "global delete: {}", resp.text().await?);
assert!(!tenants(&db).await.contains(&"u/test-user-3".to_string()));
// What no deletion named is left alone — the wildcard above all, which is
// not a principal and cannot be freed.
assert_eq!(tenants(&db).await, vec!["*".to_string()]);
Ok(())
}