Files
Ruben Fiszel 05a38d312d fix: advance postgres replication slot lsn via periodic standby status updates (#9227)
* fix: advance postgres replication slot lsn via periodic standby status updates

* test: add e2e regression test for postgres replication slot lsn advancement

Drives sustained change traffic so the slot freeze reproduces deterministically (fails pre-fix at the 20s deadline, passes post-fix within ~10s). Also wires the postgres_trigger feature through windmill-test-utils and the integration-tests crate so the postgres trigger e2e tests are actually runnable.
2026-05-19 05:44:13 +00:00

897 lines
29 KiB
Rust

/*!
* End-to-end integration tests for Windmill trigger listeners.
*
* Each test is `#[ignore]` because it requires a running external service
* (MQTT broker, NATS server, Kafka broker, etc.). See individual test doc
* comments for setup instructions.
*
* Quick start — use the helper scripts in `tests/fixtures/`:
* ```bash
* ./tests/fixtures/start_all_triggers.sh # start all services
* ./tests/fixtures/start_all_triggers.sh oss # start OSS services only
* ./tests/fixtures/start_all_triggers.sh stop # tear down everything
* ```
*
* The general pattern:
* 1. Insert a test script + trigger row + resource into the DB
* 2. Start the API server with listeners enabled (server_mode=true)
* 3. Connect to the external service and send a test message
* 4. Poll `v2_job` for a job matching the trigger path + trigger_kind
* 5. Verify the args shape/content
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use std::time::Duration;
use windmill_test_utils::*;
// ============================================================================
// Helpers
// ============================================================================
/// Row shape for polling v2_job.
#[derive(Debug)]
#[allow(dead_code)]
struct TriggerJobRow {
id: uuid::Uuid,
runnable_path: Option<String>,
trigger_kind: Option<String>,
args: Option<sqlx::types::Json<serde_json::Value>>,
}
/// Poll `v2_job` every 500ms for up to `timeout` for a job whose
/// `runnable_path` and `trigger_kind` match the expected values.
async fn poll_for_trigger_job(
db: &Pool<Postgres>,
script_path: &str,
trigger_kind: &str,
timeout: Duration,
) -> anyhow::Result<TriggerJobRow> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let row = sqlx::query_as!(
TriggerJobRow,
r#"
SELECT id, runnable_path, trigger_kind AS "trigger_kind: String",
args AS "args: sqlx::types::Json<serde_json::Value>"
FROM v2_job
WHERE runnable_path = $1
AND trigger_kind = $2::job_trigger_kind
ORDER BY created_at DESC
LIMIT 1
"#,
script_path,
trigger_kind as _,
)
.fetch_optional(db)
.await?;
if let Some(job) = row {
return Ok(job);
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for trigger job (script_path={}, trigger_kind={})",
script_path,
trigger_kind
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
/// Insert a minimal test script row that trigger listeners can reference.
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
'test-user', 'python3', 'script', '')",
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
/// Insert a resource row for triggers that resolve connection details from the
/// `resource` table.
async fn insert_resource(
db: &Pool<Postgres>,
path: &str,
resource_type: &str,
value: serde_json::Value,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2::jsonb, $3, '{}'::jsonb, 'test-user')",
)
.bind(path)
.bind(value)
.bind(resource_type)
.execute(db)
.await?;
Ok(())
}
// ============================================================================
// MQTT Trigger E2E
// ============================================================================
/// End-to-end test for MQTT trigger.
///
/// Requires a running MQTT broker. Setup:
/// ```bash
/// ./tests/fixtures/start_mqtt.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_mqtt_e2e --features mqtt_trigger \
/// -- --ignored --nocapture
/// ```
#[ignore = "requires running MQTT broker on localhost:1883"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mqtt_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/mqtt_e2e_handler";
insert_test_script(&db, script_path).await?;
insert_resource(
&db,
"u/test-user/mqtt_res",
"mqtt",
json!({
"broker": "localhost",
"port": 1883
}),
)
.await?;
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_e2e_trigger")
.bind("u/test-user/mqtt_res")
.bind(json!({"topic": "windmill/test/e2e", "qos": "qos0"}))
.bind("v5")
.bind(script_path)
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
tokio::time::sleep(Duration::from_secs(3)).await;
// Publish a message using rumqttc
let mut mqtt_opts = rumqttc::MqttOptions::new("windmill-e2e-test", "localhost", 1883);
mqtt_opts.set_keep_alive(Duration::from_secs(5));
let (client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_opts, 10);
// Drive the event loop in the background
let el_handle = tokio::spawn(async move {
loop {
match eventloop.poll().await {
Ok(_) => {}
Err(_) => break,
}
}
});
tokio::time::sleep(Duration::from_millis(500)).await;
client
.publish(
"windmill/test/e2e",
rumqttc::QoS::AtLeastOnce,
false,
b"hello from e2e test".to_vec(),
)
.await?;
let job = poll_for_trigger_job(&db, script_path, "mqtt", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
client.disconnect().await.ok();
el_handle.abort();
Ok(())
}
// ============================================================================
// WebSocket Trigger E2E
// ============================================================================
/// End-to-end test for WebSocket trigger.
///
/// Requires a WebSocket echo server. Setup:
/// ```bash
/// ./tests/fixtures/start_websocket.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_websocket_e2e --features websocket \
/// -- --ignored --nocapture
/// ```
#[ignore = "requires running WebSocket echo server on localhost:8765"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_websocket_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/ws_e2e_handler";
insert_test_script(&db, script_path).await?;
sqlx::query!(
r#"
INSERT INTO websocket_trigger (
path, url, script_path, is_flow, workspace_id,
edited_by, permissioned_as, initial_messages
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
"f/test/ws_e2e_trigger",
"ws://localhost:8765",
script_path,
false,
"test-workspace",
"test-user",
"u/test-user",
&[json!({"type": "RawMessage", "content": "hello from e2e test"})]
as &[serde_json::Value],
)
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
// The WebSocket trigger connects to the server and sends initial_messages,
// and each received message triggers a job.
let job = poll_for_trigger_job(&db, script_path, "websocket", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
Ok(())
}
// ============================================================================
// Postgres Trigger E2E
// ============================================================================
/// End-to-end test for Postgres trigger (logical replication).
///
/// Requires PostgreSQL with `wal_level=logical`. Setup:
/// ```bash
/// ./tests/fixtures/start_postgres_replication.sh
/// ```
/// (The script checks wal_level and creates the table/publication/slot in the
/// main DB. This test re-creates them in its isolated sqlx::test database.)
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_postgres_e2e --features postgres_trigger \
/// -- --ignored --nocapture
/// ```
#[ignore = "requires PostgreSQL with wal_level=logical"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_postgres_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/pg_e2e_handler";
insert_test_script(&db, script_path).await?;
// Create the tracked table + publication + replication slot inside the
// isolated test database (sqlx::test gives us a fresh DB each run).
// Replication slots are server-wide so we use a random suffix.
let suffix: u32 = rand::random();
let slot_name = format!("test_e2e_slot_{suffix}");
let pub_name = format!("test_e2e_pub_{suffix}");
sqlx::query("CREATE TABLE test_trigger_table (id serial PRIMARY KEY, data text)")
.execute(&db)
.await?;
sqlx::query(&format!(
"CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table"
))
.execute(&db)
.await?;
sqlx::query(&format!(
"SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')"
))
.execute(&db)
.await?;
// Extract the test DB name from the pool so the resource points here,
// not at the main windmill database.
let test_db_name: String = sqlx::query_scalar("SELECT current_database()")
.fetch_one(&db)
.await?;
insert_resource(
&db,
"u/test-user/pg_res",
"postgresql",
json!({
"user": "postgres",
"password": "changeme",
"host": "localhost",
"port": 5432,
"dbname": test_db_name,
"sslmode": "disable"
}),
)
.await?;
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)
"#,
)
.bind("f/test/pg_e2e_trigger")
.bind(script_path)
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.bind("u/test-user/pg_res")
.bind(&slot_name)
.bind(&pub_name)
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
tokio::time::sleep(Duration::from_secs(3)).await;
// Insert a row into the tracked table to trigger a change event
sqlx::query("INSERT INTO test_trigger_table (data) VALUES ('e2e test data')")
.execute(&db)
.await?;
let job = poll_for_trigger_job(&db, script_path, "postgres", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
Ok(())
}
/// Regression test for the replication-slot LSN freeze bug.
///
/// Before the fix the Postgres trigger consumer only sent a standby status
/// update in response to a `PrimaryKeepAlive` with `reply=true` — never on a
/// timer and never after processing `XLogData`. The bound jobs ran fine but
/// the slot's `confirmed_flush_lsn`/`restart_lsn` never advanced, so retained
/// WAL grew unbounded until it exceeded `max_slot_wal_keep_size`.
///
/// This asserts the slot's `confirmed_flush_lsn` advances to at least the WAL
/// position recorded *before* the change events, proving feedback is sent.
/// Against the pre-fix code this loop times out (LSN frozen at slot creation);
/// with the fix the periodic status update advances it within ~10s.
///
/// Requires PostgreSQL with `wal_level=logical` (see `test_postgres_e2e`).
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_postgres_slot_lsn_advances \
/// --features postgres_trigger -- --ignored --nocapture
/// ```
#[ignore = "requires PostgreSQL with wal_level=logical"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_postgres_slot_lsn_advances(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/pg_lsn_handler";
insert_test_script(&db, script_path).await?;
// Replication slots are server-wide so use a random suffix.
let suffix: u32 = rand::random();
let slot_name = format!("test_lsn_slot_{suffix}");
let pub_name = format!("test_lsn_pub_{suffix}");
sqlx::query("CREATE TABLE lsn_trigger_table (id serial PRIMARY KEY, data text)")
.execute(&db)
.await?;
sqlx::query(&format!(
"CREATE PUBLICATION {pub_name} FOR TABLE lsn_trigger_table"
))
.execute(&db)
.await?;
sqlx::query(&format!(
"SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')"
))
.execute(&db)
.await?;
let test_db_name: String = sqlx::query_scalar("SELECT current_database()")
.fetch_one(&db)
.await?;
insert_resource(
&db,
"u/test-user/pg_lsn_res",
"postgresql",
json!({
"user": "postgres",
"password": "changeme",
"host": "localhost",
"port": 5432,
"dbname": test_db_name,
"sslmode": "disable"
}),
)
.await?;
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)
"#,
)
.bind("f/test/pg_lsn_trigger")
.bind(script_path)
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.bind("u/test-user/pg_lsn_res")
.bind(&slot_name)
.bind(&pub_name)
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
// Give the listener time to open the replication stream.
tokio::time::sleep(Duration::from_secs(3)).await;
// WAL position before any change events. A correctly behaving consumer
// must report a flushed LSN at or beyond this once it has processed the
// change traffic below.
let before_lsn: String = sqlx::query_scalar("SELECT pg_current_wal_lsn()::text")
.fetch_one(&db)
.await?;
// Sustained change traffic — this is what makes the bug bite in
// production. While data keeps flowing, Postgres does not volunteer a
// reply-requested keepalive until it is about to hit `wal_sender_timeout`
// (default 60s, so ~30s of silence). The pre-fix consumer only ever
// replied to those keepalives, so under continuous load the slot stayed
// frozen. The fix sends a standby status update every 10s regardless of
// traffic, so the slot advances well before the keepalive path would.
let inserter_db = db.clone();
let inserter = tokio::spawn(async move {
let mut i = 0;
loop {
let _ = sqlx::query("INSERT INTO lsn_trigger_table (data) VALUES ($1)")
.bind(format!("row {i}"))
.execute(&inserter_db)
.await;
i += 1;
tokio::time::sleep(Duration::from_millis(500)).await;
}
});
// Sanity: the bound job still runs (this passed even with the bug).
let job = poll_for_trigger_job(&db, script_path, "postgres", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
// Post-fix advances within ~10s (the status-update interval). Pre-fix
// cannot advance before the first forced reply-keepalive (~30s with the
// default 60s wal_sender_timeout), so a 20s deadline cleanly separates the
// two while leaving generous margin on both sides.
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let advanced = loop {
let advanced: Option<bool> = sqlx::query_scalar(
r#"
SELECT confirmed_flush_lsn >= $1::pg_lsn
FROM pg_replication_slots
WHERE slot_name = $2
"#,
)
.bind(&before_lsn)
.bind(&slot_name)
.fetch_one(&db)
.await?;
if advanced == Some(true) {
break true;
}
if tokio::time::Instant::now() >= deadline {
break false;
}
tokio::time::sleep(Duration::from_secs(1)).await;
};
inserter.abort();
if !advanced {
let current: Option<String> = sqlx::query_scalar(
"SELECT confirmed_flush_lsn::text FROM pg_replication_slots WHERE slot_name = $1",
)
.bind(&slot_name)
.fetch_one(&db)
.await?;
panic!(
"replication slot {slot_name} confirmed_flush_lsn did not advance within 20s: \
before={before_lsn}, current={current:?} — WAL retention would grow unbounded"
);
}
Ok(())
}
// ============================================================================
// Kafka Trigger E2E (Enterprise)
// ============================================================================
/// End-to-end test for Kafka trigger (Enterprise only).
///
/// Requires a running Kafka broker with the test topic. Setup:
/// ```bash
/// ./tests/fixtures/start_kafka.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_kafka_e2e \
/// --features kafka,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires running Kafka broker on localhost:9092"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_kafka_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/kafka_e2e_handler";
insert_test_script(&db, script_path).await?;
insert_resource(
&db,
"u/test-user/kafka_res",
"kafka",
json!({
"brokers": ["localhost:9092"],
"security": { "label": "PLAINTEXT" }
}),
)
.await?;
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_e2e_trigger",
"u/test-user/kafka_res",
&["windmill-e2e-test"] as &[&str],
"windmill-e2e-test-group",
script_path,
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
tokio::time::sleep(Duration::from_secs(5)).await;
// Produce messages using rdkafka. The consumer starts with auto.offset.reset=latest
// and needs time for group rebalance, so we send repeatedly until a job appears.
use rdkafka::config::ClientConfig;
use rdkafka::producer::{FutureProducer, FutureRecord};
let producer: FutureProducer = ClientConfig::new()
.set("bootstrap.servers", "localhost:9092")
.create()?;
let db2 = db.clone();
let produce_handle = tokio::spawn(async move {
for _ in 0..30 {
let _ = producer
.send(
FutureRecord::to("windmill-e2e-test")
.payload("hello from kafka e2e test")
.key("test-key"),
Duration::from_secs(5),
)
.await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
let job = poll_for_trigger_job(&db2, script_path, "kafka", Duration::from_secs(30)).await?;
produce_handle.abort();
assert!(job.args.is_some(), "job should have args");
Ok(())
}
// ============================================================================
// NATS Trigger E2E (Enterprise)
// ============================================================================
/// End-to-end test for NATS trigger (Enterprise only).
///
/// Requires a running NATS server. Setup:
/// ```bash
/// ./tests/fixtures/start_nats.sh
/// ```
///
/// Run:
/// ```bash
/// cargo test --test trigger_e2e test_nats_e2e \
/// --features nats,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires running NATS server on localhost:4222"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_nats_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/nats_e2e_handler";
insert_test_script(&db, script_path).await?;
insert_resource(
&db,
"u/test-user/nats_res",
"nats",
json!({
"servers": ["nats://localhost:4222"],
"auth": { "label": "NO_AUTH" },
"require_tls": false
}),
)
.await?;
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_e2e_trigger",
"u/test-user/nats_res",
&["windmill.e2e.test"] as &[&str],
script_path,
false,
"test-workspace",
"test-user",
"u/test-user",
false,
)
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
tokio::time::sleep(Duration::from_secs(3)).await;
// Publish a message using async-nats
let nats_client = async_nats::connect("localhost:4222").await?;
nats_client
.publish("windmill.e2e.test", "hello from nats e2e test".into())
.await?;
nats_client.flush().await?;
let job = poll_for_trigger_job(&db, script_path, "nats", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
Ok(())
}
// ============================================================================
// SQS Trigger E2E (Enterprise)
// ============================================================================
/// End-to-end test for SQS trigger (Enterprise only).
///
/// Requires LocalStack with the test queue. Setup:
/// ```bash
/// ./tests/fixtures/start_sqs.sh
/// ```
///
/// Run:
/// ```bash
/// AWS_ENDPOINT_URL=http://localhost:4566 \
/// cargo test --test trigger_e2e test_sqs_e2e \
/// --features sqs_trigger,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires LocalStack SQS on localhost:4566"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_sqs_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// The SQS listener uses aws_config which respects AWS_ENDPOINT_URL for LocalStack.
std::env::set_var("AWS_ENDPOINT_URL", "http://localhost:4566");
let script_path = "f/test/sqs_e2e_handler";
insert_test_script(&db, script_path).await?;
insert_resource(
&db,
"u/test-user/aws_res",
"aws",
json!({
"awsAccessKeyId": "test",
"awsSecretAccessKey": "test",
"region": "us-east-1"
}),
)
.await?;
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_e2e_trigger",
"http://localhost:4566/000000000000/windmill-e2e-test",
"u/test-user/aws_res",
script_path,
false,
"test-workspace",
"test-user",
"u/test-user",
)
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
tokio::time::sleep(Duration::from_secs(3)).await;
// Send a message using aws-sdk-sqs
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.endpoint_url("http://localhost:4566")
.region(aws_config::Region::new("us-east-1"))
.credentials_provider(aws_credential_types::Credentials::new(
"test", "test", None, None, "test",
))
.load()
.await;
let sqs_client = aws_sdk_sqs::Client::new(&config);
sqs_client
.send_message()
.queue_url("http://localhost:4566/000000000000/windmill-e2e-test")
.message_body("hello from sqs e2e test")
.send()
.await?;
let job = poll_for_trigger_job(&db, script_path, "sqs", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
Ok(())
}
// ============================================================================
// GCP Pub/Sub Trigger E2E (Enterprise)
// ============================================================================
/// End-to-end test for GCP Pub/Sub trigger (Enterprise only).
///
/// Requires the GCP Pub/Sub emulator with test topic/subscription. Setup:
/// ```bash
/// ./tests/fixtures/start_gcp_pubsub.sh
/// ```
///
/// Run:
/// ```bash
/// PUBSUB_EMULATOR_HOST=localhost:8085 \
/// cargo test --test trigger_e2e test_gcp_e2e \
/// --features gcp_trigger,enterprise,private -- --ignored --nocapture
/// ```
#[cfg(all(feature = "enterprise", feature = "private"))]
#[ignore = "requires GCP Pub/Sub emulator on localhost:8085"]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_gcp_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let script_path = "f/test/gcp_e2e_handler";
insert_test_script(&db, script_path).await?;
// The GCP emulator doesn't require real credentials, but the resource
// row must still exist for the listener to resolve it.
// The private_key must use literal \n (backslash-n) as in real GCP service
// account JSON files. The trigger code re-parses it through serde_json to
// convert those escape sequences to actual newlines.
insert_resource(
&db,
"u/test-user/gcp_res",
"google",
json!({
"project_id": "test-project",
"private_key_id": "test",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\\nMIIBogIBAAJBALRiMLAH\\n-----END RSA PRIVATE KEY-----\\n",
"client_email": "test@test-project.iam.gserviceaccount.com",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs"
}),
)
.await?;
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_e2e_trigger")
.bind("u/test-user/gcp_res")
.bind("windmill-e2e-test")
.bind("windmill-e2e-sub")
.bind("pull")
.bind("existing")
.bind(script_path)
.bind(false)
.bind("test-workspace")
.bind("test-user")
.bind("u/test-user")
.execute(&db)
.await?;
let _server = ApiServer::start_with_listeners(db.clone()).await?;
tokio::time::sleep(Duration::from_secs(3)).await;
// Publish a message to the emulator via HTTP
let client = reqwest::Client::new();
let emulator_host =
std::env::var("PUBSUB_EMULATOR_HOST").unwrap_or_else(|_| "localhost:8085".to_string());
// The google-cloud-pubsub crate uses "local-project" as the default project ID
// when PUBSUB_EMULATOR_HOST is set, so we must publish to that project's topic.
let publish_url = format!(
"http://{}/v1/projects/local-project/topics/windmill-e2e-test:publish",
emulator_host
);
let message_data = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
"hello from gcp e2e test",
);
client
.post(&publish_url)
.json(&json!({
"messages": [{ "data": message_data }]
}))
.send()
.await?;
let job = poll_for_trigger_job(&db, script_path, "gcp", Duration::from_secs(30)).await?;
assert!(job.args.is_some(), "job should have args");
Ok(())
}