mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
* feat: show per-owner runnable counts in the homepage tree Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: exclude pipeline members from runnable owner counts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review findings on runnable owner counts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: avoid tree reflow while counts load and label pipeline rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop collapsed owners' cached rows when the tree scope changes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: untrack tree owners whose node is removed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
228 lines
8.9 KiB
Rust
228 lines
8.9 KiB
Rust
//! Regression test for `GET /w/{workspace}/runnables/counts`, which the homepage
|
|
//! tree uses to label every folder / user node and to drop the empty ones.
|
|
//!
|
|
//! The endpoint runs off the non-RLS pool and re-derives visibility from `path`
|
|
//! (admin / folder read set / own user space) plus one `extra_perms` pass, so it
|
|
//! duplicates by hand what RLS otherwise enforces. That duplication is what can
|
|
//! drift: a change to the RLS policies, or to how `authed.folders`/`groups` are
|
|
//! populated, would silently make the counts over- or under-report. So the test
|
|
//! pins them to the ground truth rather than to their own implementation — the
|
|
//! counts must equal the per-owner grouping of `/runnables/list`, which is
|
|
//! RLS-enforced, across every way an item can become visible.
|
|
|
|
use sqlx::{Pool, Postgres};
|
|
use std::collections::HashMap;
|
|
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 owner_of(path: &str) -> String {
|
|
path.split('/').take(2).collect::<Vec<_>>().join("/")
|
|
}
|
|
|
|
/// Per-owner counts derived from the RLS-enforced listing. Pipeline members are
|
|
/// dropped to match what the tree renders (and what /counts promises): they are
|
|
/// folded into their folder's single pipeline entry, never listed as rows.
|
|
async fn counts_from_list(port: u16, token: &str) -> HashMap<String, i64> {
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace/runnables/list");
|
|
let mut out: HashMap<String, i64> = HashMap::new();
|
|
let mut cursor: Option<String> = None;
|
|
loop {
|
|
let mut url = format!("{base}?per_page=100");
|
|
if let Some(c) = &cursor {
|
|
url.push_str(&format!("&cursor={c}"));
|
|
}
|
|
let resp = authed(client().get(&url), token).send().await.unwrap();
|
|
assert_eq!(resp.status(), 200, "list should succeed");
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
for it in body["items"].as_array().unwrap() {
|
|
if it["auto_kind"].as_str() == Some("pipeline") {
|
|
continue;
|
|
}
|
|
*out.entry(owner_of(it["path"].as_str().unwrap()))
|
|
.or_insert(0) += 1;
|
|
}
|
|
match body["next_cursor"].as_str() {
|
|
Some(c) => cursor = Some(c.to_string()),
|
|
None => break,
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
async fn counts_endpoint_q(port: u16, token: &str, query: &str) -> HashMap<String, i64> {
|
|
let url = format!("http://localhost:{port}/api/w/test-workspace/runnables/counts?{query}");
|
|
let resp = authed(client().get(&url), token).send().await.unwrap();
|
|
assert_eq!(resp.status(), 200, "counts should succeed");
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
body["counts"]
|
|
.as_object()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.as_i64().unwrap()))
|
|
.collect()
|
|
}
|
|
|
|
async fn counts_endpoint(port: u16, token: &str) -> HashMap<String, i64> {
|
|
counts_endpoint_q(port, token, "").await
|
|
}
|
|
|
|
async fn insert_script(
|
|
db: &Pool<Postgres>,
|
|
hash: i64,
|
|
path: &str,
|
|
extra_perms: &str,
|
|
auto_kind: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, archived, extra_perms, auto_kind)
|
|
VALUES ('test-workspace', $1, $2, '', '', 'x', 'test-user', 'deno', false, $3::jsonb, $4)",
|
|
)
|
|
.bind(hash)
|
|
.bind(path)
|
|
.bind(extra_perms)
|
|
.bind(auto_kind)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_runnable_counts_match_rls_listing(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
// `team` is readable by test-user-2; `secret` is not. Everything visible to
|
|
// test-user-2 out of `secret` can only get there through an explicit share.
|
|
for (name, extra_perms) in [("team", r#"{"u/test-user-2": false}"#), ("secret", "{}")] {
|
|
sqlx::query(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
|
|
VALUES ('test-workspace', $1, $1, ARRAY[]::text[], $2::jsonb)",
|
|
)
|
|
.bind(name)
|
|
.bind(extra_perms)
|
|
.execute(&db)
|
|
.await?;
|
|
}
|
|
// `empty` has no runnables at all: it must be absent from the counts so the
|
|
// tree can drop it.
|
|
sqlx::query(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
|
|
VALUES ('test-workspace', 'empty', 'empty', ARRAY[]::text[], '{\"u/test-user-2\": false}'::jsonb)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'all', 'test-user-2')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Visible through the folder grant.
|
|
insert_script(&db, 9000001, "f/team/s1", "{}", None).await?;
|
|
insert_script(&db, 9000002, "f/team/s2", "{}", None).await?;
|
|
// Visible through the caller's own user space.
|
|
insert_script(&db, 9000003, "u/test-user-2/mine", "{}", None).await?;
|
|
// Not visible at all: another user's space, and an unreadable folder.
|
|
insert_script(&db, 9000004, "u/test-user/theirs", "{}", None).await?;
|
|
insert_script(&db, 9000005, "f/secret/hidden", "{}", None).await?;
|
|
// Visible only through an individual share out of an unreadable folder.
|
|
insert_script(
|
|
&db,
|
|
9000006,
|
|
"f/secret/shared_to_user",
|
|
r#"{"u/test-user-2": false}"#,
|
|
None,
|
|
)
|
|
.await?;
|
|
// Visible only through a group the caller belongs to.
|
|
insert_script(
|
|
&db,
|
|
9000007,
|
|
"f/secret/shared_to_group",
|
|
r#"{"g/all": false}"#,
|
|
None,
|
|
)
|
|
.await?;
|
|
// A pipeline member in a readable folder: listed as the folder's pipeline
|
|
// entry, not as a runnable, so it must not inflate f/team's count.
|
|
insert_script(&db, 9000008, "f/team/step", "{}", Some("pipeline")).await?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, archived)
|
|
VALUES ('test-workspace', 'f/team/fl1', '', '', '{}'::jsonb, 'test-user', '{}'::json, false)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)
|
|
VALUES ('test-workspace', 'f/team/app1', '', '{}'::jsonb, ARRAY[]::bigint[], '{}'::jsonb)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// test-user-2 is a plain member: the folder grant, their own space, the direct
|
|
// share and the group share must each land, and nothing else.
|
|
let counts = counts_endpoint(port, "SECRET_TOKEN_2").await;
|
|
assert_eq!(
|
|
counts,
|
|
counts_from_list(port, "SECRET_TOKEN_2").await,
|
|
"non-admin counts must match the RLS-enforced listing"
|
|
);
|
|
assert_eq!(
|
|
counts,
|
|
HashMap::from([
|
|
("f/team".to_string(), 4),
|
|
("u/test-user-2".to_string(), 1),
|
|
("f/secret".to_string(), 2),
|
|
]),
|
|
"expected the folder grant (2 scripts + flow + app, pipeline member excluded), \
|
|
the own user space, and the two shares out of f/secret"
|
|
);
|
|
|
|
// An admin bypasses RLS entirely (and takes the grouped whole-workspace path
|
|
// rather than the per-owner prefix scans), so the counts must widen accordingly.
|
|
assert_eq!(
|
|
counts_endpoint(port, "SECRET_TOKEN").await,
|
|
counts_from_list(port, "SECRET_TOKEN").await,
|
|
"admin counts must match the RLS-enforced listing"
|
|
);
|
|
|
|
// Every kind is its own count subquery, so a repeated entry must not be counted
|
|
// twice (nor multiply the scans).
|
|
assert_eq!(
|
|
counts_endpoint_q(port, "SECRET_TOKEN_2", "kinds=script,script").await,
|
|
counts_endpoint_q(port, "SECRET_TOKEN_2", "kinds=script").await,
|
|
"duplicate kinds must not double the counts"
|
|
);
|
|
|
|
// Operators see flows and apps like anyone else; only library scripts are hidden
|
|
// from them. (test-user-3 has no grants, so their own space is the whole story.)
|
|
sqlx::query("UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'")
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, archived)
|
|
VALUES ('test-workspace', 'u/test-user-3/fl', '', '', '{}'::jsonb, 'test-user-3', '{}'::json, false)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
insert_script(&db, 9000009, "u/test-user-3/lib", "{}", Some("lib")).await?;
|
|
assert_eq!(
|
|
counts_endpoint_q(port, "SECRET_TOKEN_3", "include_without_main=true").await,
|
|
HashMap::from([("u/test-user-3".to_string(), 1)]),
|
|
"an operator's flow must be counted, and library scripts stay hidden from them \
|
|
even when asked for"
|
|
);
|
|
Ok(())
|
|
}
|