use std::path::PathBuf;
use std::process::Command;
use serde_json::Value;
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn clickhousectl_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_clickhousectl"))
}
async fn start_mock_clickpipes_api() -> MockServer {
let mock = MockServer::start().await;
let stub_pipe = serde_json::json!({
"result": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "stub",
"state": "Stopped",
"scaling": { "replicas": 1 },
"source": {},
"destination": { "database": "default" },
"metrics": {},
},
"status": 200,
"requestId": "stub-request-id"
});
Mock::given(method("POST"))
.and(path_regex(
r"^/v1/organizations/[^/]+/services/[^/]+/clickpipes$",
))
.respond_with(ResponseTemplate::new(200).set_body_json(stub_pipe))
.mount(&mock)
.await;
mock
}
async fn invoke_cli_capture_body(mock: &MockServer, cli_args: &[&str]) -> Value {
let mut full_args: Vec<&str> = vec!["cloud", "--url"];
let url = mock.uri();
full_args.push(&url);
full_args.push("--json");
full_args.extend(cli_args);
let output = Command::new(clickhousectl_binary())
.args(&full_args)
.env("CLICKHOUSE_CLOUD_API_KEY", "fake-key-for-tests")
.env("CLICKHOUSE_CLOUD_API_SECRET", "fake-secret-for-tests")
.output()
.expect("failed to spawn clickhousectl");
assert!(
output.status.success(),
"clickhousectl exited {} for args {:?}\nstderr:\n{}\nstdout:\n{}",
output.status.code().unwrap_or(-1),
full_args,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
let requests = mock
.received_requests()
.await
.expect("mock requests log unavailable");
let post = requests
.iter()
.find(|r| r.method == wiremock::http::Method::POST)
.expect("no POST request recorded by mock");
serde_json::from_slice(&post.body).expect("POST body wasn't valid JSON")
}
#[tokio::test]
async fn postgres_cdc_omits_publication_name_and_slot_when_not_passed() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"postgres",
"svc-id",
"--name",
"test-pipe",
"--host",
"pg.example.com",
"--port",
"5432",
"--pg-database",
"test",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"public.t:t",
"--replication-mode",
"cdc",
"--org-id",
"11dfa1ec-767d-43cb-bfad-618ce2aaf959",
],
)
.await;
let settings = &body["source"]["postgres"]["settings"];
assert!(
settings.get("publicationName").is_none(),
"publicationName leaked into wire body: {settings}",
);
assert!(
settings.get("replicationSlotName").is_none(),
"replicationSlotName leaked into wire body: {settings}",
);
}
#[tokio::test]
async fn postgres_destination_omits_table_columns_managed_table_definition() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"postgres",
"svc-id",
"--name",
"test-pipe",
"--host",
"pg.example.com",
"--port",
"5432",
"--pg-database",
"test",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"public.t:t",
"--replication-mode",
"cdc",
"--org-id",
"11dfa1ec-767d-43cb-bfad-618ce2aaf959",
],
)
.await;
let dest = &body["destination"];
assert_eq!(
dest["database"], "default",
"database should default to 'default' for postgres CDC, got {dest}"
);
for field in ["table", "columns", "managedTable", "tableDefinition"] {
assert!(
dest.get(field).is_none(),
"{field} leaked into destination body — Al's Bug 2 regression: {dest}",
);
}
}
#[tokio::test]
async fn mysql_destination_omits_table_columns_managed_table_definition() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"mysql",
"svc-id",
"--name",
"test-pipe",
"--host",
"mysql.example.com",
"--port",
"3306",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"mydb.t:t",
"--replication-mode",
"cdc",
"--org-id",
"11dfa1ec-767d-43cb-bfad-618ce2aaf959",
],
)
.await;
let dest = &body["destination"];
assert_eq!(dest["database"], "default");
for field in ["table", "columns", "managedTable", "tableDefinition"] {
assert!(
dest.get(field).is_none(),
"{field} leaked into MySQL destination body: {dest}",
);
}
}
#[tokio::test]
async fn mongodb_destination_omits_table_columns_managed_table_definition() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"mongodb",
"svc-id",
"--name",
"test-pipe",
"--uri",
"mongodb://mongo.example.com:27017",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"mydb.coll:t",
"--replication-mode",
"cdc",
"--org-id",
"11dfa1ec-767d-43cb-bfad-618ce2aaf959",
],
)
.await;
let dest = &body["destination"];
assert_eq!(dest["database"], "default");
for field in ["table", "columns", "managedTable", "tableDefinition"] {
assert!(
dest.get(field).is_none(),
"{field} leaked into Mongo destination body: {dest}",
);
}
}
#[tokio::test]
async fn s3_pipe_omits_iam_role_and_queue_url_when_not_passed() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"object-storage",
"svc-id",
"--name",
"test-pipe",
"--source-url",
"https://bucket.s3.us-east-1.amazonaws.com/data/*.json",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--access-key-id",
"AKIA000000000000FAKE",
"--secret-key",
"fake/secret/for/tests/0000000000000000",
"--org-id",
"11dfa1ec-767d-43cb-bfad-618ce2aaf959",
],
)
.await;
let s3 = &body["source"]["objectStorage"];
for field in [
"iamRole",
"queueUrl",
"connectionString",
"azureContainerName",
"path",
"serviceAccountKey",
"delimiter",
] {
assert!(
s3.get(field).is_none(),
"{field} leaked into S3 body when --{field} not passed: {s3}",
);
}
}
#[tokio::test]
async fn gcs_service_account_file_is_read_and_base64_encoded() {
use std::io::Write;
let mock = start_mock_clickpipes_api().await;
let dir = tempfile::tempdir().unwrap();
let sa_path = dir.path().join("service-account.json");
let sa_contents = br#"{"type":"service_account","project_id":"test"}"#;
let mut sa_file = std::fs::File::create(&sa_path).unwrap();
sa_file.write_all(sa_contents).unwrap();
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"object-storage",
"svc-id",
"--name",
"gcs-pipe",
"--source-url",
"https://storage.googleapis.com/bucket/data/*.json",
"--format",
"JSONEachRow",
"--storage-type",
"gcs",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--service-account-file",
sa_path.to_str().unwrap(),
"--org-id",
"11dfa1ec-767d-43cb-bfad-618ce2aaf959",
],
)
.await;
let gcs = &body["source"]["objectStorage"];
assert_eq!(gcs["authentication"], "SERVICE_ACCOUNT");
let expected = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
sa_contents,
);
assert_eq!(
gcs["serviceAccountKey"].as_str(),
Some(expected.as_str()),
"serviceAccountKey on the wire should be base64 of the file contents: {gcs}",
);
}
#[tokio::test]
async fn postgres_optional_fields_absent_when_flags_omitted() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"postgres",
"svc-id",
"--name",
"t",
"--host",
"pg",
"--port",
"5432",
"--pg-database",
"test",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"public.t:t",
"--replication-mode",
"cdc",
"--org-id",
"org",
],
)
.await;
let pg = &body["source"]["postgres"];
for field in ["iamRole", "tlsHost", "caCertificate"] {
assert!(
pg.get(field).is_none(),
"{field} leaked into postgres source body: {pg}",
);
}
}
#[tokio::test]
async fn mysql_optional_fields_absent_when_flags_omitted() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"mysql",
"svc-id",
"--name",
"t",
"--host",
"mysql",
"--port",
"3306",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"mydb.t:t",
"--replication-mode",
"cdc",
"--org-id",
"org",
],
)
.await;
let mysql = &body["source"]["mysql"];
for field in ["iamRole", "tlsHost", "caCertificate"] {
assert!(
mysql.get(field).is_none(),
"{field} leaked into mysql source body: {mysql}",
);
}
}
#[tokio::test]
async fn mongodb_tls_host_absent_when_not_passed() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"mongodb",
"svc-id",
"--name",
"t",
"--uri",
"mongodb://m:27017",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"db.c:t",
"--replication-mode",
"cdc",
"--org-id",
"org",
],
)
.await;
let mongo = &body["source"]["mongodb"];
assert!(
mongo.get("tlsHost").is_none(),
"tlsHost leaked into mongodb source body: {mongo}",
);
assert!(
mongo.get("caCertificate").is_none(),
"caCertificate leaked into mongodb source body: {mongo}",
);
}
fn kafka_args_minimal() -> Vec<&'static str> {
vec![
"clickpipe",
"create",
"kafka",
"svc-id",
"--name",
"t",
"--brokers",
"broker:9092",
"--topics",
"topic",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--kafka-type",
"kafka",
"--auth",
"PLAIN",
"--username",
"u",
"--password",
"p",
"--org-id",
"org",
]
}
#[tokio::test]
async fn kafka_optional_fields_absent_when_flags_omitted() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(&mock, &kafka_args_minimal()).await;
let kafka = &body["source"]["kafka"];
for field in ["consumerGroup", "iamRole", "schemaRegistry", "caCertificate"] {
assert!(
kafka.get(field).is_none(),
"{field} leaked into kafka source body: {kafka}",
);
}
assert!(
kafka["offset"].get("timestamp").is_none(),
"offset.timestamp leaked when --offset-timestamp not passed: {kafka}",
);
}
#[tokio::test]
async fn kafka_plain_credentials_shape() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(&mock, &kafka_args_minimal()).await;
let creds = &body["source"]["kafka"]["credentials"];
assert_eq!(creds["username"], "u");
assert_eq!(creds["password"], "p");
}
#[tokio::test]
async fn kafka_scram_sha_512_credentials_shape() {
let mock = start_mock_clickpipes_api().await;
let mut args = kafka_args_minimal();
let auth_idx = args.iter().position(|a| *a == "PLAIN").unwrap();
args[auth_idx] = "SCRAM-SHA-512";
let body = invoke_cli_capture_body(&mock, &args).await;
let creds = &body["source"]["kafka"]["credentials"];
assert_eq!(creds["username"], "u");
assert_eq!(creds["password"], "p");
}
#[tokio::test]
async fn kafka_iam_role_serializes_iam_role_field() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"kafka",
"svc-id",
"--name",
"t",
"--brokers",
"broker:9092",
"--topics",
"topic",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--kafka-type",
"msk",
"--auth",
"IAM_ROLE",
"--iam-role",
"arn:aws:iam::123:role/x",
"--org-id",
"org",
],
)
.await;
let kafka = &body["source"]["kafka"];
assert_eq!(kafka["iamRole"], "arn:aws:iam::123:role/x");
assert!(
kafka["credentials"].is_null(),
"IAM_ROLE credentials should be null, got: {}",
kafka["credentials"]
);
}
#[tokio::test]
async fn kafka_iam_user_credentials_shape() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"kafka",
"svc-id",
"--name",
"t",
"--brokers",
"broker:9092",
"--topics",
"topic",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--kafka-type",
"msk",
"--auth",
"IAM_USER",
"--access-key-id",
"AKIA000000000000FAKE",
"--secret-key",
"fake/secret/0000000000000000",
"--org-id",
"org",
],
)
.await;
let creds = &body["source"]["kafka"]["credentials"];
assert_eq!(creds["accessKeyId"], "AKIA000000000000FAKE");
assert_eq!(creds["secretKey"], "fake/secret/0000000000000000");
}
#[tokio::test]
async fn kafka_mutual_tls_credentials_use_cert_file_contents() {
use std::io::Write;
let mock = start_mock_clickpipes_api().await;
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("client.crt");
let key_path = dir.path().join("client.key");
let mut cert_file = std::fs::File::create(&cert_path).unwrap();
let mut key_file = std::fs::File::create(&key_path).unwrap();
cert_file
.write_all(b"-----BEGIN CERTIFICATE-----\nCERT_PEM\n-----END CERTIFICATE-----\n")
.unwrap();
key_file
.write_all(b"-----BEGIN PRIVATE KEY-----\nKEY_PEM\n-----END PRIVATE KEY-----\n")
.unwrap();
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"kafka",
"svc-id",
"--name",
"t",
"--brokers",
"broker:9092",
"--topics",
"topic",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--kafka-type",
"kafka",
"--auth",
"MUTUAL_TLS",
"--client-certificate",
cert_path.to_str().unwrap(),
"--client-key",
key_path.to_str().unwrap(),
"--org-id",
"org",
],
)
.await;
let creds = &body["source"]["kafka"]["credentials"];
assert!(
creds["certificate"]
.as_str()
.map(|s| s.contains("CERT_PEM"))
.unwrap_or(false),
"MUTUAL_TLS certificate should contain file contents: {creds}",
);
assert!(
creds["privateKey"]
.as_str()
.map(|s| s.contains("KEY_PEM"))
.unwrap_or(false),
"MUTUAL_TLS privateKey should contain file contents: {creds}",
);
}
#[tokio::test]
async fn kinesis_iam_role_omits_access_key() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"kinesis",
"svc-id",
"--name",
"t",
"--stream-name",
"s",
"--region",
"us-east-1",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--auth",
"IAM_ROLE",
"--iam-role",
"arn:aws:iam::123:role/x",
"--iterator-type",
"TRIM_HORIZON",
"--org-id",
"org",
],
)
.await;
let kinesis = &body["source"]["kinesis"];
assert_eq!(kinesis["iamRole"], "arn:aws:iam::123:role/x");
assert!(
kinesis.get("accessKey").is_none(),
"accessKey leaked when --auth IAM_ROLE: {kinesis}",
);
}
#[tokio::test]
async fn kinesis_iam_user_omits_iam_role() {
let mock = start_mock_clickpipes_api().await;
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"kinesis",
"svc-id",
"--name",
"t",
"--stream-name",
"s",
"--region",
"us-east-1",
"--format",
"JSONEachRow",
"--database",
"default",
"--table",
"events",
"--column",
"id:Int64",
"--auth",
"IAM_USER",
"--access-key-id",
"AKIA000000000000FAKE",
"--secret-key",
"fake/secret/0000000000000000",
"--iterator-type",
"TRIM_HORIZON",
"--org-id",
"org",
],
)
.await;
let kinesis = &body["source"]["kinesis"];
assert_eq!(kinesis["accessKey"]["accessKeyId"], "AKIA000000000000FAKE");
assert!(
kinesis.get("iamRole").is_none(),
"iamRole leaked when --auth IAM_USER: {kinesis}",
);
}
#[tokio::test]
async fn bigquery_destination_omits_table_columns_managed_table_definition() {
use std::io::Write;
let mock = start_mock_clickpipes_api().await;
let dir = tempfile::tempdir().unwrap();
let sa_path = dir.path().join("service-account.json");
let mut sa_file = std::fs::File::create(&sa_path).unwrap();
sa_file
.write_all(
br#"{
"type": "service_account",
"project_id": "test",
"private_key_id": "fake",
"private_key": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n",
"client_email": "fake@test.iam.gserviceaccount.com",
"client_id": "0",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}"#,
)
.unwrap();
let body = invoke_cli_capture_body(
&mock,
&[
"clickpipe",
"create",
"bigquery",
"svc-id",
"--name",
"t",
"--service-account-file",
sa_path.to_str().unwrap(),
"--staging-path",
"gs://bucket/staging",
"--table-mapping",
"dataset.t:t",
"--org-id",
"org",
],
)
.await;
let dest = &body["destination"];
assert_eq!(dest["database"], "default");
for field in ["table", "columns", "managedTable", "tableDefinition"] {
assert!(
dest.get(field).is_none(),
"{field} leaked into BigQuery destination body: {dest}",
);
}
}
fn postgres_args_minimal() -> Vec<String> {
[
"clickpipe",
"create",
"postgres",
"svc-id",
"--name",
"t",
"--host",
"pg",
"--port",
"5432",
"--pg-database",
"test",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"public.t:t",
"--replication-mode",
"cdc",
"--org-id",
"org",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[tokio::test]
async fn postgres_publication_name_serializes_when_provided() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
args.push("--publication-name".into());
args.push("my_pub".into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["settings"]["publicationName"], "my_pub",
"publicationName should round-trip the user-provided value"
);
}
#[tokio::test]
async fn postgres_replication_slot_name_serializes_when_provided() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
args.push("--replication-slot-name".into());
args.push("my_slot".into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["settings"]["replicationSlotName"], "my_slot",
"replicationSlotName should round-trip the user-provided value"
);
}
#[tokio::test]
async fn postgres_tls_host_serializes_when_provided() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
args.push("--tls-host".into());
args.push("pg.example.com".into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["tlsHost"], "pg.example.com",
"tlsHost should round-trip the user-provided value"
);
}
#[tokio::test]
async fn postgres_iam_role_serializes_when_provided() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
args.push("--iam-role".into());
args.push("arn:aws:iam::123:role/x".into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["iamRole"], "arn:aws:iam::123:role/x",
"iamRole should round-trip the user-provided value"
);
}
#[tokio::test]
async fn postgres_ca_certificate_file_contents_flow_to_body() {
use std::io::Write;
let mock = start_mock_clickpipes_api().await;
let dir = tempfile::tempdir().unwrap();
let ca_path = dir.path().join("ca.pem");
let pem = "-----BEGIN CERTIFICATE-----\nCA_PEM_CONTENT\n-----END CERTIFICATE-----\n";
std::fs::File::create(&ca_path)
.unwrap()
.write_all(pem.as_bytes())
.unwrap();
let mut args = postgres_args_minimal();
args.push("--ca-certificate".into());
args.push(ca_path.to_str().unwrap().to_string());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert!(
body["source"]["postgres"]["caCertificate"]
.as_str()
.map(|s| s.contains("CA_PEM_CONTENT"))
.unwrap_or(false),
"caCertificate body should contain the file's PEM content, got {}",
body["source"]["postgres"]["caCertificate"]
);
}
#[tokio::test]
async fn postgres_replication_mode_snapshot_serializes() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
let idx = args.iter().position(|a| a == "cdc").unwrap();
args[idx] = "snapshot".into();
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["settings"]["replicationMode"], "snapshot",
);
}
#[tokio::test]
async fn postgres_replication_mode_cdc_only_serializes() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
let idx = args.iter().position(|a| a == "cdc").unwrap();
args[idx] = "cdc_only".into();
args.push("--publication-name".into());
args.push("p".into());
args.push("--replication-slot-name".into());
args.push("s".into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["settings"]["replicationMode"], "cdc_only",
);
}
#[tokio::test]
async fn postgres_multiple_table_mappings_serialize_as_array() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
args.push("--table-mapping".into());
args.push("public.t2:t2_dst".into());
args.push("--table-mapping".into());
args.push("other_schema.t3:t3_dst".into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
let mappings = body["source"]["postgres"]["tableMappings"]
.as_array()
.unwrap_or_else(|| {
panic!(
"tableMappings should be an array, got: {}",
body["source"]["postgres"]["tableMappings"]
)
});
assert_eq!(
mappings.len(),
3,
"expected 3 table mappings (minimal default + 2 added), got {}: {:?}",
mappings.len(),
mappings
);
let target_tables: Vec<&str> = mappings
.iter()
.filter_map(|m| m["targetTable"].as_str())
.collect();
assert!(target_tables.contains(&"t"));
assert!(target_tables.contains(&"t2_dst"));
assert!(target_tables.contains(&"t3_dst"));
}
macro_rules! postgres_type_test {
($test_name:ident, $cli_value:literal, $wire_value:literal) => {
#[tokio::test]
async fn $test_name() {
let mock = start_mock_clickpipes_api().await;
let mut args = postgres_args_minimal();
args.push("--postgres-type".into());
args.push($cli_value.into());
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let body = invoke_cli_capture_body(&mock, &arg_refs).await;
assert_eq!(
body["source"]["postgres"]["type"], $wire_value,
"--postgres-type {} should serialize to wire value {}",
$cli_value, $wire_value,
);
}
};
}
postgres_type_test!(postgres_type_postgres_serializes, "postgres", "postgres");
postgres_type_test!(postgres_type_supabase_serializes, "supabase", "supabase");
postgres_type_test!(postgres_type_neon_serializes, "neon", "neon");
postgres_type_test!(postgres_type_alloydb_serializes, "alloydb", "alloydb");
postgres_type_test!(
postgres_type_planetscale_serializes,
"planetscale",
"planetscale"
);
postgres_type_test!(
postgres_type_rdspostgres_serializes,
"rdspostgres",
"rdspostgres"
);
postgres_type_test!(
postgres_type_aurorapostgres_serializes,
"aurorapostgres",
"aurorapostgres"
);
postgres_type_test!(
postgres_type_cloudsqlpostgres_serializes,
"cloudsqlpostgres",
"cloudsqlpostgres"
);
postgres_type_test!(
postgres_type_azurepostgres_serializes,
"azurepostgres",
"azurepostgres"
);
postgres_type_test!(
postgres_type_crunchybridge_serializes,
"crunchybridge",
"crunchybridge"
);
postgres_type_test!(postgres_type_tigerdata_serializes, "tigerdata", "tigerdata");
#[tokio::test]
async fn dotenv_creds_produce_basic_auth_request() {
use std::io::Write;
let mock = MockServer::start().await;
let stub_orgs = serde_json::json!({
"result": [],
"status": 200,
"requestId": "stub-org-list",
});
Mock::given(method("GET"))
.and(path("/v1/organizations"))
.respond_with(ResponseTemplate::new(200).set_body_json(stub_orgs))
.mount(&mock)
.await;
let dir = tempfile::tempdir().unwrap();
let mut env_file = std::fs::File::create(dir.path().join(".env")).unwrap();
env_file
.write_all(b"CLICKHOUSE_CLOUD_API_KEY=dotenv-key\nCLICKHOUSE_CLOUD_API_SECRET=dotenv-secret\n")
.unwrap();
drop(env_file);
let url = mock.uri();
let output = Command::new(clickhousectl_binary())
.args(["cloud", "--url", &url, "--json", "org", "list"])
.current_dir(dir.path())
.env_remove("CLICKHOUSE_CLOUD_API_KEY")
.env_remove("CLICKHOUSE_CLOUD_API_SECRET")
.output()
.expect("failed to spawn clickhousectl");
assert!(
output.status.success(),
"clickhousectl exited {}\nstderr:\n{}\nstdout:\n{}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
let requests = mock
.received_requests()
.await
.expect("mock requests log unavailable");
let auth = requests
.iter()
.find(|r| r.method == wiremock::http::Method::GET)
.and_then(|r| r.headers.get("Authorization"))
.expect("no Authorization header recorded");
let auth_str = auth.to_str().expect("non-utf8 auth header");
let expected = format!(
"Basic {}",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
"dotenv-key:dotenv-secret",
)
);
assert_eq!(
auth_str, expected,
"Authorization header should match the .env credentials exactly"
);
}
#[tokio::test]
async fn shell_env_overrides_dotenv_creds_in_request() {
use std::io::Write;
let mock = MockServer::start().await;
let stub_orgs = serde_json::json!({
"result": [],
"status": 200,
"requestId": "stub-org-list",
});
Mock::given(method("GET"))
.and(path("/v1/organizations"))
.respond_with(ResponseTemplate::new(200).set_body_json(stub_orgs))
.mount(&mock)
.await;
let dir = tempfile::tempdir().unwrap();
let mut env_file = std::fs::File::create(dir.path().join(".env")).unwrap();
env_file
.write_all(b"CLICKHOUSE_CLOUD_API_KEY=dotenv-key\nCLICKHOUSE_CLOUD_API_SECRET=dotenv-secret\n")
.unwrap();
drop(env_file);
let url = mock.uri();
let output = Command::new(clickhousectl_binary())
.args(["cloud", "--url", &url, "--json", "org", "list"])
.current_dir(dir.path())
.env("CLICKHOUSE_CLOUD_API_KEY", "shell-key")
.env("CLICKHOUSE_CLOUD_API_SECRET", "shell-secret")
.output()
.expect("failed to spawn clickhousectl");
assert!(output.status.success(), "binary failed: {}", String::from_utf8_lossy(&output.stderr));
let requests = mock.received_requests().await.unwrap();
let auth = requests
.iter()
.find(|r| r.method == wiremock::http::Method::GET)
.and_then(|r| r.headers.get("Authorization"))
.expect("no Authorization header recorded");
let auth_str = auth.to_str().expect("non-utf8 auth header");
let expected = format!(
"Basic {}",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
"shell-key:shell-secret",
)
);
assert_eq!(
auth_str, expected,
"shell env vars must override .env values on the wire"
);
}