mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 16:02:43 +00:00
* 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>
1139 lines
35 KiB
Rust
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(())
|
|
}
|