* 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>
999 lines
34 KiB
Rust
999 lines
34 KiB
Rust
/*!
|
|
* Tests for the polling-based notify_event system that replaces PostgreSQL LISTEN/NOTIFY.
|
|
*
|
|
* These tests verify:
|
|
* 1. Database triggers correctly insert events into notify_event table
|
|
* 2. Polling functions retrieve events correctly
|
|
* 3. Cleanup functions delete old events
|
|
* 4. All notification channels work as expected
|
|
*/
|
|
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_common::notify_events::{cleanup_old_events, get_latest_event_id, poll_notify_events};
|
|
|
|
/// Helper to insert a test event directly
|
|
async fn insert_test_event(db: &Pool<Postgres>, channel: &str, payload: &str) -> i64 {
|
|
sqlx::query_scalar::<_, i64>(
|
|
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
|
)
|
|
.bind(channel)
|
|
.bind(payload)
|
|
.fetch_one(db)
|
|
.await
|
|
.expect("Failed to insert test event")
|
|
}
|
|
|
|
/// Helper to count events for a channel
|
|
async fn count_events_for_channel(db: &Pool<Postgres>, channel: &str) -> i64 {
|
|
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_event WHERE channel = $1")
|
|
.bind(channel)
|
|
.fetch_one(db)
|
|
.await
|
|
.expect("Failed to count events");
|
|
result.0
|
|
}
|
|
|
|
// ============================================================================
|
|
// Basic Functionality Tests
|
|
// ============================================================================
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_get_latest_event_id_returns_valid_id(db: Pool<Postgres>) {
|
|
// Get current latest id
|
|
let latest_id = get_latest_event_id(&db)
|
|
.await
|
|
.expect("Should get latest event id");
|
|
assert!(latest_id >= 0, "Latest id should be non-negative");
|
|
|
|
// Insert a new event and verify latest_id increases
|
|
let new_id = insert_test_event(&db, "test_latest_id", "payload").await;
|
|
let new_latest_id = get_latest_event_id(&db)
|
|
.await
|
|
.expect("Should get latest event id");
|
|
assert!(
|
|
new_latest_id >= new_id,
|
|
"Latest id should be >= new event id"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_get_latest_event_id_with_events(db: Pool<Postgres>) {
|
|
let _id1 = insert_test_event(&db, "test_channel_1", "payload1").await;
|
|
let _id2 = insert_test_event(&db, "test_channel_2", "payload2").await;
|
|
let id3 = insert_test_event(&db, "test_channel_3", "payload3").await;
|
|
|
|
let latest_id = get_latest_event_id(&db)
|
|
.await
|
|
.expect("Should get latest event id");
|
|
assert!(latest_id >= id3, "Latest id should be >= last inserted id");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_poll_notify_events_no_new_events(db: Pool<Postgres>) {
|
|
// Get latest id first
|
|
let latest_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Poll from the latest id - should return empty since no new events
|
|
let events = poll_notify_events(&db, latest_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
assert!(
|
|
events.is_empty(),
|
|
"Should return empty vec when polling from latest id"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_poll_notify_events_returns_new_events(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
let _id1 = insert_test_event(&db, "test_poll_channel", "payload1").await;
|
|
let _id2 = insert_test_event(&db, "test_poll_channel", "payload2").await;
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
assert!(events.len() >= 2, "Should return at least 2 new events");
|
|
|
|
// Verify the events we inserted are present
|
|
let our_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "test_poll_channel")
|
|
.collect();
|
|
assert_eq!(our_events.len(), 2, "Should have exactly our 2 test events");
|
|
|
|
// Verify ordering (ascending by id)
|
|
assert!(
|
|
our_events[0].id < our_events[1].id,
|
|
"Events should be ordered by id ascending"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_poll_notify_events_respects_last_event_id(db: Pool<Postgres>) {
|
|
let id1 = insert_test_event(&db, "test_respect_id", "payload1").await;
|
|
let _id2 = insert_test_event(&db, "test_respect_id", "payload2").await;
|
|
let _id3 = insert_test_event(&db, "test_respect_id", "payload3").await;
|
|
|
|
// Poll from id1 should only return id2 and id3
|
|
let events = poll_notify_events(&db, id1)
|
|
.await
|
|
.expect("Should poll events");
|
|
let our_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "test_respect_id")
|
|
.collect();
|
|
|
|
assert_eq!(our_events.len(), 2, "Should only return events after id1");
|
|
assert!(
|
|
our_events.iter().all(|e| e.id > id1),
|
|
"All events should have id > id1"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_cleanup_old_events(db: Pool<Postgres>) {
|
|
// Use unique channel names to avoid interference from other tests
|
|
let old_channel = format!("test_cleanup_old_{}", uuid::Uuid::new_v4());
|
|
let recent_channel = format!("test_cleanup_recent_{}", uuid::Uuid::new_v4());
|
|
|
|
// Insert an event with old timestamp
|
|
sqlx::query(
|
|
"INSERT INTO notify_event (channel, payload, created_at) VALUES ($1, $2, now() - interval '15 minutes')",
|
|
)
|
|
.bind(&old_channel)
|
|
.bind("old_payload")
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert old event");
|
|
|
|
// Insert a recent event
|
|
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
|
|
.bind(&recent_channel)
|
|
.bind("recent_payload")
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert recent event");
|
|
|
|
// Count before cleanup
|
|
let old_count_before = count_events_for_channel(&db, &old_channel).await;
|
|
assert_eq!(
|
|
old_count_before, 1,
|
|
"Should have 1 old event before cleanup"
|
|
);
|
|
|
|
// Cleanup events older than 10 minutes
|
|
let deleted = cleanup_old_events(&db, 10)
|
|
.await
|
|
.expect("Should cleanup events");
|
|
assert!(deleted >= 1, "Should delete at least 1 old event");
|
|
|
|
// Verify old event is gone
|
|
let old_count = count_events_for_channel(&db, &old_channel).await;
|
|
assert_eq!(old_count, 0, "Old event should be deleted");
|
|
|
|
// Verify recent event is still there
|
|
let recent_count = count_events_for_channel(&db, &recent_channel).await;
|
|
assert_eq!(recent_count, 1, "Recent event should still exist");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Database Trigger Tests - Verify triggers insert events correctly
|
|
// ============================================================================
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_config_change(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Insert or update a config entry
|
|
sqlx::query(
|
|
"INSERT INTO config (name, config) VALUES ('test_config_trigger', '{}'::jsonb)
|
|
ON CONFLICT (name) DO UPDATE SET config = '{}'::jsonb",
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert config");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let config_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_config_change" && e.payload == "test_config_trigger")
|
|
.collect();
|
|
|
|
assert!(
|
|
!config_events.is_empty(),
|
|
"Should have notify_config_change event"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_global_setting_change_insert(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Use a unique setting name for testing
|
|
let setting_name = format!("test_setting_{}", uuid::Uuid::new_v4());
|
|
|
|
// Insert a global setting
|
|
sqlx::query("INSERT INTO global_settings (name, value) VALUES ($1, '{}'::jsonb)")
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert global setting");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let setting_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name)
|
|
.collect();
|
|
|
|
assert!(
|
|
!setting_events.is_empty(),
|
|
"Should have notify_global_setting_change event on insert"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_global_setting_change_update(db: Pool<Postgres>) {
|
|
// Use a unique setting name for testing
|
|
let setting_name = format!("test_setting_update_{}", uuid::Uuid::new_v4());
|
|
|
|
// First insert
|
|
sqlx::query("INSERT INTO global_settings (name, value) VALUES ($1, '{}'::jsonb)")
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert global setting");
|
|
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Update the setting
|
|
sqlx::query("UPDATE global_settings SET value = '{\"updated\": true}'::jsonb WHERE name = $1")
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to update global setting");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let setting_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name)
|
|
.collect();
|
|
|
|
assert!(
|
|
!setting_events.is_empty(),
|
|
"Should have notify_global_setting_change event on update"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_global_setting_change_delete(db: Pool<Postgres>) {
|
|
// Use a unique setting name for testing
|
|
let setting_name = format!("test_setting_delete_{}", uuid::Uuid::new_v4());
|
|
|
|
// First insert
|
|
sqlx::query("INSERT INTO global_settings (name, value) VALUES ($1, '{}'::jsonb)")
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert global setting");
|
|
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Delete the setting
|
|
sqlx::query("DELETE FROM global_settings WHERE name = $1")
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to delete global setting");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let setting_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name)
|
|
.collect();
|
|
|
|
assert!(
|
|
!setting_events.is_empty(),
|
|
"Should have notify_global_setting_change event on delete"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_workspace_envs_change(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Insert a workspace env (test-workspace exists from fixture)
|
|
sqlx::query(
|
|
"INSERT INTO workspace_env (workspace_id, name, value) VALUES ('test-workspace', 'TEST_ENV_VAR', 'test_value')
|
|
ON CONFLICT (workspace_id, name) DO UPDATE SET value = 'test_value_updated'",
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert workspace env");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let env_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_workspace_envs_change" && e.payload == "test-workspace")
|
|
.collect();
|
|
|
|
assert!(
|
|
!env_events.is_empty(),
|
|
"Should have notify_workspace_envs_change event"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_workspace_key_change(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Insert a workspace key (base fixture already has one, so this will conflict and update)
|
|
sqlx::query(
|
|
"INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('test-workspace', 'cloud', 'test_key_value')
|
|
ON CONFLICT (workspace_id, kind) DO UPDATE SET key = 'test_key_value_updated'",
|
|
)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert workspace key");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let key_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_workspace_key_change" && e.payload == "test-workspace")
|
|
.collect();
|
|
|
|
assert!(
|
|
!key_events.is_empty(),
|
|
"Should have notify_workspace_key_change event"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_token_invalidation(db: Pool<Postgres>) {
|
|
// First insert a session token with token_hash and token_prefix
|
|
let token = format!("test_token_{}", uuid::Uuid::new_v4());
|
|
let token_hash = windmill_common::utils::calculate_hash(&token);
|
|
let token_prefix = &token[..10];
|
|
sqlx::query(
|
|
"INSERT INTO token (token_hash, token_prefix, label, email, workspace_id, owner, expiration)
|
|
VALUES ($1, $2, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')",
|
|
)
|
|
.bind(&token_hash)
|
|
.bind(token_prefix)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert token");
|
|
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Delete the token (should trigger notification with prefix)
|
|
sqlx::query("DELETE FROM token WHERE token_hash = $1")
|
|
.bind(&token_hash)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to delete token");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let token_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_token_invalidation" && e.payload == token_prefix)
|
|
.collect();
|
|
|
|
assert!(
|
|
!token_events.is_empty(),
|
|
"Should have notify_token_invalidation event"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_webhook_change(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Update webhook setting (workspace_settings exists from base fixture)
|
|
sqlx::query("UPDATE workspace_settings SET webhook = 'https://test.webhook.com' WHERE workspace_id = 'test-workspace'")
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to update webhook");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let webhook_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_webhook_change" && e.payload == "test-workspace")
|
|
.collect();
|
|
|
|
assert!(
|
|
!webhook_events.is_empty(),
|
|
"Should have notify_webhook_change event"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_workspace_premium_change(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Toggle premium status
|
|
sqlx::query("UPDATE workspace SET premium = NOT premium WHERE id = 'test-workspace'")
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to update workspace premium");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let premium_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_workspace_premium_change" && e.payload == "test-workspace")
|
|
.collect();
|
|
|
|
assert!(
|
|
!premium_events.is_empty(),
|
|
"Should have notify_workspace_premium_change event"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// HTTP Trigger Tests
|
|
// ============================================================================
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_http_trigger_change(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
let trigger_path = format!("test_http_trigger_{}", uuid::Uuid::new_v4());
|
|
|
|
// Insert an HTTP trigger
|
|
sqlx::query(
|
|
"INSERT INTO http_trigger (path, route_path, route_path_key, script_path, is_flow, workspace_id, edited_by, permissioned_as, http_method, authentication_method)
|
|
VALUES ($1, '/test/route', '/test/route', 'test/script', false, 'test-workspace', 'test-user', 'u/test-user', 'get', 'none')",
|
|
)
|
|
.bind(&trigger_path)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert HTTP trigger");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let http_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_http_trigger_change")
|
|
.filter(|e| e.payload.contains("test-workspace") && e.payload.contains(&trigger_path))
|
|
.collect();
|
|
|
|
assert!(
|
|
!http_events.is_empty(),
|
|
"Should have notify_http_trigger_change event"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Script/Flow Version Change Tests
|
|
// ============================================================================
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_runnable_version_change_script(db: Pool<Postgres>) {
|
|
// First create a script without lock
|
|
let script_path = format!("f/test/script_{}", uuid::Uuid::new_v4());
|
|
let script_hash: i64 = rand::random::<i64>().abs();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind)
|
|
VALUES ('test-workspace', $1, $2, 'test', 'test', 'def main(): pass', 'test-user', 'python3', 'script')",
|
|
)
|
|
.bind(script_hash)
|
|
.bind(&script_path)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert script");
|
|
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Update the lock field (this should trigger the notification)
|
|
sqlx::query("UPDATE script SET lock = 'test_lock_content' WHERE hash = $1")
|
|
.bind(script_hash)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to update script lock");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let script_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_runnable_version_change")
|
|
.filter(|e| e.payload.contains("test-workspace") && e.payload.contains("script"))
|
|
.collect();
|
|
|
|
assert!(
|
|
!script_events.is_empty(),
|
|
"Should have notify_runnable_version_change event for script"
|
|
);
|
|
|
|
// Verify payload format: workspace_id:source_type:path:kind
|
|
let parts: Vec<&str> = script_events[0].payload.split(':').collect();
|
|
assert!(parts.len() >= 4, "Payload should have at least 4 parts");
|
|
assert_eq!(
|
|
parts[0], "test-workspace",
|
|
"First part should be workspace_id"
|
|
);
|
|
assert_eq!(parts[1], "script", "Second part should be 'script'");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_trigger_notify_runnable_version_change_flow(db: Pool<Postgres>) {
|
|
// First create a flow with empty versions array
|
|
let flow_path = format!("f/test/flow_{}", uuid::Uuid::new_v4());
|
|
|
|
sqlx::query(
|
|
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions)
|
|
VALUES ('test-workspace', $1, 'test', 'test', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[]::bigint[])",
|
|
)
|
|
.bind(&flow_path)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert flow");
|
|
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Update the flow's versions array (this triggers flow_versions_append_trigger)
|
|
sqlx::query(
|
|
"UPDATE flow SET versions = array_append(versions, 1::bigint) WHERE workspace_id = 'test-workspace' AND path = $1",
|
|
)
|
|
.bind(&flow_path)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to update flow versions");
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let flow_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "notify_runnable_version_change")
|
|
.filter(|e| e.payload.contains("test-workspace") && e.payload.contains("flow"))
|
|
.collect();
|
|
|
|
assert!(
|
|
!flow_events.is_empty(),
|
|
"Should have notify_runnable_version_change event for flow"
|
|
);
|
|
|
|
// Verify payload format
|
|
let parts: Vec<&str> = flow_events[0].payload.split(':').collect();
|
|
assert!(parts.len() >= 4, "Payload should have at least 4 parts");
|
|
assert_eq!(
|
|
parts[0], "test-workspace",
|
|
"First part should be workspace_id"
|
|
);
|
|
assert_eq!(parts[1], "flow", "Second part should be 'flow'");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Concurrent Access Tests
|
|
// ============================================================================
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_concurrent_event_insertion(db: Pool<Postgres>) {
|
|
// Use a unique channel name for this test run
|
|
let channel = format!("test_concurrent_{}", uuid::Uuid::new_v4());
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Insert multiple events concurrently
|
|
let handles: Vec<_> = (0..10)
|
|
.map(|i| {
|
|
let db = db.clone();
|
|
let ch = channel.clone();
|
|
tokio::spawn(async move {
|
|
sqlx::query_scalar::<_, i64>(
|
|
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
|
)
|
|
.bind(&ch)
|
|
.bind(format!("payload_{}", i))
|
|
.fetch_one(&db)
|
|
.await
|
|
.expect("Failed to insert event")
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Wait for all insertions
|
|
for handle in handles {
|
|
handle.await.expect("Task should complete");
|
|
}
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let concurrent_events: Vec<_> = events.iter().filter(|e| e.channel == channel).collect();
|
|
|
|
assert_eq!(
|
|
concurrent_events.len(),
|
|
10,
|
|
"Should have all 10 concurrent events"
|
|
);
|
|
|
|
// Verify all events have unique IDs
|
|
let ids: std::collections::HashSet<i64> = concurrent_events.iter().map(|e| e.id).collect();
|
|
assert_eq!(ids.len(), 10, "All events should have unique IDs");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_polling_isolation(db: Pool<Postgres>) {
|
|
// Use a unique channel name for this test
|
|
let channel = format!("test_isolation_{}", uuid::Uuid::new_v4());
|
|
|
|
// Get baseline before inserting
|
|
let baseline_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Insert some events
|
|
let id1 = sqlx::query_scalar::<_, i64>(
|
|
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
|
)
|
|
.bind(&channel)
|
|
.bind("payload1")
|
|
.fetch_one(&db)
|
|
.await
|
|
.expect("Failed to insert event");
|
|
|
|
let id2 = sqlx::query_scalar::<_, i64>(
|
|
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
|
)
|
|
.bind(&channel)
|
|
.bind("payload2")
|
|
.fetch_one(&db)
|
|
.await
|
|
.expect("Failed to insert event");
|
|
|
|
let _id3 = sqlx::query_scalar::<_, i64>(
|
|
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
|
)
|
|
.bind(&channel)
|
|
.bind("payload3")
|
|
.fetch_one(&db)
|
|
.await
|
|
.expect("Failed to insert event");
|
|
|
|
// Two different "consumers" polling from different points
|
|
let events_from_baseline = poll_notify_events(&db, baseline_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let events_from_id1 = poll_notify_events(&db, id1)
|
|
.await
|
|
.expect("Should poll events");
|
|
let events_from_id2 = poll_notify_events(&db, id2)
|
|
.await
|
|
.expect("Should poll events");
|
|
|
|
// Filter to our test events
|
|
let from_baseline: Vec<_> = events_from_baseline
|
|
.iter()
|
|
.filter(|e| e.channel == channel)
|
|
.collect();
|
|
let from_id1: Vec<_> = events_from_id1
|
|
.iter()
|
|
.filter(|e| e.channel == channel)
|
|
.collect();
|
|
let from_id2: Vec<_> = events_from_id2
|
|
.iter()
|
|
.filter(|e| e.channel == channel)
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
from_baseline.len(),
|
|
3,
|
|
"Polling from baseline should include all 3 events"
|
|
);
|
|
assert_eq!(
|
|
from_id1.len(),
|
|
2,
|
|
"Polling from id1 should include id2 and id3"
|
|
);
|
|
assert_eq!(
|
|
from_id2.len(),
|
|
1,
|
|
"Polling from id2 should include only id3"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Edge Case Tests
|
|
// ============================================================================
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_empty_payload(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
insert_test_event(&db, "test_empty_payload", "").await;
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let empty_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "test_empty_payload")
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
empty_events.len(),
|
|
1,
|
|
"Should have event with empty payload"
|
|
);
|
|
assert_eq!(
|
|
empty_events[0].payload, "",
|
|
"Payload should be empty string"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_large_payload(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
// Create a large payload (1KB)
|
|
let large_payload = "x".repeat(1024);
|
|
insert_test_event(&db, "test_large_payload", &large_payload).await;
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let large_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "test_large_payload")
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
large_events.len(),
|
|
1,
|
|
"Should have event with large payload"
|
|
);
|
|
assert_eq!(
|
|
large_events[0].payload.len(),
|
|
1024,
|
|
"Payload should be preserved"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_special_characters_in_payload(db: Pool<Postgres>) {
|
|
let before_id = get_latest_event_id(&db).await.unwrap();
|
|
|
|
let special_payload = r#"{"key": "value with \"quotes\" and 'apostrophes'", "unicode": "日本語", "newline": "line1\nline2"}"#;
|
|
insert_test_event(&db, "test_special_chars", special_payload).await;
|
|
|
|
let events = poll_notify_events(&db, before_id)
|
|
.await
|
|
.expect("Should poll events");
|
|
let special_events: Vec<_> = events
|
|
.iter()
|
|
.filter(|e| e.channel == "test_special_chars")
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
special_events.len(),
|
|
1,
|
|
"Should have event with special characters"
|
|
);
|
|
assert_eq!(
|
|
special_events[0].payload, special_payload,
|
|
"Special characters should be preserved"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_cleanup_with_no_old_events(db: Pool<Postgres>) {
|
|
// Use a unique channel name for this test
|
|
let channel = format!("test_no_old_{}", uuid::Uuid::new_v4());
|
|
|
|
// Insert only recent events
|
|
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
|
|
.bind(&channel)
|
|
.bind("recent1")
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert event");
|
|
|
|
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
|
|
.bind(&channel)
|
|
.bind("recent2")
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert event");
|
|
|
|
let before_count = count_events_for_channel(&db, &channel).await;
|
|
assert_eq!(before_count, 2, "Should have 2 recent events");
|
|
|
|
// Cleanup old events (none of our events should be deleted since they're recent)
|
|
let _deleted = cleanup_old_events(&db, 10)
|
|
.await
|
|
.expect("Should cleanup events");
|
|
|
|
let after_count = count_events_for_channel(&db, &channel).await;
|
|
assert_eq!(after_count, 2, "Recent events should not be deleted");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Multi-Server Integration Tests
|
|
// ============================================================================
|
|
// These tests start two actual windmill server processes on different ports
|
|
// with LISTEN_NEW_EVENTS_INTERVAL_SEC=1, trigger DB changes, and verify
|
|
// both servers process the events via their log output.
|
|
|
|
use std::io::{BufRead, BufReader};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
struct ServerProcess {
|
|
child: Child,
|
|
log_lines: Arc<Mutex<Vec<String>>>,
|
|
_stdout_handle: std::thread::JoinHandle<()>,
|
|
_stderr_handle: std::thread::JoinHandle<()>,
|
|
}
|
|
|
|
impl ServerProcess {
|
|
fn start(port: u16, db_url: &str) -> Self {
|
|
let binary = std::env::var("WINDMILL_BINARY")
|
|
.unwrap_or_else(|_| format!("{}/target/debug/windmill", env!("CARGO_MANIFEST_DIR")));
|
|
|
|
let mut child = Command::new(&binary)
|
|
.env("DATABASE_URL", db_url)
|
|
.env("MODE", "server")
|
|
.env("PORT", port.to_string())
|
|
.env("LISTEN_NEW_EVENTS_INTERVAL_SEC", "1")
|
|
.env("RUST_LOG", "info")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap_or_else(|e| panic!("Failed to start windmill on port {port}: {e}"));
|
|
|
|
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
|
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
|
let log_lines = Arc::new(Mutex::new(Vec::new()));
|
|
let log_lines_stdout = log_lines.clone();
|
|
let log_lines_stderr = log_lines.clone();
|
|
|
|
// Read both stdout and stderr into the same log buffer
|
|
let _reader_handle = std::thread::spawn(move || {
|
|
let reader = BufReader::new(stdout);
|
|
for line in reader.lines() {
|
|
if let Ok(line) = line {
|
|
log_lines_stdout.lock().unwrap().push(line);
|
|
}
|
|
}
|
|
});
|
|
let _stderr_handle = std::thread::spawn(move || {
|
|
let reader = BufReader::new(stderr);
|
|
for line in reader.lines() {
|
|
if let Ok(line) = line {
|
|
log_lines_stderr.lock().unwrap().push(line);
|
|
}
|
|
}
|
|
});
|
|
|
|
ServerProcess { child, log_lines, _stdout_handle: _reader_handle, _stderr_handle }
|
|
}
|
|
|
|
fn logs_contain(&self, needle: &str) -> bool {
|
|
self.log_lines
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|l| l.contains(needle))
|
|
}
|
|
|
|
fn dump_logs(&self) -> String {
|
|
self.log_lines.lock().unwrap().join("\n")
|
|
}
|
|
}
|
|
|
|
impl Drop for ServerProcess {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
/// Wait for server to be ready by polling its HTTP endpoint.
|
|
async fn wait_for_server(port: u16, timeout_secs: u64) -> bool {
|
|
let client = reqwest::Client::new();
|
|
let url = format!("http://127.0.0.1:{}/api/version", port);
|
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
|
while tokio::time::Instant::now() < deadline {
|
|
if client.get(&url).send().await.is_ok() {
|
|
return true;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Helper to get a database connection (only used by the e2e multi-server test)
|
|
async fn get_db() -> Pool<Postgres> {
|
|
let database_url = std::env::var("DATABASE_URL")
|
|
.unwrap_or_else(|_| "postgres://postgres:changeme@localhost:5432/windmill".to_string());
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
.max_connections(5)
|
|
.connect(&database_url)
|
|
.await
|
|
.expect("Failed to connect to database")
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "slow - starts two server processes with 1s poll interval"]
|
|
async fn test_two_server_processes_both_receive_event() {
|
|
let db_url = std::env::var("DATABASE_URL")
|
|
.unwrap_or_else(|_| "postgres://postgres:changeme@localhost:5432/windmill".to_string());
|
|
|
|
// Start two server processes on different ports with 1s poll interval
|
|
let mut server_a = ServerProcess::start(19100, &db_url);
|
|
let mut server_b = ServerProcess::start(19200, &db_url);
|
|
|
|
// Wait for both servers to be ready
|
|
let (ready_a, ready_b) = tokio::join!(wait_for_server(19100, 30), wait_for_server(19200, 30),);
|
|
assert!(
|
|
ready_a,
|
|
"Server A (port 19100) failed to start. Logs:\n{}",
|
|
server_a.dump_logs()
|
|
);
|
|
assert!(
|
|
ready_b,
|
|
"Server B (port 19200) failed to start. Logs:\n{}",
|
|
server_b.dump_logs()
|
|
);
|
|
|
|
// Give servers a moment to complete their first poll cycle
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
// Trigger a global setting change via direct DB insert
|
|
let db = get_db().await;
|
|
let setting_name = format!("test_e2e_{}", uuid::Uuid::new_v4());
|
|
sqlx::query(
|
|
"INSERT INTO global_settings (name, value) VALUES ($1, '\"e2e_test\"'::jsonb)
|
|
ON CONFLICT (name) DO UPDATE SET value = '\"e2e_test\"'::jsonb",
|
|
)
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.expect("Failed to insert global setting");
|
|
|
|
// Wait for at least 2 poll cycles (interval is 1s)
|
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
|
|
|
let needle = format!("Global setting change detected: {}", setting_name);
|
|
assert!(
|
|
server_a.logs_contain(&needle),
|
|
"Server A should have processed the global setting event.\nSearching for: {}\nServer A logs:\n{}",
|
|
needle, server_a.dump_logs()
|
|
);
|
|
assert!(
|
|
server_b.logs_contain(&needle),
|
|
"Server B should have processed the global setting event.\nSearching for: {}\nServer B logs:\n{}",
|
|
needle, server_b.dump_logs()
|
|
);
|
|
|
|
// Cleanup
|
|
sqlx::query("DELETE FROM global_settings WHERE name = $1")
|
|
.bind(&setting_name)
|
|
.execute(&db)
|
|
.await
|
|
.ok();
|
|
|
|
// Explicitly kill before drop to avoid port conflicts with other tests
|
|
let _ = server_a.child.kill();
|
|
let _ = server_b.child.kill();
|
|
}
|