Skip to content

Commit dfeed9c

Browse files
rubenfiszelclaude
andauthored
fix: actionable error when a custom_path is taken by an app in another workspace (#9190)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 52960ca commit dfeed9c

6 files changed

Lines changed: 392 additions & 48 deletions

backend/.sqlx/query-5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b.json

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/.sqlx/query-debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/.sqlx/query-fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df.json

Lines changed: 0 additions & 25 deletions
This file was deleted.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! Regression test for the cross-workspace custom_path conflict.
2+
//!
3+
//! When custom paths are instance-global (CLOUD_HOSTED unset and
4+
//! `app_workspaced_route` off — the default for dedicated instances), a
5+
//! custom_path is a single global route slot. The uniqueness check correctly
6+
//! blocks two apps from claiming it, including the same logical app deployed
7+
//! to two workspaces (staging/prod, git-sync). The bug was that the error
8+
//! ("App with custom path <x> already exists") gave the operator no idea
9+
//! where the conflicting copy lived. This test pins down:
10+
//! - a single-workspace edit keeping its own custom_path still succeeds
11+
//! (the app's own row is excluded),
12+
//! - a real conflict is still rejected, and
13+
//! - the error now names the conflicting app's path and workspace.
14+
15+
use serde_json::json;
16+
use sqlx::{Pool, Postgres};
17+
use windmill_test_utils::*;
18+
19+
fn client() -> reqwest::Client {
20+
reqwest::Client::new()
21+
}
22+
23+
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
24+
builder.header("Authorization", format!("Bearer {}", token))
25+
}
26+
27+
fn new_app(path: &str, custom_path: &str) -> serde_json::Value {
28+
json!({
29+
"path": path,
30+
"summary": "Test app",
31+
"value": { "type": "rawapp", "inline_script": null },
32+
"policy": { "execution_mode": "anonymous", "triggerables": {} },
33+
"custom_path": custom_path
34+
})
35+
}
36+
37+
#[sqlx::test(fixtures("app_custom_path_cross_workspace"))]
38+
async fn test_custom_path_cross_workspace_deploy(db: Pool<Postgres>) -> anyhow::Result<()> {
39+
initialize_tracing().await;
40+
41+
let server = ApiServer::start(db.clone()).await?;
42+
let port = server.addr.port();
43+
let ws_a = format!("http://localhost:{port}/api/w/test-workspace");
44+
let ws_b = format!("http://localhost:{port}/api/w/test-workspace-2");
45+
46+
let app_path = "f/Newsletter/newsletter_composer";
47+
let custom_path = "newsletter";
48+
49+
// 1. Create the app with a custom path in workspace A.
50+
let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN")
51+
.json(&new_app(app_path, custom_path))
52+
.send()
53+
.await?;
54+
assert_eq!(
55+
resp.status(),
56+
201,
57+
"create app in ws A should succeed: {}",
58+
resp.text().await?
59+
);
60+
61+
// 2. Editing the app in its own workspace, keeping the same custom path,
62+
// must still succeed — the app's own row is excluded from the check.
63+
// (This is the common single-workspace deploy; it must not regress.)
64+
let resp = authed(
65+
client().post(format!("{ws_a}/apps/update/{app_path}")),
66+
"SECRET_TOKEN",
67+
)
68+
.json(&json!({
69+
"summary": "Test app (edited)",
70+
"value": { "type": "rawapp", "inline_script": null },
71+
"policy": { "execution_mode": "anonymous", "triggerables": {} },
72+
"custom_path": custom_path
73+
}))
74+
.send()
75+
.await?;
76+
assert_eq!(
77+
resp.status(),
78+
200,
79+
"editing an app in its own workspace keeping its custom path must succeed: {}",
80+
resp.text().await?
81+
);
82+
83+
// 3. Deploying the same app (same path) to a second workspace is a real
84+
// conflict in global mode (one global route slot). It must be rejected,
85+
// and the error must name the conflicting workspace + app so the
86+
// operator knows what to resolve.
87+
let resp = authed(client().post(format!("{ws_b}/apps/create")), "SECRET_TOKEN")
88+
.json(&new_app(app_path, custom_path))
89+
.send()
90+
.await?;
91+
let status = resp.status();
92+
let body = resp.text().await?;
93+
assert_eq!(
94+
status, 400,
95+
"same custom path in another workspace is a global conflict: {body}"
96+
);
97+
assert!(
98+
body.contains("test-workspace") && body.contains(app_path),
99+
"error must name the conflicting workspace and app, got: {body}"
100+
);
101+
102+
// 4. A genuinely different app claiming the in-use custom path is still
103+
// rejected, with the same actionable message.
104+
let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN")
105+
.json(&new_app("f/Other/other_app", custom_path))
106+
.send()
107+
.await?;
108+
let status = resp.status();
109+
let body = resp.text().await?;
110+
assert_eq!(
111+
status, 400,
112+
"a different app must not steal an in-use custom path: {body}"
113+
);
114+
assert!(
115+
body.contains(app_path),
116+
"error must name the app already using the custom path, got: {body}"
117+
);
118+
119+
Ok(())
120+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
-- Fixture for app_custom_path_cross_workspace regression test.
2+
-- Two workspaces sharing the same admin user, so the same logical app
3+
-- (same `path`) can be deployed to both — exercising the instance-global
4+
-- custom_path uniqueness behavior (CLOUD_HOSTED unset and
5+
-- app_workspaced_route off, the default for dedicated instances).
6+
7+
INSERT INTO workspace
8+
(id, name, owner)
9+
VALUES ('test-workspace', 'test-workspace', 'test-user');
10+
11+
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
12+
('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin');
13+
14+
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
15+
('test-workspace', 'cloud', 'test-key');
16+
17+
INSERT INTO workspace_settings (workspace_id) VALUES
18+
('test-workspace');
19+
20+
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
21+
('test-workspace', 'all', 'All users', '{}');
22+
23+
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
24+
VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user');
25+
26+
-- Second workspace, same admin user. Lets us deploy the same app path to
27+
-- two workspaces, which is what triggered the custom_path conflict.
28+
INSERT INTO workspace (id, name, owner) VALUES
29+
('test-workspace-2', 'test-workspace-2', 'test-user');
30+
31+
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
32+
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
33+
34+
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
35+
('test-workspace-2', 'cloud', 'test-key-2');
36+
37+
INSERT INTO workspace_settings (workspace_id) VALUES
38+
('test-workspace-2');
39+
40+
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
41+
('test-workspace-2', 'all', 'All users', '{}');
42+
43+
-- super_admin token so custom_path edits pass require_admin in both workspaces.
44+
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
45+
VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true);
46+
47+
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
48+
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
49+
50+
CREATE FUNCTION "notify_insert_on_completed_job" ()
51+
RETURNS TRIGGER AS $$
52+
BEGIN
53+
PERFORM pg_notify('completed', NEW.id::text);
54+
RETURN NEW;
55+
END;
56+
$$ LANGUAGE PLPGSQL;
57+
58+
CREATE TRIGGER "notify_insert_on_completed_job"
59+
AFTER INSERT ON "v2_job_completed"
60+
FOR EACH ROW
61+
EXECUTE FUNCTION "notify_insert_on_completed_job" ();
62+
63+
64+
CREATE FUNCTION "notify_queue" ()
65+
RETURNS TRIGGER AS $$
66+
BEGIN
67+
PERFORM pg_notify('queued', NEW.id::text);
68+
RETURN NEW;
69+
END;
70+
$$ LANGUAGE PLPGSQL;
71+
72+
CREATE TRIGGER "notify_queue_after_insert"
73+
AFTER INSERT ON "v2_job_queue"
74+
FOR EACH ROW
75+
EXECUTE FUNCTION "notify_queue" ();
76+
77+
CREATE TRIGGER "notify_queue_after_flow_status_update"
78+
AFTER UPDATE ON "v2_job_status"
79+
FOR EACH ROW
80+
WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status)
81+
EXECUTE FUNCTION "notify_queue" ();
82+
83+
-- Apply phase 4:
84+
DROP FUNCTION IF EXISTS v2_job_after_update CASCADE;
85+
DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE;
86+
DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE;
87+
DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE;
88+
DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE;
89+
DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE;
90+
DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE;
91+
DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE;
92+
DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE;
93+
DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE;
94+
95+
DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE;
96+
97+
ALTER TABLE v2_job_queue
98+
DROP COLUMN IF EXISTS __parent_job CASCADE,
99+
DROP COLUMN IF EXISTS __created_by CASCADE,
100+
DROP COLUMN IF EXISTS __script_hash CASCADE,
101+
DROP COLUMN IF EXISTS __script_path CASCADE,
102+
DROP COLUMN IF EXISTS __args CASCADE,
103+
DROP COLUMN IF EXISTS __logs CASCADE,
104+
DROP COLUMN IF EXISTS __raw_code CASCADE,
105+
DROP COLUMN IF EXISTS __canceled CASCADE,
106+
DROP COLUMN IF EXISTS __last_ping CASCADE,
107+
DROP COLUMN IF EXISTS __job_kind CASCADE,
108+
DROP COLUMN IF EXISTS __env_id CASCADE,
109+
DROP COLUMN IF EXISTS __schedule_path CASCADE,
110+
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
111+
DROP COLUMN IF EXISTS __flow_status CASCADE,
112+
DROP COLUMN IF EXISTS __raw_flow CASCADE,
113+
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
114+
DROP COLUMN IF EXISTS __language CASCADE,
115+
DROP COLUMN IF EXISTS __same_worker CASCADE,
116+
DROP COLUMN IF EXISTS __raw_lock CASCADE,
117+
DROP COLUMN IF EXISTS __pre_run_error CASCADE,
118+
DROP COLUMN IF EXISTS __email CASCADE,
119+
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
120+
DROP COLUMN IF EXISTS __mem_peak CASCADE,
121+
DROP COLUMN IF EXISTS __root_job CASCADE,
122+
DROP COLUMN IF EXISTS __leaf_jobs CASCADE,
123+
DROP COLUMN IF EXISTS __concurrent_limit CASCADE,
124+
DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE,
125+
DROP COLUMN IF EXISTS __timeout CASCADE,
126+
DROP COLUMN IF EXISTS __flow_step_id CASCADE,
127+
DROP COLUMN IF EXISTS __cache_ttl CASCADE;
128+
129+
LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE;
130+
ALTER TABLE v2_job_completed
131+
DROP COLUMN IF EXISTS __parent_job CASCADE,
132+
DROP COLUMN IF EXISTS __created_by CASCADE,
133+
DROP COLUMN IF EXISTS __created_at CASCADE,
134+
DROP COLUMN IF EXISTS __success CASCADE,
135+
DROP COLUMN IF EXISTS __script_hash CASCADE,
136+
DROP COLUMN IF EXISTS __script_path CASCADE,
137+
DROP COLUMN IF EXISTS __args CASCADE,
138+
DROP COLUMN IF EXISTS __logs CASCADE,
139+
DROP COLUMN IF EXISTS __raw_code CASCADE,
140+
DROP COLUMN IF EXISTS __canceled CASCADE,
141+
DROP COLUMN IF EXISTS __job_kind CASCADE,
142+
DROP COLUMN IF EXISTS __env_id CASCADE,
143+
DROP COLUMN IF EXISTS __schedule_path CASCADE,
144+
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
145+
DROP COLUMN IF EXISTS __raw_flow CASCADE,
146+
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
147+
DROP COLUMN IF EXISTS __language CASCADE,
148+
DROP COLUMN IF EXISTS __is_skipped CASCADE,
149+
DROP COLUMN IF EXISTS __raw_lock CASCADE,
150+
DROP COLUMN IF EXISTS __email CASCADE,
151+
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
152+
DROP COLUMN IF EXISTS __tag CASCADE,
153+
DROP COLUMN IF EXISTS __priority CASCADE;

0 commit comments

Comments
 (0)