Files
windmill/backend/windmill-api-integration-tests/tests/native_triggers.rs
T
dc1fb5087b feat: let native triggers act through per-user or shared connections (#11352)
* fix: only let an oauth account's creator or readers link it to a variable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop an oauth account link the caller may not hold instead of failing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover relinking an oauth account through a variable update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover a permitted relink through a variable update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep an oauth account's creator in step with the user's username

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: follow a non-member superadmin's instance username on the OAuth accounts they created

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: link a variable to its OAuth account only in the connect flow

The variable endpoints no longer take an account link from the caller. The connect
flows create the token variable first and pass its path to create_account, which
links it server-side. This replaces the creator check and the created_by
bookkeeping, along with both migrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: keep the MCP secret variable test's formatting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: link the account to the token variable AppConnect created, even if the path is edited mid-save

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stop exporting the OAuth account a variable links, and regenerate the chat tool schema

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the EE refresh and linking checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: delete AppConnect's token variable when linking its account fails, so Save can be retried

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the resource AppConnect fills when linking its account fails

Deleting a variable also deletes the resource at its path, so the rollback
is skipped when filling an existing resource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: create an OAuth token variable and its account in one request

createAccount takes the variable and the store creates both in one
transaction, so a connect is either fully saved or not at all. The connect
flows make that single call instead of creating the variable and linking it
afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stop listing the ignored account input in the chat's write_variable instructions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the account visibility helper the oauth refresh route no longer uses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: let native triggers act through per-user or shared connections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: bind native trigger oauth state to its user and follow connection renames

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: authorize oauth account links and scope native trigger disconnects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: order the native trigger connection migration after account ownership

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: delete only the scope-checked triggers when disconnecting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: skip triggers moved since their scope check when disconnecting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: scope-check the connection path when connecting, keep a connection still in use

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: key token write-back by account and serialize trigger creation with disconnect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: let an admin share one native trigger connection with the whole workspace

Members can use the shared connection without reading its token. It reuses
workspace_integrations.resource_path, so existing workspace connections stay
usable by members after upgrading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: pick the workspace connection like other workspace resources, connect and disconnect from the picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: label the disconnect action in the connection picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop the own-account note from the workspace connection field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let admins share any connection, serialize service removal with trigger creation

An admin can already read every connection, so restricting sharing to their own
added nothing. Removing a service now takes each connection's lock and keeps
everything if a trigger was created meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: show each native trigger service's OAuth app status and keep removal apart

Remove now only appears with a workspace OAuth app, since a workspace using the
instance app has nothing of its own to remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: configure a native trigger service's OAuth app in a dialog

The card states once which OAuth app is in use, with a single Configure action,
and the form follows the design system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop a stray brace from the OAuth app dialog binding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: lead the OAuth app row with which app is in use, with a link to change it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: compact the native trigger service card

Edit and Remove sit on the workspace OAuth app row, and the workspace connection
reads as part of the same section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: show the OAuth app status in each native trigger card's header

App actions move into a menu, the card body holds only the shared account, and
service descriptions name what fires the trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say who configured each native trigger service, and name removal for what it removes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: say what members can do with each native trigger service, keep the shared account optional

Nextcloud, whose webhooks need an admin account, offers connecting and sharing one
directly instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: pick the shared account with an inline select, and keep Nextcloud's own-account path in its wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: one status line per native trigger service, a Nextcloud admin hint, and clearer refusals

The shared account is a regular form field, Nextcloud shows an alert while no
admin account is shared, a non-admin Nextcloud account gets a message saying why
it cannot create triggers, and the OAuth dialog only accepts a full server URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: point native trigger error hints at the account picker and the shared account

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say members can browse what a shared native trigger account can access

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: make the OAuth app dialog's open prop required

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep pre-connection triggers working, and guard reconnects and service removal

- The migration recovers a service's connection when the workspace row lost its path,
  which saving the OAuth app or an abandoned instance connect used to do.
- A trigger created without a connection uses the shared account, as older clients and
  trigger specs expect.
- Only whoever connected a path, or an admin, may reconnect over it.
- Removing a service or sharing an account checks a path-scoped token against what it touches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: connect native trigger accounts at a chosen path through the resource drawer, and reconnect from the picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: date the native trigger connection migration after main's newest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say a disconnect deletes the triggers other members created on the account

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: offer Reconnect next to an existing trigger's account and point the refresh hints at it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: date the native trigger connection migration after main's newest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to da49c12e3006fc77de789277f1462a4d8d7d92ce

This commit updates the EE repository reference after PR #829 was merged in windmill-ee-private.

Previous ee-repo-ref: 58681a2e7eafb5689ed739cd2560fa0f5d82c3dc

New ee-repo-ref: da49c12e3006fc77de789277f1462a4d8d7d92ce

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-22 16:17:51 +02:00

1139 lines
35 KiB
Rust

/*!
* Integration tests for the native trigger system (Google).
*
* Tests cover 4 business-logic areas:
* 1. Resource path change — cleanup old path, recreate at new path
* 2. Config loading — workspace-level, instance-level, token update
* 3. Channel expiration renewal — should_renew_channel pure logic
* 4. Delete workspace integration — full cascade, cleanup preserves triggers, parse_stop_channel_params
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use axum::http::StatusCode;
use windmill_api_auth::{ApiAuthed, ScopePathFilter};
use windmill_common::{
db::UserDB,
error::Error,
variables::{build_crypt, encrypt},
};
use windmill_native_triggers::{
classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
get_workspace_integration,
github::GitHub,
google::{parse_stop_channel_params, should_renew_channel},
grant_refused, http_error_status, list_native_triggers, list_usable_connections,
map_external_error, native_trigger_is_enabled,
nextcloud::NextCloud,
require_native_integration_use, resolve_usable_connection, set_native_trigger_enabled,
store_native_trigger, store_workspace_integration, update_native_trigger,
update_oauth_token_resource, External, ExternalReadFailure, HttpRequestError,
NativeTriggerConfig, OAuthConfig, ServiceName,
};
// ============================================================================
// Helpers
// ============================================================================
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
'test-user', 'python3', 'script', '')",
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
fn test_authed() -> ApiAuthed {
ApiAuthed {
email: "test@windmill.dev".to_string(),
username: "test-user".to_string(),
is_admin: true,
is_operator: false,
groups: vec!["all".to_string()],
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,
credential_expiry: None,
}
}
/// Set up a complete workspace integration with account+variable+resource.
/// Returns (resource_path, account_id).
async fn setup_oauth_integration(
db: &Pool<Postgres>,
service_name: ServiceName,
resource_path: &str,
access_token: &str,
refresh_token: &str,
oauth_data_override: Option<serde_json::Value>,
) -> anyhow::Result<i32> {
// 1. Create account with is_workspace_integration=true
let account_id: i32 = sqlx::query_scalar!(
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)
RETURNING id",
service_name.as_str(),
refresh_token,
)
.fetch_one(db)
.await?;
// 2. Encrypt and create variable
let mc = build_crypt(db, "test-workspace").await?;
let encrypted = encrypt(&mc, access_token);
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
resource_path,
encrypted,
account_id,
)
.execute(db)
.await?;
// 3. Create resource
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
resource_path,
resource_value,
service_name.resource_type(),
)
.execute(db)
.await?;
// 4. Store workspace integration with resource_path
let oauth_data = oauth_data_override.unwrap_or_else(|| {
json!({
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"base_url": "https://example.com",
"resource_path": resource_path,
})
});
let authed = test_authed();
let mut tx = db.begin().await?;
store_workspace_integration(
&mut *tx,
&authed,
"test-workspace",
service_name,
oauth_data,
)
.await?;
tx.commit().await?;
Ok(account_id)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
// ============================================================================
// 1. Several connections of one service
// ============================================================================
/// Each connection keeps its own tokens: a refresh written back for one, or disconnecting one,
/// must not touch another. Providers that rotate refresh tokens invalidate the old one, so a
/// refresh token copied onto the wrong account breaks that account's next refresh.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_connections_are_isolated(db: Pool<Postgres>) -> anyhow::Result<()> {
let path_a = "u/test-user/native_nextcloud";
let path_b = "u/other-user/native_nextcloud";
let mut account_a = 0;
for (path, token) in [(path_a, "a"), (path_b, "b")] {
let account = setup_oauth_integration(
&db,
ServiceName::Nextcloud,
path,
&format!("access-{token}"),
&format!("refresh-{token}"),
None,
)
.await?;
if path == path_a {
account_a = account;
}
}
update_oauth_token_resource(
&db,
"test-workspace",
account_a,
"access-a2",
Some("refresh-a2"),
)
.await;
let a: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_a)).await?;
let b: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?;
assert_eq!(
(a.access_token.as_str(), a.refresh_token.as_deref()),
("access-a2", Some("refresh-a2"))
);
assert_eq!(
(b.access_token.as_str(), b.refresh_token.as_deref()),
("access-b", Some("refresh-b"))
);
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_connection(
&mut *tx,
"test-workspace",
path_a,
)
.await?;
tx.commit().await?;
assert!(decrypt_oauth_data::<OAuthConfig>(
&db,
"test-workspace",
ServiceName::Nextcloud,
Some(path_a)
)
.await
.is_err());
let b: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?;
assert_eq!(b.refresh_token.as_deref(), Some("refresh-b"));
Ok(())
}
/// A member who cannot read the admin's connection may still use it once it is shared as the
/// workspace connection.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_workspace_connection_usable_by_members(db: Pool<Postgres>) -> anyhow::Result<()> {
let path = "u/test-user/native_nextcloud";
setup_oauth_integration(&db, ServiceName::Nextcloud, path, "a", "r", None).await?;
let member = ApiAuthed {
email: "test2@windmill.dev".to_string(),
username: "test-user-2".to_string(),
is_admin: false,
..test_authed()
};
let usable_paths = || async {
let mut tx = UserDB::new(db.clone()).begin(&member).await?;
let connections =
list_usable_connections(&db, &mut tx, "test-workspace", ServiceName::Nextcloud).await?;
tx.commit().await?;
anyhow::Ok(
connections
.into_iter()
.map(|c| (c.path, c.workspace))
.collect::<Vec<_>>(),
)
};
assert!(usable_paths().await?.is_empty());
sqlx::query(
"UPDATE workspace_integrations SET resource_path = $1
WHERE workspace_id = 'test-workspace' AND service_name = 'nextcloud'",
)
.bind(path)
.execute(&db)
.await?;
assert_eq!(usable_paths().await?, vec![(path.to_string(), true)]);
// Clients and specs that predate connections name none: they keep getting the shared
// account once the member also has their own.
let own = "u/test-user-2/native_nextcloud";
setup_oauth_integration(&db, ServiceName::Nextcloud, own, "b", "s", None).await?;
let mut tx = UserDB::new(db.clone()).begin(&member).await?;
let default =
resolve_usable_connection(&db, &mut tx, "test-workspace", ServiceName::Nextcloud, None)
.await?;
tx.commit().await?;
assert_eq!(usable_paths().await?.len(), 2);
assert_eq!(default, path);
Ok(())
}
// ============================================================================
// 2. Config Loading — workspace vs instance + token update
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_workspace_level(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"ws-access-token",
"ws-refresh-token",
None,
)
.await?;
let config: OAuthConfig = decrypt_oauth_data(
&db,
"test-workspace",
ServiceName::Google,
Some(resource_path),
)
.await?;
assert_eq!(config.access_token, "ws-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token"));
assert_eq!(config.client_id, "test-client-id");
assert_eq!(config.client_secret, "test-client-secret");
assert_eq!(config.base_url, "https://example.com");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_instance_level(db: Pool<Postgres>) -> anyhow::Result<()> {
// Insert instance-level credentials into global_settings
sqlx::query!(
"INSERT INTO global_settings (name, value) VALUES ('oauths', $1)
ON CONFLICT (name) DO UPDATE SET value = $1",
json!({
"gworkspace": {
"id": "instance-client-id",
"secret": "instance-client-secret"
}
}),
)
.execute(&db)
.await?;
let resource_path = "u/test-user/native_gworkspace";
let oauth_data = json!({
"instance_shared": true,
"base_url": "https://accounts.google.com",
"resource_path": resource_path,
});
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"inst-access-token",
"inst-refresh-token",
Some(oauth_data),
)
.await?;
let config: OAuthConfig = decrypt_oauth_data(
&db,
"test-workspace",
ServiceName::Google,
Some(resource_path),
)
.await?;
assert_eq!(config.client_id, "instance-client-id");
assert_eq!(config.client_secret, "instance-client-secret");
assert_eq!(config.access_token, "inst-access-token");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"old-access-token",
"old-refresh-token",
None,
)
.await?;
// Verify old tokens
let config: OAuthConfig = decrypt_oauth_data(
&db,
"test-workspace",
ServiceName::Google,
Some(resource_path),
)
.await?;
assert_eq!(config.access_token, "old-access-token");
// Simulate token refresh: update variable + account
let mc = build_crypt(&db, "test-workspace").await?;
let new_encrypted = encrypt(&mc, "new-access-token");
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
new_encrypted,
resource_path,
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
"new-refresh-token",
account_id,
)
.execute(&db)
.await?;
// Verify new tokens
let config: OAuthConfig = decrypt_oauth_data(
&db,
"test-workspace",
ServiceName::Google,
Some(resource_path),
)
.await?;
assert_eq!(config.access_token, "new-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token"));
Ok(())
}
// ============================================================================
// 3. Channel Expiration Renewal — should_renew_channel
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_require_native_integration_use_needs_code_and_manage_triggers(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
// The pickers drive the admin-connected account's upstream API: the default operator, which
// may manage triggers but not write code, must stay locked out of them.
windmill_common::roles::invalidate_all_roles_cache();
let mut operator = test_authed();
operator.username = "operator".to_string();
operator.is_admin = false;
operator.is_operator = true;
assert!(
require_native_integration_use(&db, "test-workspace", &operator)
.await
.is_err()
);
let set_override = |caps: &'static str| {
let db = db.clone();
async move {
sqlx::query(
"UPDATE workspace_settings SET role_overrides = jsonb_build_object('operator',
jsonb_build_object('capabilities', $1::jsonb))
WHERE workspace_id = 'test-workspace'",
)
.bind(caps)
.execute(&db)
.await
.unwrap();
windmill_common::roles::invalidate_all_roles_cache();
}
};
set_override(r#"{"code": true}"#).await;
assert!(
require_native_integration_use(&db, "test-workspace", &operator)
.await
.is_ok()
);
set_override(r#"{"code": true, "manage_triggers": false}"#).await;
assert!(
require_native_integration_use(&db, "test-workspace", &operator)
.await
.is_err()
);
assert!(
require_native_integration_use(&db, "test-workspace", &test_authed())
.await
.is_ok()
);
Ok(())
}
#[test]
fn test_should_renew_drive_channel_expired() {
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() - 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_within_window() {
// 30 minutes remaining — within the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 30 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_not_yet() {
// 2 hours remaining — outside the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 2 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_within_window() {
// 12 hours remaining — within the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 12 * 60 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_not_yet() {
// 2 days remaining — outside the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 2 * 24 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_zero_expiration() {
let config = json!({
"triggerType": "drive",
"expiration": "0",
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_missing_fields() {
assert!(!should_renew_channel(&json!({})));
}
// ============================================================================
// 4. Delete Workspace Integration
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Add a native trigger linked to this integration
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
true,
"u/test-user/native_conn",
)
.await?;
// Step 1: Delete triggers
let deleted =
delete_native_trigger(&db, "test-workspace", ServiceName::Google, "ext-1").await?;
assert!(deleted);
// Step 2: Cleanup OAuth resources
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_service_connections(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await?;
tx.commit().await?;
// Step 3: Delete workspace integration
let mut tx = db.begin().await?;
let deleted =
delete_workspace_integration(&mut *tx, "test-workspace", ServiceName::Google).await?;
tx.commit().await?;
assert!(deleted);
// Verify everything is gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
let acc_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
account_id,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(acc_count, 0);
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0);
assert!(
get_workspace_integration(&db, "test-workspace", ServiceName::Google)
.await
.is_err()
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Create a trigger
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
true,
"u/test-user/native_conn",
)
.await?;
// Cleanup OAuth only — should NOT remove the trigger
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_connection(
&mut *tx,
"test-workspace",
resource_path,
)
.await?;
tx.commit().await?;
// OAuth resources gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
// Trigger still exists
let trigger_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'"
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(trigger_count, 1, "trigger should survive OAuth cleanup");
Ok(())
}
// ============================================================================
// 5. Runnable rename
// ============================================================================
/// A rename has to carry the trigger row onto the new path and report it as moved: listings only
/// return rows whose runnable still exists, so one left behind on the old path disappears from the
/// UI for good, and one not reported keeps a webhook aimed at the old path.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<()> {
insert_test_script(&db, "f/test/before").await?;
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-1",
&NativeTriggerConfig {
script_path: "f/test/before".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
},
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
None,
true,
"u/test-user/native_conn",
)
.await?;
// An unrelated trigger already sitting on the target path must not be reported as moved.
insert_test_script(&db, "f/test/after").await?;
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-2",
&NativeTriggerConfig {
script_path: "f/test/after".to_string(),
is_flow: false,
webhook_token: "0987654321jihgfedcba".to_string(),
},
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
None,
true,
"u/test-user/native_conn",
)
.await?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE script SET path = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
"f/test/after",
"f/test/before",
)
.execute(&mut *tx)
.await?;
let moved = windmill_common::triggers::update_triggers_script_path(
&mut tx,
"f/test/after",
"f/test/before",
"test-workspace",
false,
)
.await?;
tx.commit().await?;
assert_eq!(
moved
.iter()
.map(|t| (t.service_name.as_str(), t.external_id.as_str()))
.collect::<Vec<_>>(),
vec![("nextcloud", "ext-1")]
);
let triggers = list_native_triggers(
&db,
"test-workspace",
ServiceName::Nextcloud,
None,
None,
Some("f/test/after"),
Some(false),
ScopePathFilter::AllowAll,
)
.await?;
assert_eq!(
triggers.len(),
2,
"the moved trigger should be listed under the new path"
);
Ok(())
}
// ============================================================================
// 6. Token scope filtering on list
// ============================================================================
/// A `native_triggers:read:f/test/*` token must only list triggers under `f/test/`, on the `/`
/// boundary (`f/testx/` is out of scope).
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_list_native_triggers_scope_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
for (i, path) in ["f/testx/out", "f/test/in"].into_iter().enumerate() {
insert_test_script(&db, path).await?;
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
&format!("ext-{i}"),
&NativeTriggerConfig {
script_path: path.to_string(),
is_flow: false,
webhook_token: format!("abcdefghij123456789{i}"),
},
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
None,
true,
"u/test-user/native_conn",
)
.await?;
}
let triggers = list_native_triggers(
&db,
"test-workspace",
ServiceName::Nextcloud,
None,
None,
None,
None,
ScopePathFilter::Restricted { exact: vec![], prefix: vec!["f/test".to_string()] },
)
.await?;
assert_eq!(
triggers
.iter()
.map(|t| t.script_path.as_str())
.collect::<Vec<_>>(),
vec!["f/test/in"]
);
Ok(())
}
// --- parse_stop_channel_params ---
#[test]
fn test_parse_stop_channel_params_drive() {
let config = json!({
"triggerType": "drive",
"googleChannelId": "chan-abc",
"googleResourceId": "res-123",
});
let (channel_id, resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(channel_id.as_deref(), Some("chan-abc"));
assert_eq!(resource_id, "res-123");
assert!(
url.contains("googleapis.com/drive/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_calendar() {
let config = json!({
"triggerType": "calendar",
"googleChannelId": "chan-xyz",
"googleResourceId": "res-456",
});
let (channel_id, resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(channel_id.as_deref(), Some("chan-xyz"));
assert_eq!(resource_id, "res-456");
assert!(
url.contains("googleapis.com/calendar/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_default() {
// Missing triggerType defaults to Drive; missing googleChannelId yields None.
let config = json!({ "googleResourceId": "res-789" });
let (channel_id, resource_id, url) = parse_stop_channel_params(&config);
assert!(channel_id.is_none());
assert_eq!(resource_id, "res-789");
assert!(url.contains("drive/v3/channels/stop"), "url={}", url);
}
#[test]
fn test_parse_stop_channel_params_missing_resource_id() {
let config = json!({ "triggerType": "drive" });
let (channel_id, resource_id, _url) = parse_stop_channel_params(&config);
assert!(channel_id.is_none());
assert_eq!(resource_id, "");
}
// --- provider error reporting ---
fn nextcloud_error(status: StatusCode, body: &str) -> Error {
NextCloud.external_api_error(HttpRequestError::ApiError { status, body: body.to_string() })
}
/// A rejection has to reach the user as the service's own sentence plus what to do about it,
/// never as an internal error carrying the raw envelope.
#[test]
fn test_provider_rejection_is_readable_and_not_internal() {
let err = nextcloud_error(
StatusCode::FORBIDDEN,
r#"{"ocs":{"meta":{"status":"failure","statuscode":403,"message":"Logged in account must be an admin, a sub admin or gotten special right to access this setting"},"data":[]}}"#,
);
let message = map_external_error(err).to_string();
assert!(
message.contains("Logged in account must be an admin"),
"message={message}"
);
assert!(
!message.contains("\"ocs\""),
"the envelope should not reach the user: {message}"
);
assert!(
message.contains("the workspace's shared account"),
"the hint should say what to do: {message}"
);
}
/// Both the "trigger is gone on the service" path and the delete that tolerates an
/// already-removed webhook branch on this status.
#[test]
fn test_provider_404_is_recognized() {
let err = nextcloud_error(StatusCode::NOT_FOUND, "{}");
assert_eq!(http_error_status(&err), Some(StatusCode::NOT_FOUND));
assert!(
matches!(map_external_error(err), Error::NotFound(_)),
"a missing external trigger must map to NotFound"
);
assert!(matches!(
classify_read_failure(nextcloud_error(StatusCode::NOT_FOUND, "{}")),
ExternalReadFailure::Missing
));
}
/// Sending a user to reconnect their integration is only right when the token endpoint refused
/// the grant; a busy or broken endpoint has them fix credentials that are fine.
#[test]
fn test_refresh_failures_blame_only_the_grant_they_refuse() {
let refused = |code: u16| grant_refused(Some(StatusCode::from_u16(code).unwrap()), "");
for code in [400, 401, 403] {
assert!(refused(code), "{code} refuses the grant");
}
for code in [404, 408, 429, 500, 503] {
assert!(!refused(code), "{code} says nothing about the grant");
}
assert!(!grant_refused(None, ""));
// GitHub answers `bad_refresh_token` with HTTP 200, so the body is the only tell.
let ok = Some(StatusCode::OK);
assert!(grant_refused(ok, r#"{"error":"bad_refresh_token"}"#));
assert!(grant_refused(ok, r#"{"error":"invalid_grant"}"#));
assert!(!grant_refused(
ok,
r#"{"access_token":"t","token_type":"bearer"}"#
));
}
/// A service that is busy or broken has not refused anything, and callers react differently to
/// the two. GitHub and Google spend a 403 on throttling, where advice about permissions sends
/// the reader after a problem they do not have.
#[test]
fn test_transient_service_failures_are_not_refusals() {
for transient in [408, 429, 503] {
let err = nextcloud_error(StatusCode::from_u16(transient).unwrap(), "{}");
assert!(
matches!(map_external_error(err), Error::BadGateway(_)),
"{transient} should read as the service failing to serve, not refusing"
);
}
// GitHub words its throttle two ways, and neither is a permission problem.
for wording in [
"API rate limit exceeded for user ID 1.",
"You have exceeded a secondary rate limit.",
"You have triggered an abuse detection mechanism.",
] {
let throttled = GitHub.external_api_error(HttpRequestError::ApiError {
status: StatusCode::FORBIDDEN,
body: format!(r#"{{"message":"{wording}"}}"#),
});
let throttled = map_external_error(throttled);
assert!(
matches!(throttled, Error::BadGateway(_)),
"a throttled 403 is the service failing to serve: {throttled:?}"
);
assert!(
!throttled.to_string().contains("admin rights"),
"a throttled 403 must not advise about permissions: {throttled}"
);
}
let refused = GitHub.external_api_error(HttpRequestError::ApiError {
status: StatusCode::FORBIDDEN,
body: r#"{"message":"Must have admin rights to Repository."}"#.to_string(),
});
assert!(
map_external_error(refused)
.to_string()
.contains("admin rights"),
"a real 403 keeps its guidance"
);
}
/// A service read degrades to the stored configuration, but only for the service's own
/// failures: `External::get` also runs queries, and reporting one of those as the service's
/// word would hide a Windmill outage behind a 200.
#[test]
fn test_only_service_failures_degrade_the_read() {
assert!(matches!(
classify_read_failure(nextcloud_error(StatusCode::FORBIDDEN, "{}")),
ExternalReadFailure::Unreadable(_)
));
assert!(matches!(
classify_read_failure(Error::internal_err("connection pool timed out")),
ExternalReadFailure::Internal(_)
));
let internal = Error::internal_err("connection pool timed out");
assert!(
matches!(map_external_error(internal), Error::InternalErrLoc { .. }),
"a non-provider error must pass through unmapped"
);
}
/// The pause switch a webhook delivery is gated on. A trigger arrives enabled, survives an
/// unrelated edit, and an unknown one reads as enabled so a delivery Windmill cannot place is
/// never silently dropped.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_native_trigger_enabled_toggle(db: Pool<Postgres>) -> anyhow::Result<()> {
insert_test_script(&db, "f/test/handler").await?;
let config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-1",
&config,
json!({"event": "OCA\\Files\\Event\\LoadAdditionalScriptsEvent"}),
None,
true,
"u/test-user/native_conn",
)
.await?;
assert!(
native_trigger_is_enabled(&db, "test-workspace", ServiceName::Nextcloud, "ext-1").await?,
"a new trigger fires"
);
assert!(
set_native_trigger_enabled(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-1",
false
)
.await?
);
assert!(
!native_trigger_is_enabled(&db, "test-workspace", ServiceName::Nextcloud, "ext-1").await?
);
// Saving a configuration must not resume a trigger someone paused.
update_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-1",
&config,
None,
Some("edited"),
)
.await?;
assert!(
!native_trigger_is_enabled(&db, "test-workspace", ServiceName::Nextcloud, "ext-1").await?,
"an edit leaves the pause in place"
);
// A recreate registers a fresh trigger and must be able to come up already paused, in one
// write, rather than being enabled for as long as it takes a second call to arrive.
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-2",
&config,
json!({}),
None,
false,
"u/test-user/native_conn",
)
.await?;
assert!(
!native_trigger_is_enabled(&db, "test-workspace", ServiceName::Nextcloud, "ext-2").await?
);
// The conflict branch is a re-registration of a trigger that already exists, so it carries no
// opinion about the pause.
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
"ext-2",
&config,
json!({}),
None,
true,
"u/test-user/native_conn",
)
.await?;
assert!(
!native_trigger_is_enabled(&db, "test-workspace", ServiceName::Nextcloud, "ext-2").await?,
"re-registering leaves the pause in place"
);
assert!(
!set_native_trigger_enabled(
&db,
"test-workspace",
ServiceName::Nextcloud,
"unknown",
true
)
.await?,
"nothing to toggle"
);
assert!(
native_trigger_is_enabled(&db, "test-workspace", ServiceName::Nextcloud, "unknown").await?,
"a trigger Windmill has no row for is not treated as paused"
);
Ok(())
}