mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
* fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr)
Privilege escalation: an app/flow/schedule/trigger execution policy's `on_behalf_of`
(which a `wm_deployers` member can set) could point at a superadmin email. The
resulting job `WM_TOKEN` then passed the email-based superadmin checks, granting
instance superadmin. `forbid_superadmin_job_token` only guarded ~15 of ~75 routes.
Fix at the token layer: a WM_TOKEN must never satisfy a superadmin gate,
regardless of whose email it runs as (sentinel OR a real superadmin).
- `ApiAuthed` gains a `job_id` field, stamped once in `AuthCache::get_opt_job_authed`
from the resolved token's job_id (correct even on cache hits).
- `require_super_admin(db, email)` -> `require_super_admin(db, &ApiAuthed)`, rejects
`authed.job_id.is_some()`. `require_super_admin_email` kept for the few internal
callers without an ApiAuthed.
- `is_super_admin_authed(db, &ApiAuthed)` for the boolean `is_super_admin_email`
authorization branches on request handlers (workspace deletion, fork drops,
dev-workspace attach/archive, object-storage SSRF exemption, custom dbname, EE GHES
+ connected repositories, ...). Migrate ~75 sites (OSS + EE).
- CUSTOM_INSTANCE_DB reads the *authenticated* job_id, not the caller-supplied
`?job_id` query param. Worker-tag check takes a precomputed job-aware `is_super_admin`
on the request path.
Execution-time on-behalf checks (scheduled/flow worker-tag, Cloud enqueue quota,
is_devops_email) are hardened in a follow-up — see
docs/followup-onbehalf-execution-privilege-hardening.md.
Regression tests: a superadmin-email WM_TOKEN is rejected on `require_super_admin`
routes, on `DELETE /workspaces/delete/{w}` (403, workspace preserved), and on the
CUSTOM_INSTANCE_DB lookup with no `?job_id` (401); real superadmin tokens still succeed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: cap devops role at workspace admin and reject reserved on_behalf_of identities
Extends the job-token cap with three pieces:
- `require_devops_role` takes `&ApiAuthed` and rejects job tokens.
`is_devops_email` is true for superadmin emails, so every worker-management,
instance-config and service-log route was reachable by the same superadmin
`WM_TOKEN` that `require_super_admin` already rejects.
- A `job_id` claim that does not parse as a uuid rejects the token rather than
resolving to `None`, which would clear the job provenance and uncap it. Applies
to the internal JWT and the external `jwt_ext_` path.
- Defense in depth at store time: `validate_on_behalf_of` refuses the reserved
internal sentinels as an `on_behalf_of` on apps/flows/scripts/schedules/triggers,
and app execution refuses a policy carrying one — covering already-persisted and
forked-app rows that predate the cap. Deploying on behalf of a real user,
including a real superadmin, stays allowed; the cap handles that at execution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mcp): preserve job-token provenance when minting the proxy JWT
The MCP endpoint-tool proxy re-mints a JWT from the caller's ApiAuthed to
forward the proxied request, but passed job_id: None. A job's WM_TOKEN is
capped at workspace admin (GHSA-hfh4-cx4h-3fcr); dropping the job_id here
re-minted an uncapped token that satisfies require_super_admin /
require_devops_role on the proxied route (e.g. listWorkers exposing worker
IPs, job/workspace IDs, and sensitive tags).
Carry api_authed.job_id into create_jwt_token. Adds an in-module regression
that decodes the forwarded JWT and asserts the job_id is preserved for a job
caller and absent for a non-job caller.
Reported by Codex CI review (P1) on #10124.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: cap the admin-or-devops gate at workspace admin for job tokens
require_admin_or_devops (the EE critical-alerts endpoints) grants when the
caller is a workspace admin OR an instance devops. is_devops_email is true
for superadmins, so a WM_TOKEN running on-behalf of a superadmin who is not a
member of the target workspace could clear the devops branch and read/ack that
workspace's critical alerts (GHSA-hfh4-cx4h-3fcr). This gate takes a bare
email, not an ApiAuthed, so the token-layer cap could not see it.
Thread the caller's job-token provenance and reject the devops branch for job
tokens, matching require_devops_role. The workspace-admin branch stays allowed
— that is the cap ceiling. Adds an enterprise-gated regression proving the
bypass is closed and a real superadmin token still clears the gate.
Found while auditing the PR for bare-email gates the choke-point cap misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: cap instance-global is_admin gates at workspace admin for job tokens
Three instance-global routes gate on the caller's own `is_admin` claim, which
`ApiAuthed.is_admin` carries into a WM_TOKEN (it is a workspace-admin claim,
true for superadmins too). A job token is capped at workspace admin
(GHSA-hfh4-cx4h-3fcr), so its is_admin claim must not authorize instance
actions on a route with no workspace binding:
- `unarchive_workspace` — unarchive an arbitrary workspace by id
- `prune_concurrency_group` — delete a global concurrency group
- `list_worker_groups` — return unobfuscated `env_vars_static` (may hold secrets)
Add job-token-aware `is_instance_admin` / `require_instance_admin` helpers (the
same shape as `require_super_admin` / `require_devops_role`) and use them at
these three sites. Workspace-scoped `require_admin(authed.is_admin, ...)` gates
are intentionally left unchanged — a workspace-admin job token is within the
cap there. Regression added covering all three; verified it lets a WM_TOKEN
unarchive/leak without the fix and is blocked with it.
Reported by Codex CI review (P1) on #10124.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mcp): drop orphaned path_field_renames from EndpointTool test helper
The merge with main adopted main's mcp path-substitution refactor (#10162),
which removed the `path_field_renames` field from `EndpointTool` and its
consumer (`substitute_path_params` no longer takes per-field path renames).
main's `runner.rs` `ep` test helper still constructed the struct with
`path_field_renames: None`, so the workspace test build (cargo test --all,
which compiles windmill-mcp's own #[cfg(test)] module under the `server`
feature) failed with E0560. A plain `cargo check` does not compile that test
module, so it only surfaced in CI's cargo_test.
Remove the orphaned field to match the struct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: describe the sentinel-rejection policy the forged-identity test asserts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: complete ApiAuthed initializers in feature-gated tests after merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: stop job tokens minting credentials that shed their provenance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cap the MCP OAuth approval mint at the same elevated-job-token gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cap the self-service password reset at the elevated-job-token gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cap app embed/SDK mints and scope widening at the elevated-job-token gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: keep job tokens from destroying the account they run on behalf of
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: deny job tokens a foreign-workspace admin claim and workspace ejection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: keep the follow-up inventory in the PR instead of the repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: make the session workspace status gate job-token aware
session_workspace_status derived its superadmin branch from a bare email
check, so a job token carrying a superadmin identity resolved the existence
of workspaces it has no relationship with rather than seeing them as
deleted. Switch to is_super_admin_authed, matching every other instance
gate reached from a request ApiAuthed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert: leave the global concurrency-group listing on the plain admin gate
The listing exposes concurrency keys across workspaces, which is metadata
rather than a capability, and it 401s rather than degrading. Keep the guard
on the prune route next to it, which is the destructive one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the instance-admin gate on the global concurrency listing
The listing spans every workspace's concurrency keys, and the gate rejects
only job tokens: the !is_admin branch is the pre-existing check, so
workspaced tokens and interactive admins are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to d30af67d38954f9012f7bad08da23e347344b4c6
This commit updates the EE repository reference after PR #664 was merged in windmill-ee-private.
Previous ee-repo-ref: 7870573dbc3360f99bada143f094c67dce0d9e9c
New ee-repo-ref: d30af67d38954f9012f7bad08da23e347344b4c6
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
453 lines
15 KiB
Rust
453 lines
15 KiB
Rust
//! What a caller who cannot see a dbt script gets from a run of it.
|
|
//!
|
|
//! The share-link case: `require_job_read_access` has already let them through
|
|
//! to the job, so `asset_graph_for` is handed the run — but the project itself
|
|
//! is not theirs to read. The graph's SHAPE must survive that and its model SQL
|
|
//! must not, and the two are decided by different predicates. Resolving the
|
|
//! version from the caller's `script` access instead of from the job emptied the
|
|
//! whole dbt half, and every later fix in this area re-touched one of the two.
|
|
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_api_assets::{asset_graph_for, GraphQuery, PinnedRun};
|
|
use windmill_api_auth::ApiAuthed;
|
|
use windmill_common::db::UserDB;
|
|
|
|
const WS: &str = "test-workspace";
|
|
const PATH: &str = "f/private/proj";
|
|
const HASH: i64 = 42;
|
|
|
|
/// A member of the workspace with no grant on `f/private` — the reason someone
|
|
/// is sent a share link in the first place.
|
|
fn outsider() -> ApiAuthed {
|
|
ApiAuthed {
|
|
email: "outsider@windmill.dev".to_string(),
|
|
username: "outsider".to_string(),
|
|
is_admin: false,
|
|
is_operator: false,
|
|
groups: vec![],
|
|
folders: vec![],
|
|
scopes: None,
|
|
username_override: None,
|
|
username_override_is_token_label: false,
|
|
is_session_token: false,
|
|
token_prefix: None,
|
|
read_only: false,
|
|
job_id: None,
|
|
}
|
|
}
|
|
|
|
async fn seed(db: &Pool<Postgres>, job: uuid::Uuid) {
|
|
sqlx::query!(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
|
|
VALUES ($1, 'private', 'private', '{}', '{}')",
|
|
WS
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
|
|
created_by, language, lock)
|
|
VALUES ($1, $2, $3, '', '', 'profile: {}', 'test-user', 'dbt', '')",
|
|
WS,
|
|
HASH,
|
|
PATH,
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query!(
|
|
"INSERT INTO dbt_graph_snapshot (workspace_id, script_path, script_hash, job_id, digest)
|
|
VALUES ($1, $2, $3, $4, 'd')",
|
|
WS,
|
|
PATH,
|
|
HASH,
|
|
job
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query!(
|
|
r#"INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id,
|
|
resource_type, name, asset_path, raw_code, tags, description,
|
|
columns, freshness)
|
|
VALUES ($1, $2, $3, $4, 'model.p.orders', 'model', 'orders',
|
|
'u/a/wh/analytics/orders', 'select 1', '{finance}', 'daily order facts',
|
|
'{"order_id": {"description": "natural key"}}'::jsonb,
|
|
'{"warn_after": {"count": 12, "period": "hour"}}'::jsonb)"#,
|
|
WS,
|
|
PATH,
|
|
HASH,
|
|
job
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
// A test node, for the arguments it carries: `accepted_values` spells out a
|
|
// column's domain.
|
|
sqlx::query!(
|
|
r#"INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id,
|
|
resource_type, name, tags, test_kind, test_column, test_args,
|
|
attached_node)
|
|
VALUES ($1, $2, $3, $4, 'test.p.accepted_values_orders_status', 'test',
|
|
'accepted_values_orders_status', '{}', 'accepted_values', 'status',
|
|
'{"values": ["gold", "silver"]}'::jsonb, 'model.p.orders')"#,
|
|
WS,
|
|
PATH,
|
|
HASH,
|
|
job
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Everything on a node that the project's author wrote, rather than the shape
|
|
/// of the relation it produces.
|
|
const AUTHORED: [&str; 5] = [
|
|
"select 1",
|
|
"daily order facts",
|
|
"finance",
|
|
"natural key",
|
|
"gold",
|
|
];
|
|
|
|
fn query() -> GraphQuery {
|
|
GraphQuery { asset_kinds: Some("dbt".to_string()), folder: None, dbt_script_hash: None }
|
|
}
|
|
|
|
/// Pinned to a run they are entitled to, the outsider gets the model — and
|
|
/// nothing its author wrote. Both halves matter: dropping the first is the blank
|
|
/// Models panel under working progress rows, dropping the second hands the
|
|
/// project's source and documentation to anyone holding a link.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_pinned_run_survives_no_access_to_its_script(db: Pool<Postgres>) {
|
|
let job = uuid::Uuid::from_u128(7);
|
|
seed(&db, job).await;
|
|
|
|
let pinned = PinnedRun { job_id: job, script_path: PATH.to_string(), script_hash: Some(HASH) };
|
|
let res = asset_graph_for(
|
|
&outsider(),
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
Some(pinned),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = serde_json::to_value(&res.0).unwrap();
|
|
|
|
let nodes = body["dbt_nodes"]
|
|
.as_array()
|
|
.or(body["assets"].as_array())
|
|
.unwrap();
|
|
assert!(
|
|
!nodes.is_empty(),
|
|
"a run the caller may read must render, whatever their access to the project: {body}"
|
|
);
|
|
assert_eq!(
|
|
body["dbt_snapshot_job"],
|
|
serde_json::json!(job),
|
|
"and the marker must agree with it, or the page polls the graph 40 times"
|
|
);
|
|
for authored in AUTHORED {
|
|
assert!(
|
|
!body.to_string().contains(authored),
|
|
"`{authored}` is the project's, and stays behind access to it: {body}"
|
|
);
|
|
}
|
|
assert!(
|
|
body.to_string().contains("u/a/wh/analytics/orders"),
|
|
"while the relation the run wrote is what the page is for: {body}"
|
|
);
|
|
|
|
// The same read by someone who may open the project: the gate has to be the
|
|
// caller's access, not a field this endpoint stopped serving.
|
|
let seen = asset_graph_for(
|
|
&ApiAuthed { is_admin: true, ..outsider() },
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
Some(PinnedRun { job_id: job, script_path: PATH.to_string(), script_hash: Some(HASH) }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let seen = serde_json::to_value(&seen.0).unwrap().to_string();
|
|
for authored in AUTHORED {
|
|
assert!(
|
|
seen.contains(authored),
|
|
"`{authored}` renders for a reader of the project: {seen}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Unpinned, the same caller sees nothing of the project: the workspace graph
|
|
/// answers for their own access, and this one is not theirs. This is the half
|
|
/// that must NOT be relaxed by making the pinned case work.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn without_a_run_the_same_caller_sees_no_dbt_nodes(db: Pool<Postgres>) {
|
|
let job = uuid::Uuid::from_u128(7);
|
|
seed(&db, job).await;
|
|
|
|
let res = asset_graph_for(
|
|
&outsider(),
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = serde_json::to_value(&res.0).unwrap();
|
|
assert!(
|
|
!body.to_string().contains("orders"),
|
|
"an unpinned graph is the caller's own view of the workspace: {body}"
|
|
);
|
|
}
|
|
|
|
/// Archiving retires the script; it must not retire the runs that already
|
|
/// happened. The pinned read resolves versions through a CTE that skips archived
|
|
/// rows, so nothing about the CURRENT workspace graph depends on the sidecar
|
|
/// surviving — which is exactly why deleting it looks safe and is not.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn archiving_the_script_leaves_its_finished_runs_renderable(db: Pool<Postgres>) {
|
|
let job = uuid::Uuid::from_u128(7);
|
|
seed(&db, job).await;
|
|
sqlx::query!(
|
|
"UPDATE script SET archived = true WHERE workspace_id = $1 AND hash = $2",
|
|
WS,
|
|
HASH
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
|
|
let admin = ApiAuthed { is_admin: true, ..outsider() };
|
|
let pinned = PinnedRun { job_id: job, script_path: PATH.to_string(), script_hash: Some(HASH) };
|
|
let res = asset_graph_for(
|
|
&admin,
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
Some(pinned),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
serde_json::to_value(&res.0)
|
|
.unwrap()
|
|
.to_string()
|
|
.contains("orders"),
|
|
"a completed run of an archived version still renders its models"
|
|
);
|
|
|
|
let now = asset_graph_for(
|
|
&admin,
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!serde_json::to_value(&now.0)
|
|
.unwrap()
|
|
.to_string()
|
|
.contains("model.p.orders"),
|
|
"while the workspace graph, which describes what is live, drops it"
|
|
);
|
|
}
|
|
|
|
/// `extra_perms` is a grant on a ROW, so a path recreated with narrower ones
|
|
/// leaves the old version readable to whoever the old row named. The source
|
|
/// probe therefore has to name the version it is about to return: matched on
|
|
/// the path alone, that stale grant answered for the pinned version's SQL and
|
|
/// handed a viewer the body of a project they were never given.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn an_old_grant_at_the_same_path_does_not_expose_a_newer_version(db: Pool<Postgres>) {
|
|
const V2: i64 = 43;
|
|
let v1_job = uuid::Uuid::from_u128(7);
|
|
let v2_job = uuid::Uuid::from_u128(8);
|
|
seed(&db, v1_job).await;
|
|
|
|
// The version the outsider WAS granted, archived — the shape the grant
|
|
// outlives the deploy in.
|
|
sqlx::query!(
|
|
r#"UPDATE script SET archived = true, extra_perms = '{"u/outsider": true}'::jsonb
|
|
WHERE workspace_id = $1 AND hash = $2"#,
|
|
WS,
|
|
HASH
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
|
|
// The version they were not.
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
|
|
created_by, language, lock)
|
|
VALUES ($1, $2, $3, '', '', 'profile: {}', 'test-user', 'dbt', '')",
|
|
WS,
|
|
V2,
|
|
PATH,
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query!(
|
|
"INSERT INTO dbt_graph_snapshot (workspace_id, script_path, script_hash, job_id, digest)
|
|
VALUES ($1, $2, $3, $4, 'd2')",
|
|
WS,
|
|
PATH,
|
|
V2,
|
|
v2_job
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query!(
|
|
"INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id,
|
|
resource_type, name, asset_path, raw_code, tags)
|
|
VALUES ($1, $2, $3, $4, 'model.p.orders', 'model', 'orders',
|
|
'u/a/wh/analytics/orders', 'select 2', '{}')",
|
|
WS,
|
|
PATH,
|
|
V2,
|
|
v2_job
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
|
|
let pinned = PinnedRun { job_id: v2_job, script_path: PATH.to_string(), script_hash: Some(V2) };
|
|
let res = asset_graph_for(
|
|
&outsider(),
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
Some(pinned),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = serde_json::to_value(&res.0).unwrap().to_string();
|
|
|
|
assert!(
|
|
body.contains("orders"),
|
|
"the run they were given still renders its models: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("select 2"),
|
|
"but not the SQL of a version their grant never covered: {body}"
|
|
);
|
|
}
|
|
|
|
/// The editor's own graph, keyed to the parse job and to no version.
|
|
async fn seed_editor_graph(db: &Pool<Postgres>, job: uuid::Uuid) {
|
|
sqlx::query!(
|
|
"INSERT INTO dbt_graph_snapshot (workspace_id, script_path, script_hash, job_id, digest)
|
|
VALUES ($1, $2, NULL, $3, 'd')",
|
|
WS,
|
|
PATH,
|
|
job
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query!(
|
|
r#"INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id,
|
|
resource_type, name, asset_path, raw_code, tags)
|
|
VALUES ($1, $2, NULL, $3, 'model.p.draft', 'model', 'draft',
|
|
'u/a/wh/analytics/draft', 'select 3', '{}')"#,
|
|
WS,
|
|
PATH,
|
|
job
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// A buffer parse is the third provenance: no deployed version behind it, and
|
|
/// its models are ones no `asset` row has heard of. It renders when pinned to
|
|
/// the job that produced it — the only way in — and its SQL comes with it, since
|
|
/// it exists because this caller's own parse job wrote the buffer.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn an_editor_graph_renders_only_through_its_own_job(db: Pool<Postgres>) {
|
|
let job = uuid::Uuid::from_u128(7);
|
|
seed(&db, job).await;
|
|
let parse = uuid::Uuid::from_u128(9);
|
|
seed_editor_graph(&db, parse).await;
|
|
|
|
let admin = ApiAuthed { is_admin: true, ..outsider() };
|
|
let pinned = asset_graph_for(
|
|
&admin,
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
Some(PinnedRun { job_id: parse, script_path: PATH.to_string(), script_hash: None }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = serde_json::to_value(&pinned.0).unwrap();
|
|
assert!(
|
|
body.to_string().contains("u/a/wh/analytics/draft"),
|
|
"the buffer's own models render: {body}"
|
|
);
|
|
assert!(
|
|
body.to_string().contains("select 3"),
|
|
"and their SQL, which is what the caller just wrote: {body}"
|
|
);
|
|
assert_eq!(
|
|
body["dbt_snapshot_job"],
|
|
serde_json::json!(parse),
|
|
"labelled as a graph of its own, so the editor can say where it came from"
|
|
);
|
|
assert!(
|
|
!body.to_string().contains("select 1"),
|
|
"and the deployed version's models are not mixed into it: {body}"
|
|
);
|
|
|
|
// Through the PATH — which is what the workspace graph and every run of the
|
|
// deployed version ask for — a buffer parse must not appear at all. It
|
|
// describes an editor's unsaved state, not what the script owns.
|
|
let workspace = asset_graph_for(
|
|
&admin,
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let workspace = serde_json::to_value(&workspace.0).unwrap().to_string();
|
|
assert!(
|
|
!workspace.contains("u/a/wh/analytics/draft"),
|
|
"an editor's buffer is not part of the workspace graph: {workspace}"
|
|
);
|
|
|
|
let deployed_run = asset_graph_for(
|
|
&admin,
|
|
WS,
|
|
UserDB::new(db.clone()),
|
|
db.clone(),
|
|
query(),
|
|
Some(PinnedRun { job_id: job, script_path: PATH.to_string(), script_hash: Some(HASH) }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let deployed_run = serde_json::to_value(&deployed_run.0).unwrap().to_string();
|
|
assert!(
|
|
!deployed_run.contains("u/a/wh/analytics/draft"),
|
|
"nor of a run of the deployed version: {deployed_run}"
|
|
);
|
|
}
|