Files
windmill/backend/windmill-api-integration-tests/tests/triggers.rs
hugocasa efb4a27d51 fix: replace email with permissioned_as for triggers/schedules (#8439)
* refactor: replace email with permissioned_as for triggers/schedules

Add a new `permissioned_as` column (format: `u/{username}`, `g/{group}`,
or raw email) to all trigger tables and schedule. This value is used
directly for job permission checks, removing the need for email lookups
when creating/updating triggers.

- Migration: add permissioned_as to all 9 trigger tables + schedule,
  drop email from trigger tables (schedule keeps it for backwards compat)
- Backend: resolve_email() (async, DB) -> resolve_permissioned_as() (sync)
- Email cache: get_email_from_permissioned_as() with quick_cache for
  places that still need email (fetch_api_authed, schedule backwards compat)
- Frontend: rename email/preserve_email -> permissioned_as/preserve_permissioned_as
  in deploy data and OpenAPI schemas
- Tests updated for new field names and u/{username} format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix sqlx/build

* update ee ref

* refactor: simplify resolve_edited_by to always use authed username

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix compile + migration

* update ref

* test: add trigger trait method tests for permissioned_as queries

Add tests that call TriggerCrud and Listener trait methods directly
to verify dynamic SQL correctly references the permissioned_as column.
Covers get_trigger_by_path, list_triggers, set_trigger_mode, and
fetch_enabled_unlistened_triggers for all trigger types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update sqlx

* fix: use permissioned_as directly for schedules and fix audit RLS for groups

- Schedule: permissioned_as only set on create, not on edit/set_enabled
- Schedule: stop reading email column, use get_email_from_permissioned_as
- Triggers: use fetch_api_authed_from_permissioned_as instead of edited_by
- Triggers: rename listener fields for clarity (username -> edited_by)
- Fix audit author username for group permissioned_as (g/test -> group-test)
  to match session.user, preventing RLS policy violations on audit_partitioned
- OpenAPI: remove permissioned_as/preserve_permissioned_as from EditSchedule
- Add backwards-compat comments for schedule email writes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts for permissioned_as field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix build

* refactor: generalize onBehalfOf naming, add permissioned_as to EditSchedule

- Frontend: rename onBehalfOfPermissionedAs -> onBehalfOf with comments
  explaining it carries emails for flows/scripts and permissioned_as for
  triggers/schedules
- Frontend: rename getOnBehalfOfEmail -> getOnBehalfOf,
  getOnBehalfOfPermissionedAsForDeploy -> getOnBehalfOfForDeploy,
  customOnBehalfOfEmails -> customOnBehalfOf
- Backend: add optional permissioned_as/preserve_permissioned_as to
  EditSchedule with COALESCE (only updates when provided)
- Backend: add on_behalf_of audit log for schedule edit
- Backend: remove unused resolve_on_behalf_of_permissioned_as
- Tests: remove email assertions from schedule update test (email is
  just backwards compat, only permissioned_as matters)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve email column when permissioned_as is preserved on schedule edit

Derive email from the preserved permissioned_as via cache lookup instead
of always writing authed.email. This keeps the email column consistent
with the old behavior for backwards compat with old workers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update deploy UI labels from "edited by" to "run as" for triggers

Triggers now use permissioned_as (not edited_by) for permissions, so
update the deploy UI wording to reflect this. Also update wm_deployers
group description to mention schedules and permissioned_as.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use u/username format for custom trigger/schedule deploy selection

When picking a custom user for trigger/schedule deployment, store
u/${username} (permissioned_as format) instead of the email. Flows/scripts
continue to use email format for on_behalf_of_email.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show u/username format for "me" option in trigger deploy selector

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify OnBehalfOfSelector to return the right format per kind

OnBehalfOfSelector now handles the email vs permissioned_as format
internally based on kind:
- triggers: returns u/username, displays u/username in all options
- flows/scripts/apps: returns email, displays username

The onSelect callback now takes (choice, value?) where value is already
in the correct format. Parent components just store it directly without
needing to know about the format difference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always show u/username format in OnBehalfOfSelector for all kinds

Display is now consistent: all kinds show u/username in the selector.
The returned value still differs (email for flows/scripts, u/username
for triggers) since the backend APIs expect different formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace email with permissioned_as in http_trigger test insert

The email column was dropped from trigger tables in the migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review fixes — migration, app policy, capture cleanup, naming

- Migration: remove DEFAULT '', use nullable → populate → SET NOT NULL
- App policy: set both on_behalf_of and on_behalf_of_email for all choices
- OnBehalfOfSelector: return OnBehalfOfDetails {email, permissionedAs} instead of ambiguous value
- Remove unused email field from Capture struct and query
- Rename getSourceEmail/getTargetEmail → getSourceOnBehalfOf/getTargetOnBehalfOf
- Rename test functions from preserve_email to preserve_permissioned_as

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add permissioned_as to all test schedule INSERTs

Since the migration no longer uses DEFAULT '', all INSERTs must
explicitly provide permissioned_as. Updated test fixtures and
schedule_push tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: strip permissioned_as from exports/sync, fix OpenAPI required field

- Add permissioned_as to workspace export strip list (like edited_by)
- Add permissioned_as to CLI TriggerFile Omit list
- Fix TriggerExtraProperty.required: email → permissioned_as
- Regenerate frontend and CLI types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove accidentally committed generated files

These directories are gitignored and should not be tracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts for permissioned_as schema changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove permissioned_as from CLI TriggerFile Omit list

Already stripped in workspace export, no need to also omit from the type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: optimize email cache key and revert TriggerFile Omit change

- Use single concatenated string for cache key instead of (String, String) tuple
- Remove permissioned_as from CLI TriggerFile Omit (already stripped in export)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: zero-allocation email cache lookups using Equivalent trait

Use a borrowed EmailCacheKey(&str, &str) for cache lookups via
quick_cache's Equivalent support. Only allocates (String, String)
on cache miss for insert. This is called on every trigger fire
and schedule push.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add permissioned_as to Schedule required fields in OpenAPI spec

The backend always returns permissioned_as (non-optional String),
so the schema should reflect that.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle group- prefix in migration UPDATE statements

edited_by can be 'group-{name}' for group-owned triggers/schedules.
The migration now correctly maps these to 'g/{name}' format instead
of incorrectly producing 'u/group-{name}'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Revert "fix: handle group- prefix in migration UPDATE statements"

This reverts commit 0971392b38.

* fix: use superadmin email to resolve permissioned_as in schedule migration

For users upgrading from older versions where edited_by may not reflect
the actual schedule owner, check if the email belongs to a superadmin
and look up their username. Otherwise fall back to edited_by.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fall back to superadmin email when not in workspace usr table

If the superadmin isn't a member of the workspace, use their email
as raw permissioned_as instead of falling back to edited_by.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always update permissioned_as and email on schedule edit

Consistent with pre-refactor behavior where email and edited_by
were always updated on every edit. permissioned_as is now always
set (to editing user or preserved value), removing the COALESCE
that previously preserved it when not provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add schedule permission tests and centralize group prefix constants

Tests: schedule create/update for normal user, workspace admin, and
superadmin not in workspace. Verifies schedule fields (email,
permissioned_as, edited_by) and pushed job fields (permissioned_as,
permissioned_as_email).

Constants: centralize "u/", "g/", "group-" as PERMISSIONED_AS_USER_PREFIX,
PERMISSIONED_AS_GROUP_PREFIX, USERNAME_GROUP_PREFIX.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use @unknown.windmill.dev for synthetic email fallback

Prevents privilege escalation: a user with username like
'superadmin_secret' would get superadmin via the synthetic
email matching SUPERADMIN_SECRET_EMAIL. Using a different
subdomain avoids any collision with hardcoded @windmill.dev emails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update ee ref

* sqlx

* chore: regenerate system prompts after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to bda51bc33bcb573659e7ff07d0a23ff6e23b8148

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

Previous ee-repo-ref: 8cf1802f8fe183f430830590b4f3172a50207843

New ee-repo-ref: bda51bc33bcb573659e7ff07d0a23ff6e23b8148

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-20 16:28:38 +00:00

1745 lines
49 KiB
Rust

/*!
* Integration tests for the trigger system (captures, HTTP triggers, trigger configs).
*
* These tests verify:
* 1. Capture config CRUD (create/ping/list/delete via API)
* 2. Capture payload insertion and retrieval
* 3. HTTP trigger CRUD and route matching
* 4. All trigger types DB schema validation
*/
use serde::Deserialize;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
// ============================================================================
// Capture Config Tests (direct DB)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_config_insert_and_query(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO capture_config (workspace_id, path, is_flow, trigger_kind, owner, email)
VALUES ($1, $2, $3, $4::trigger_kind, $5, $6)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
"test-user",
"test@windmill.dev",
)
.execute(&db)
.await?;
let config = sqlx::query!(
r#"
SELECT path, owner, email, trigger_kind AS "trigger_kind: String"
FROM capture_config
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/script",
)
.fetch_one(&db)
.await?;
assert_eq!(config.path, "f/test/script");
assert_eq!(config.owner, "test-user");
assert_eq!(config.email, "test@windmill.dev");
assert_eq!(config.trigger_kind, "webhook");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_config_upsert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO capture_config (workspace_id, path, is_flow, trigger_kind, owner, email)
VALUES ($1, $2, $3, $4::trigger_kind, $5, $6)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
"test-user",
"test@windmill.dev",
)
.execute(&db)
.await?;
sqlx::query!(
r#"
INSERT INTO capture_config (workspace_id, path, is_flow, trigger_kind, owner, email)
VALUES ($1, $2, $3, $4::trigger_kind, $5, $6)
ON CONFLICT (workspace_id, path, is_flow, trigger_kind)
DO UPDATE SET owner = $5, email = $6, server_id = NULL, error = NULL
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
"new-owner",
"new@windmill.dev",
)
.execute(&db)
.await?;
let config = sqlx::query!(
"SELECT owner, email FROM capture_config WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/script",
)
.fetch_one(&db)
.await?;
assert_eq!(config.owner, "new-owner");
assert_eq!(config.email, "new@windmill.dev");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_config_ping_updates_timestamp(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO capture_config (workspace_id, path, is_flow, trigger_kind, owner, email)
VALUES ($1, $2, $3, $4::trigger_kind, $5, $6)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
"test-user",
"test@windmill.dev",
)
.execute(&db)
.await?;
let before = sqlx::query!(
"SELECT last_client_ping FROM capture_config WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/script",
)
.fetch_one(&db)
.await?;
assert!(before.last_client_ping.is_none());
sqlx::query!(
r#"
UPDATE capture_config SET last_client_ping = NOW()
WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = $4::trigger_kind
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
)
.execute(&db)
.await?;
let after = sqlx::query!(
"SELECT last_client_ping FROM capture_config WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/script",
)
.fetch_one(&db)
.await?;
assert!(after.last_client_ping.is_some());
Ok(())
}
// ============================================================================
// Capture Payload Tests (direct DB)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_insert_and_list(db: Pool<Postgres>) -> anyhow::Result<()> {
for i in 0..2 {
sqlx::query!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"key": format!("value{}", i)}),
json!({"pre": format!("args{}", i)}),
"test-user",
)
.execute(&db)
.await?;
}
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM capture WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/script",
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(2));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
RETURNING id
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"key": "value"}),
json!({"pre": "args"}),
"test-user",
)
.fetch_one(&db)
.await?;
sqlx::query!("DELETE FROM capture WHERE id = $1", id)
.execute(&db)
.await?;
let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_filter_by_trigger_kind(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"source": "webhook"}),
json!({}),
"test-user",
)
.execute(&db)
.await?;
sqlx::query!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
"#,
"test-workspace",
"f/test/script",
false,
"email" as _,
json!({"source": "email"}),
json!({}),
"test-user",
)
.execute(&db)
.await?;
let webhook_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) FROM capture
WHERE workspace_id = $1 AND path = $2 AND trigger_kind = $3::trigger_kind
"#,
"test-workspace",
"f/test/script",
"webhook" as _,
)
.fetch_one(&db)
.await?;
assert_eq!(webhook_count, Some(1));
let email_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) FROM capture
WHERE workspace_id = $1 AND path = $2 AND trigger_kind = $3::trigger_kind
"#,
"test-workspace",
"f/test/script",
"email" as _,
)
.fetch_one(&db)
.await?;
assert_eq!(email_count, Some(1));
Ok(())
}
// ============================================================================
// Capture API Tests (via HTTP)
// ============================================================================
#[derive(Debug, Deserialize)]
struct CaptureResponse {
id: i64,
#[allow(dead_code)]
trigger_kind: String,
main_args: serde_json::Value,
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_api_set_config_and_list(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let response = client
.client()
.post(format!(
"{}/w/test-workspace/capture/set_config",
client.baseurl()
))
.json(&json!({
"path": "f/test/my_script",
"is_flow": false,
"trigger_kind": "webhook",
}))
.send()
.await?;
assert!(
response.status().is_success(),
"set_config should succeed, got: {}",
response.status()
);
let config = sqlx::query!(
"SELECT owner FROM capture_config WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/my_script",
)
.fetch_one(&db)
.await?;
assert_eq!(config.owner, "test-user");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_api_list_captures(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
for i in 0..3 {
sqlx::query!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"index": i}),
json!({}),
"test-user",
)
.execute(&db)
.await?;
}
let response = client
.client()
.get(format!(
"{}/w/test-workspace/capture/list/script/f/test/script",
client.baseurl()
))
.send()
.await?;
assert!(
response.status().is_success(),
"list captures should succeed"
);
let captures: Vec<CaptureResponse> = response.json().await?;
assert_eq!(captures.len(), 3);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_api_get_single(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let id = sqlx::query_scalar!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
RETURNING id
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"hello": "world"}),
json!({}),
"test-user",
)
.fetch_one(&db)
.await?;
let response = client
.client()
.get(format!(
"{}/w/test-workspace/capture/{}",
client.baseurl(),
id
))
.send()
.await?;
assert!(response.status().is_success(), "get capture should succeed");
let capture: CaptureResponse = response.json().await?;
assert_eq!(capture.id, id);
assert_eq!(capture.main_args["hello"], "world");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_api_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let id = sqlx::query_scalar!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
RETURNING id
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"data": "to_delete"}),
json!({}),
"test-user",
)
.fetch_one(&db)
.await?;
let response = client
.client()
.delete(format!(
"{}/w/test-workspace/capture/{}",
client.baseurl(),
id
))
.send()
.await?;
assert!(response.status().is_success(), "delete should succeed");
let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_api_pagination(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
for i in 0..5 {
sqlx::query!(
r#"
INSERT INTO capture (workspace_id, path, is_flow, trigger_kind, main_args, preprocessor_args, created_by)
VALUES ($1, $2, $3, $4::trigger_kind, $5::jsonb, $6::jsonb, $7)
"#,
"test-workspace",
"f/test/script",
false,
"webhook" as _,
json!({"index": i}),
json!({}),
"test-user",
)
.execute(&db)
.await?;
}
let response = client
.client()
.get(format!(
"{}/w/test-workspace/capture/list/script/f/test/script?per_page=2",
client.baseurl()
))
.send()
.await?;
assert!(response.status().is_success());
let limited: Vec<CaptureResponse> = response.json().await?;
assert_eq!(limited.len(), 2, "per_page=2 should limit to 2 results");
let response = client
.client()
.get(format!(
"{}/w/test-workspace/capture/list/script/f/test/script",
client.baseurl()
))
.send()
.await?;
assert!(response.status().is_success());
let all: Vec<CaptureResponse> = response.json().await?;
assert_eq!(all.len(), 5, "without limit should return all 5");
Ok(())
}
// ============================================================================
// HTTP Trigger Tests (direct DB)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_http_trigger_insert_and_query(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow,
workspace_id, edited_by, permissioned_as, http_method,
authentication_method, is_static_website, workspaced_route,
wrap_body, raw_string
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::http_method,
$10::authentication_method, $11, $12, $13, $14)
"#,
"f/test/http_trigger",
"api/v1/users/:id",
"api/v1/users",
"f/test/handler_script",
false,
"test-workspace",
"test-user",
"u/test-user",
"post" as _,
"none" as _,
false,
false,
false,
false,
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT
path, route_path, script_path,
http_method AS "http_method: String",
authentication_method AS "authentication_method: String"
FROM http_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/http_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.path, "f/test/http_trigger");
assert_eq!(trigger.route_path, "api/v1/users/:id");
assert_eq!(trigger.script_path, "f/test/handler_script");
assert_eq!(trigger.http_method, "post");
assert_eq!(trigger.authentication_method, "none");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_http_trigger_multiple_methods(db: Pool<Postgres>) -> anyhow::Result<()> {
let methods = ["get", "post", "put", "delete", "patch"];
for method in &methods {
sqlx::query!(
r#"
INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow,
workspace_id, edited_by, permissioned_as, http_method,
authentication_method, is_static_website, workspaced_route,
wrap_body, raw_string
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::http_method,
$10::authentication_method, $11, $12, $13, $14)
"#,
format!("f/test/trigger_{}", method),
format!("api/{}", method),
format!("api/{}", method),
"f/test/handler",
false,
"test-workspace",
"test-user",
"u/test-user",
*method as _,
"none" as _,
false,
false,
false,
false,
)
.execute(&db)
.await?;
}
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM http_trigger WHERE workspace_id = $1",
"test-workspace",
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(methods.len() as i64));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_http_trigger_authentication_methods(db: Pool<Postgres>) -> anyhow::Result<()> {
let auth_methods = ["none", "windmill", "api_key", "basic_http", "signature"];
for (i, auth) in auth_methods.iter().enumerate() {
sqlx::query!(
r#"
INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow,
workspace_id, edited_by, permissioned_as, http_method,
authentication_method, is_static_website, workspaced_route,
wrap_body, raw_string
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::http_method,
$10::authentication_method, $11, $12, $13, $14)
"#,
format!("f/test/trigger_{}", i),
format!("api/{}", i),
format!("api/{}", i),
"f/test/handler",
false,
"test-workspace",
"test-user",
"u/test-user",
"get" as _,
*auth as _,
false,
false,
false,
auth == &"signature",
)
.execute(&db)
.await?;
}
let sig_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) FROM http_trigger
WHERE workspace_id = $1 AND authentication_method = $2::authentication_method
"#,
"test-workspace",
"signature" as _,
)
.fetch_one(&db)
.await?;
assert_eq!(sig_count, Some(1));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_http_trigger_update(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow,
workspace_id, edited_by, permissioned_as, http_method,
authentication_method, is_static_website, workspaced_route,
wrap_body, raw_string
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::http_method,
$10::authentication_method, $11, $12, $13, $14)
"#,
"f/test/trigger",
"api/v1/old",
"api/v1/old",
"f/test/old_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
"get" as _,
"none" as _,
false,
false,
false,
false,
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE http_trigger SET script_path = $1 WHERE workspace_id = $2 AND path = $3",
"f/test/new_handler",
"test-workspace",
"f/test/trigger",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
"SELECT script_path FROM http_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.script_path, "f/test/new_handler");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_http_trigger_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow,
workspace_id, edited_by, permissioned_as, http_method,
authentication_method, is_static_website, workspaced_route,
wrap_body, raw_string
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::http_method,
$10::authentication_method, $11, $12, $13, $14)
"#,
"f/test/to_delete",
"api/delete_me",
"api/delete_me",
"f/test/handler",
false,
"test-workspace",
"test-user",
"u/test-user",
"get" as _,
"none" as _,
false,
false,
false,
false,
)
.execute(&db)
.await?;
sqlx::query!(
"DELETE FROM http_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/to_delete",
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM http_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/to_delete",
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_http_trigger_mode_filtering(db: Pool<Postgres>) -> anyhow::Result<()> {
let modes = ["enabled", "disabled", "suspended"];
for (i, mode) in modes.iter().enumerate() {
sqlx::query!(
r#"
INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow,
workspace_id, edited_by, permissioned_as, http_method,
authentication_method, is_static_website, workspaced_route,
wrap_body, raw_string, mode
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::http_method,
$10::authentication_method, $11, $12, $13, $14, $15::trigger_mode)
"#,
format!("f/test/trigger_{}", i),
format!("api/{}", i),
format!("api/{}", i),
"f/test/handler",
false,
"test-workspace",
"test-user",
"u/test-user",
"get" as _,
"none" as _,
false,
false,
false,
false,
*mode as _,
)
.execute(&db)
.await?;
}
// Query for active triggers (enabled or suspended, matching refresh_routers logic)
let active_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) FROM http_trigger
WHERE workspace_id = $1
AND (mode = 'enabled'::trigger_mode OR mode = 'suspended'::trigger_mode)
"#,
"test-workspace",
)
.fetch_one(&db)
.await?;
assert_eq!(active_count, Some(2));
Ok(())
}
// ============================================================================
// Other Trigger Types Tests (DB schema validation)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_websocket_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO websocket_trigger (
path, url, script_path, is_flow, workspace_id,
edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
"f/test/ws_trigger",
"wss://example.com/feed",
"f/test/ws_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT url, script_path, mode AS "mode: String"
FROM websocket_trigger WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/ws_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.url, "wss://example.com/feed");
assert_eq!(trigger.script_path, "f/test/ws_handler");
assert_eq!(trigger.mode, "enabled");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_kafka_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO kafka_trigger (
path, kafka_resource_path, topics, group_id, script_path,
is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
"f/test/kafka_trigger",
"u/admin/kafka_resource",
&["topic-a", "topic-b"] as &[&str],
"my-consumer-group",
"f/test/kafka_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT kafka_resource_path, topics, group_id, mode AS "mode: String",
auto_offset_reset, auto_commit, reset_offset
FROM kafka_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/kafka_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.kafka_resource_path, "u/admin/kafka_resource");
assert_eq!(trigger.topics, vec!["topic-a", "topic-b"]);
assert_eq!(trigger.group_id, "my-consumer-group");
assert_eq!(trigger.mode, "enabled");
assert_eq!(trigger.auto_offset_reset, "latest");
assert_eq!(trigger.auto_commit, true);
assert_eq!(trigger.reset_offset, false);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_kafka_trigger_insert_auto_commit_disabled(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO kafka_trigger (
path, kafka_resource_path, topics, group_id, script_path,
is_flow, workspace_id, edited_by, permissioned_as, auto_commit
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
"f/test/kafka_trigger_no_commit",
"u/admin/kafka_resource",
&["topic-c"] as &[&str],
"my-consumer-group-2",
"f/test/kafka_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
false,
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT auto_commit
FROM kafka_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/kafka_trigger_no_commit",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.auto_commit, false);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_postgres_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO postgres_trigger (
path, script_path, is_flow, workspace_id, edited_by, permissioned_as,
postgres_resource_path, replication_slot_name, publication_name
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
"f/test/pg_trigger",
"f/test/pg_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
"u/admin/pg_resource",
"test_slot",
"test_publication",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT postgres_resource_path, replication_slot_name, publication_name, mode AS "mode: String"
FROM postgres_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/pg_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.postgres_resource_path, "u/admin/pg_resource");
assert_eq!(trigger.replication_slot_name, "test_slot");
assert_eq!(trigger.publication_name, "test_publication");
assert_eq!(trigger.mode, "enabled");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_nats_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO nats_trigger (
path, nats_resource_path, subjects, script_path,
is_flow, workspace_id, edited_by, permissioned_as, use_jetstream
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
"f/test/nats_trigger",
"u/admin/nats_resource",
&["orders.>", "payments.*"] as &[&str],
"f/test/nats_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
false,
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT nats_resource_path, subjects, use_jetstream, mode AS "mode: String"
FROM nats_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/nats_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.nats_resource_path, "u/admin/nats_resource");
assert_eq!(trigger.subjects, vec!["orders.>", "payments.*"]);
assert_eq!(trigger.use_jetstream, false);
assert_eq!(trigger.mode, "enabled");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_sqs_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO sqs_trigger (
path, queue_url, aws_resource_path, script_path,
is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
"f/test/sqs_trigger",
"https://sqs.us-east-1.amazonaws.com/123456789/my-queue",
"u/admin/aws_resource",
"f/test/sqs_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT queue_url, aws_resource_path,
aws_auth_resource_type AS "aws_auth_resource_type: String",
mode AS "mode: String"
FROM sqs_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/sqs_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(
trigger.queue_url,
"https://sqs.us-east-1.amazonaws.com/123456789/my-queue"
);
assert_eq!(trigger.aws_resource_path, "u/admin/aws_resource");
assert_eq!(trigger.aws_auth_resource_type, "credentials");
assert_eq!(trigger.mode, "enabled");
Ok(())
}
// ============================================================================
// Cross-trigger tests
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_trigger_server_state_tracking(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO websocket_trigger (
path, url, script_path, is_flow, workspace_id,
edited_by, permissioned_as, server_id, error
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
"f/test/ws_with_error",
"wss://example.com/feed",
"f/test/handler",
false,
"test-workspace",
"test-user",
"u/test-user",
"server-abc-123",
"connection refused",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
"SELECT server_id, error FROM websocket_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/ws_with_error",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.server_id, Some("server-abc-123".to_string()));
assert_eq!(trigger.error, Some("connection refused".to_string()));
sqlx::query!(
"UPDATE websocket_trigger SET error = NULL WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/ws_with_error",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
"SELECT error FROM websocket_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/ws_with_error",
)
.fetch_one(&db)
.await?;
assert!(trigger.error.is_none());
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_trigger_mode_filtering(db: Pool<Postgres>) -> anyhow::Result<()> {
let modes = ["enabled", "disabled", "enabled"];
for (i, mode) in modes.iter().enumerate() {
sqlx::query!(
r#"
INSERT INTO websocket_trigger (
path, url, script_path, is_flow, workspace_id,
edited_by, permissioned_as, mode
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::trigger_mode)
"#,
format!("f/test/ws_trigger_{}", i),
"wss://example.com",
"f/test/handler",
false,
"test-workspace",
"test-user",
"u/test-user",
*mode as _,
)
.execute(&db)
.await?;
}
let enabled_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM websocket_trigger WHERE workspace_id = $1 AND mode = 'enabled'::trigger_mode",
"test-workspace",
)
.fetch_one(&db)
.await?;
assert_eq!(enabled_count, Some(2));
let disabled_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM websocket_trigger WHERE workspace_id = $1 AND mode = 'disabled'::trigger_mode",
"test-workspace",
)
.fetch_one(&db)
.await?;
assert_eq!(disabled_count, Some(1));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_multiple_capture_configs_per_path(db: Pool<Postgres>) -> anyhow::Result<()> {
let trigger_kinds = ["webhook", "email", "kafka"];
for kind in &trigger_kinds {
sqlx::query!(
r#"
INSERT INTO capture_config (workspace_id, path, is_flow, trigger_kind, owner, email)
VALUES ($1, $2, $3, $4::trigger_kind, $5, $6)
"#,
"test-workspace",
"f/test/multi_trigger_script",
false,
*kind as _,
"test-user",
"test@windmill.dev",
)
.execute(&db)
.await?;
}
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM capture_config WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/multi_trigger_script",
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(3));
Ok(())
}
// ============================================================================
// Schedule Tests (DB-level)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_schedule_insert_and_query(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO schedule (
workspace_id, path, edited_by, schedule, enabled,
script_path, is_flow, email, timezone, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
"test-workspace",
"f/test/my_schedule",
"test-user",
"0 */5 * * *",
true,
"f/test/scheduled_script",
false,
"test@windmill.dev",
"UTC",
"u/test-user",
)
.execute(&db)
.await?;
let schedule = sqlx::query!(
r#"
SELECT path, schedule, enabled, script_path, timezone
FROM schedule
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/my_schedule",
)
.fetch_one(&db)
.await?;
assert_eq!(schedule.path, "f/test/my_schedule");
assert_eq!(schedule.schedule, "0 */5 * * *");
assert_eq!(schedule.enabled, true);
assert_eq!(schedule.script_path, "f/test/scheduled_script");
assert_eq!(schedule.timezone, "UTC");
Ok(())
}
// ============================================================================
// MQTT Trigger Tests (DB-level)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mqtt_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO mqtt_trigger (
path, mqtt_resource_path, subscribe_topics, client_version,
script_path, is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, ARRAY[$3::jsonb], $4::mqtt_client_version, $5, $6, $7, $8, $9)
"#,
)
.bind("f/test/mqtt_trigger")
.bind("u/admin/mqtt_resource")
.bind(json!({"topic": "test/+", "qos": "qos1"}))
.bind("v5")
.bind("f/test/mqtt_handler")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT mqtt_resource_path, client_version AS "client_version: String",
script_path, mode AS "mode: String"
FROM mqtt_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/mqtt_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.mqtt_resource_path, "u/admin/mqtt_resource");
assert_eq!(trigger.client_version, "v5");
assert_eq!(trigger.script_path, "f/test/mqtt_handler");
assert_eq!(trigger.mode, "enabled");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mqtt_trigger_update(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO mqtt_trigger (
path, mqtt_resource_path, subscribe_topics, client_version,
script_path, is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, ARRAY[$3::jsonb], $4::mqtt_client_version, $5, $6, $7, $8, $9)
"#,
)
.bind("f/test/mqtt_trigger")
.bind("u/admin/mqtt_resource")
.bind(json!({"topic": "test/+", "qos": "qos1"}))
.bind("v5")
.bind("f/test/old_handler")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
sqlx::query!(
"UPDATE mqtt_trigger SET script_path = $1 WHERE workspace_id = $2 AND path = $3",
"f/test/new_handler",
"test-workspace",
"f/test/mqtt_trigger",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
"SELECT script_path FROM mqtt_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/mqtt_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.script_path, "f/test/new_handler");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mqtt_trigger_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO mqtt_trigger (
path, mqtt_resource_path, subscribe_topics, client_version,
script_path, is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, ARRAY[$3::jsonb], $4::mqtt_client_version, $5, $6, $7, $8, $9)
"#,
)
.bind("f/test/mqtt_trigger")
.bind("u/admin/mqtt_resource")
.bind(json!({"topic": "test/+", "qos": "qos1"}))
.bind("v5")
.bind("f/test/mqtt_handler")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
sqlx::query!(
"DELETE FROM mqtt_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/mqtt_trigger",
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM mqtt_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/mqtt_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}
// ============================================================================
// GCP Trigger Tests (DB-level)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_gcp_trigger_insert_pull(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO gcp_trigger (
path, gcp_resource_path, topic_id, subscription_id,
delivery_type, subscription_mode, script_path, is_flow,
workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5::delivery_mode, $6::gcp_subscription_mode, $7, $8, $9, $10, $11)
"#,
)
.bind("f/test/gcp_trigger_pull")
.bind("u/admin/gcp_resource")
.bind("my-topic")
.bind("my-subscription")
.bind("pull")
.bind("create_update")
.bind("f/test/gcp_handler")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT gcp_resource_path, topic_id, subscription_id,
delivery_type AS "delivery_type: String",
subscription_mode AS "subscription_mode: String",
mode AS "mode: String"
FROM gcp_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/gcp_trigger_pull",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.gcp_resource_path, "u/admin/gcp_resource");
assert_eq!(trigger.topic_id, "my-topic");
assert_eq!(trigger.subscription_id, "my-subscription");
assert_eq!(trigger.delivery_type, "pull");
assert_eq!(trigger.subscription_mode, "create_update");
assert_eq!(trigger.mode, "enabled");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_gcp_trigger_insert_push(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO gcp_trigger (
path, gcp_resource_path, topic_id, subscription_id,
delivery_type, delivery_config, subscription_mode,
script_path, is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5::delivery_mode, $6::jsonb, $7::gcp_subscription_mode, $8, $9, $10, $11, $12)
"#,
)
.bind("f/test/gcp_trigger_push")
.bind("u/admin/gcp_resource")
.bind("my-topic")
.bind("my-push-subscription")
.bind("push")
.bind(json!({"endpoint": "https://example.com/push"}))
.bind("create_update")
.bind("f/test/gcp_handler")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT delivery_type AS "delivery_type: String",
delivery_config
FROM gcp_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/gcp_trigger_push",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.delivery_type, "push");
assert!(trigger.delivery_config.is_some());
assert_eq!(
trigger.delivery_config.unwrap()["endpoint"],
"https://example.com/push"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_gcp_trigger_unique_constraint(db: Pool<Postgres>) -> anyhow::Result<()> {
let insert_query = r#"
INSERT INTO gcp_trigger (
path, gcp_resource_path, topic_id, subscription_id,
delivery_type, subscription_mode, script_path, is_flow,
workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5::delivery_mode, $6::gcp_subscription_mode, $7, $8, $9, $10, $11)
"#;
sqlx::query(insert_query)
.bind("f/test/gcp_trigger_1")
.bind("u/admin/gcp_resource")
.bind("my-topic")
.bind("shared-subscription")
.bind("pull")
.bind("create_update")
.bind("f/test/gcp_handler")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
// Inserting a second trigger with same (subscription_id, gcp_resource_path, workspace_id) should fail
let result = sqlx::query(insert_query)
.bind("f/test/gcp_trigger_2")
.bind("u/admin/gcp_resource")
.bind("my-topic")
.bind("shared-subscription")
.bind("pull")
.bind("create_update")
.bind("f/test/gcp_handler_2")
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await;
assert!(
result.is_err(),
"should fail due to unique constraint on (subscription_id, gcp_resource_path, workspace_id)"
);
Ok(())
}
// ============================================================================
// Email Trigger Tests (DB-level)
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_email_trigger_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO email_trigger (
path, local_part, workspaced_local_part, script_path,
is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
"f/test/email_trigger",
"support",
true,
"f/test/email_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
r#"
SELECT local_part, workspaced_local_part, script_path,
mode AS "mode: String"
FROM email_trigger
WHERE workspace_id = $1 AND path = $2
"#,
"test-workspace",
"f/test/email_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.local_part, "support");
assert_eq!(trigger.workspaced_local_part, true);
assert_eq!(trigger.script_path, "f/test/email_handler");
assert_eq!(trigger.mode, "enabled");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_email_trigger_update(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO email_trigger (
path, local_part, workspaced_local_part, script_path,
is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
"f/test/email_trigger",
"support",
true,
"f/test/old_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE email_trigger SET script_path = $1, local_part = $2 WHERE workspace_id = $3 AND path = $4",
"f/test/new_handler",
"billing",
"test-workspace",
"f/test/email_trigger",
)
.execute(&db)
.await?;
let trigger = sqlx::query!(
"SELECT script_path, local_part FROM email_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/email_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(trigger.script_path, "f/test/new_handler");
assert_eq!(trigger.local_part, "billing");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_email_trigger_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query!(
r#"
INSERT INTO email_trigger (
path, local_part, workspaced_local_part, script_path,
is_flow, workspace_id, edited_by, permissioned_as
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
"f/test/email_trigger",
"support",
true,
"f/test/email_handler",
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
sqlx::query!(
"DELETE FROM email_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/email_trigger",
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM email_trigger WHERE workspace_id = $1 AND path = $2",
"test-workspace",
"f/test/email_trigger",
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}