Files
rustfs/crates/targets/tests/postgres_integration.rs
houseme 73bde843d6 refactor(s3): consolidate semantic boundaries and remove s3-common (#3012)
* refactor(common): introduce rustfs-data-usage core crate

* refactor(concurrency): migrate workers crate into concurrency

* refactor(crypto): migrate appauth token APIs into crypto

* fix docs urls

* remove unused crate

* refactor(data-usage): switch consumers to rustfs-data-usage

* chore(fmt): apply cargo fmt and lockfile sync

* refactor(common): remove data_usage compatibility re-export

* refactor(capacity): move capacity_scope to object-capacity

* refactor(io-metrics): relocate internode metrics from common

* refactor(common): decouple scanner report from madmin

* chore(fmt): normalize import ordering after pre-commit

* refactor(s3): split s3 types and ops crates

* refactor(s3): centralize event version and safe parsing

* refactor(s3): add op-event compatibility guardrails

* refactor(s3): add runtime op-event mismatch observability

* refactor(s3): extract delete event mapping helper

* refactor(s3): extract put event mapping helper

* refactor(s3): consolidate remaining event semantic helpers

* refactor(s3): add op-event coverage checks and observability alerts

* refactor(s3-ops): consolidate op-event semantic mapping

* refactor(scanner): remove last_minute wrapper module

* refactor(scanner): consolidate duplicated data usage models
2026-05-19 12:50:25 +00:00

314 lines
11 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! PostgreSQL notification target integration tests.
//!
//! These tests require a running PostgreSQL server. They are `#[ignore]` by
//! default so CI never runs them. To run locally
//! (podman recommended; docker works too):
//!
//! ```bash
//! podman run -d --name rustfs-pg-test -p 5432:5432 \
//! -e POSTGRES_PASSWORD=rustfs -e POSTGRES_DB=rustfs_events \
//! docker.io/library/postgres:16
//! ```
//!
//! Wait for PostgreSQL to be ready (look for `database system is ready` in logs),
//! then set `RUSTFS_TEST_PG_DSN` and run:
//!
//! ```bash
//! export RUSTFS_TEST_PG_DSN="postgres://postgres:rustfs@localhost:5432/rustfs_events"
//! cargo test -p rustfs-targets --test postgres_integration -- --ignored
//! ```
//!
//! Clean up:
//!
//! ```bash
//! podman rm -f rustfs-pg-test
//! ```
use rustfs_s3_types::EventName;
use rustfs_targets::Target;
use rustfs_targets::check_postgres_server_available;
use rustfs_targets::target::EntityTarget;
use rustfs_targets::target::TargetType;
use rustfs_targets::target::postgres::{PostgresArgs, PostgresDsn, PostgresFormat, PostgresTarget};
use serde_json::Value;
use std::sync::Arc;
use tokio_postgres::NoTls;
use url::Url;
use uuid::Uuid;
fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn test_args(table: &str, format: PostgresFormat) -> PostgresArgs {
let dsn = env_or("RUSTFS_TEST_PG_DSN", "postgres://postgres:rustfs@localhost:5432/rustfs_events");
let schema = PostgresDsn::parse(&dsn)
.expect("RUSTFS_TEST_PG_DSN must be a valid PostgreSQL DSN")
.schema;
PostgresArgs {
enable: true,
dsn_string: dsn,
schema,
table: table.to_string(),
format,
tls_required: false,
tls_ca: String::new(),
tls_client_cert: String::new(),
tls_client_key: String::new(),
queue_dir: String::new(),
queue_limit: 100_000,
target_type: TargetType::NotifyEvent,
}
}
fn with_search_path(dsn: &str, schema: &str) -> String {
let mut url = Url::parse(dsn).expect("RUSTFS_TEST_PG_DSN must be a valid PostgreSQL DSN URL");
url.query_pairs_mut().clear().append_pair("search_path", schema);
url.to_string()
}
async fn raw_client(args: &PostgresArgs) -> tokio_postgres::Client {
let (client, connection) = tokio_postgres::connect(&args.dsn_string, NoTls)
.await
.expect("connect to postgres test server");
tokio::spawn(async move {
let _ = connection.await;
});
client
}
fn unique_table(prefix: &str) -> String {
let suffix = Uuid::new_v4().simple().to_string();
format!("{prefix}_{}", &suffix[..16])
}
fn entity_for(bucket: &str, object: &str) -> Arc<EntityTarget<serde_json::Value>> {
Arc::new(EntityTarget {
bucket_name: bucket.to_string(),
object_name: object.to_string(),
event_name: EventName::ObjectCreatedPut,
data: serde_json::json!({"bucket": bucket, "object": object}),
})
}
#[tokio::test]
#[ignore = "requires running PostgreSQL server"]
async fn test_check_postgres_server_available_with_existing_table() {
let args = test_args("pg_class", PostgresFormat::Namespace);
// Use a real existing table: pg_class always exists.
let mut args = args;
args.dsn_string = with_search_path(&args.dsn_string, "pg_catalog");
args.schema = "pg_catalog".to_string();
args.table = "pg_class".to_string();
check_postgres_server_available(&args)
.await
.expect("connectivity probe should succeed against pg_catalog.pg_class");
}
#[tokio::test]
#[ignore = "requires running PostgreSQL server"]
async fn test_check_postgres_server_available_missing_table_fails() {
let args = test_args("does_not_exist_table_xyz", PostgresFormat::Namespace);
let result = check_postgres_server_available(&args).await;
assert!(result.is_err(), "missing table should fail the probe");
}
#[tokio::test]
#[ignore = "requires running PostgreSQL server"]
async fn test_namespace_format_upsert_replaces_value() {
let table = unique_table("rustfs_test_namespace");
let args = test_args(&table, PostgresFormat::Namespace);
// Setup: create the namespace table.
let setup = raw_client(&args).await;
setup
.execute(
&format!("CREATE TABLE \"{}\" (key VARCHAR PRIMARY KEY, value JSONB NOT NULL)", table),
&[],
)
.await
.expect("create namespace table");
// Build target and deliver two events for the same key.
let target: PostgresTarget<serde_json::Value> =
PostgresTarget::new("test_namespace".to_string(), args.clone()).expect("construct target");
target
.save(entity_for("bucket1", "obj-A"))
.await
.expect("first save should succeed");
target
.save(entity_for("bucket1", "obj-A"))
.await
.expect("second save should succeed");
// Verify only one row exists for the key (UPSERT collapsed).
let row = setup
.query_one(&format!("SELECT count(*)::bigint FROM \"{}\" WHERE key = $1", table), &[&"bucket1/obj-A"])
.await
.expect("count rows");
let count: i64 = row.get(0);
assert_eq!(count, 1, "namespace format should keep only one row per key");
// Cleanup.
setup
.execute(&format!("DROP TABLE \"{}\"", table), &[])
.await
.expect("drop namespace table");
}
#[tokio::test]
#[ignore = "requires running PostgreSQL server"]
async fn test_access_format_appends_distinct_events() {
let table = unique_table("rustfs_test_access");
let args = test_args(&table, PostgresFormat::Access);
let setup = raw_client(&args).await;
setup
.execute(
&format!(
"CREATE TABLE \"{}\" (\
event_id TEXT PRIMARY KEY, \
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
event_name TEXT NOT NULL, \
key TEXT NOT NULL, \
value JSONB NOT NULL, \
queued_at_ms BIGINT NOT NULL\
)",
table
),
&[],
)
.await
.expect("create access table");
let target: PostgresTarget<serde_json::Value> =
PostgresTarget::new("test_access".to_string(), args.clone()).expect("construct target");
// Two distinct events for different objects produce two rows.
target.save(entity_for("bucket1", "obj-A")).await.expect("save A");
target.save(entity_for("bucket1", "obj-B")).await.expect("save B");
let row = setup
.query_one(&format!("SELECT count(*)::bigint FROM \"{}\"", table), &[])
.await
.expect("count rows");
let count: i64 = row.get(0);
assert_eq!(count, 2, "access format should append two distinct rows");
setup
.execute(&format!("DROP TABLE \"{}\"", table), &[])
.await
.expect("drop access table");
}
#[tokio::test]
#[ignore = "requires running PostgreSQL server"]
async fn test_access_format_replay_is_idempotent() {
let table = unique_table("rustfs_test_access_replay");
let args = test_args(&table, PostgresFormat::Access);
let setup = raw_client(&args).await;
setup
.execute(
&format!(
"CREATE TABLE \"{}\" (\
event_id TEXT PRIMARY KEY, \
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
event_name TEXT NOT NULL, \
key TEXT NOT NULL, \
value JSONB NOT NULL, \
queued_at_ms BIGINT NOT NULL\
)",
table
),
&[],
)
.await
.expect("create access table");
// Insert the same row twice with the same event_id via direct SQL — this
// simulates store replay where send_raw_from_store is called twice with
// the same Key.
let event_id = Uuid::new_v4().to_string();
let payload: Value = serde_json::json!({"EventName": "s3:ObjectCreated:Put", "Key": "bucket1/obj-A", "Records": []});
let queued_at_ms: i64 = 1234567890;
let sql = format!(
"INSERT INTO \"{}\" (event_id, event_name, key, value, queued_at_ms) \
VALUES ($1, $2, $3, $4::jsonb, $5) ON CONFLICT (event_id) DO NOTHING",
table
);
setup
.execute(&sql, &[&event_id, &"s3:ObjectCreated:Put", &"bucket1/obj-A", &payload, &queued_at_ms])
.await
.expect("first insert");
setup
.execute(&sql, &[&event_id, &"s3:ObjectCreated:Put", &"bucket1/obj-A", &payload, &queued_at_ms])
.await
.expect("second insert (should be silent skip)");
let row = setup
.query_one(&format!("SELECT count(*)::bigint FROM \"{}\"", table), &[])
.await
.expect("count rows");
let count: i64 = row.get(0);
assert_eq!(count, 1, "ON CONFLICT (event_id) DO NOTHING should make replay idempotent");
setup
.execute(&format!("DROP TABLE \"{}\"", table), &[])
.await
.expect("drop access table");
}
#[tokio::test]
#[ignore = "requires running PostgreSQL server"]
async fn test_init_succeeds_against_existing_table() {
let table = unique_table("rustfs_test_init");
let args = test_args(&table, PostgresFormat::Namespace);
let setup = raw_client(&args).await;
setup
.execute(
&format!("CREATE TABLE \"{}\" (key VARCHAR PRIMARY KEY, value JSONB NOT NULL)", table),
&[],
)
.await
.expect("create table");
let target: PostgresTarget<serde_json::Value> =
PostgresTarget::new("test_init".to_string(), args.clone()).expect("construct target");
target.init().await.expect("init should succeed against existing table");
target.close().await.expect("close should succeed");
setup
.execute(&format!("DROP TABLE \"{}\"", table), &[])
.await
.expect("drop table");
}
#[tokio::test]
async fn test_invalid_identifier_rejected_at_construction() {
// No #[ignore] — pure validation, no DB needed.
let args = test_args("malicious; DROP TABLE users", PostgresFormat::Namespace);
match PostgresTarget::<serde_json::Value>::new("bad_id".to_string(), args) {
Ok(_) => panic!("malicious table identifier must fail at construction"),
Err(e) => assert!(e.to_string().contains("table"), "unexpected error: {e}"),
}
}