mod common;
use common::pgwire_harness::TestServer;
async fn bootstrap_tenant_user(server: &TestServer, user: &str, collection: &str) {
server
.exec("CREATE TENANT acme ID 2")
.await
.expect("CREATE TENANT");
server
.exec(&format!(
"CREATE USER {user} WITH PASSWORD 'x' ROLE readwrite TENANT 2"
))
.await
.expect("CREATE USER");
let (svc, _h) = server
.connect_as(user, "x")
.await
.expect("tenant user connect");
svc.simple_query(&format!(
"CREATE COLLECTION {collection} \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')"
))
.await
.expect("tenant user CREATE COLLECTION");
drop(svc);
}
async fn query_as(server: &TestServer, user: &str, sql: &str) -> Result<Vec<String>, String> {
let (client, _h) = server.connect_as(user, "x").await?;
match client.simple_query(sql).await {
Ok(msgs) => {
let mut rows = Vec::new();
for msg in msgs {
if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
rows.push(row.get(0).unwrap_or("").to_string());
}
}
Ok(rows)
}
Err(e) => Err(pg_err(&e)),
}
}
fn pg_err(e: &tokio_postgres::Error) -> String {
if let Some(db) = e.as_db_error() {
format!("{}: {}", db.code().code(), db.message())
} else {
format!("{e:?}")
}
}
#[tokio::test]
async fn tenant_user_can_select_own_collection() {
let server = TestServer::start().await;
bootstrap_tenant_user(&server, "svc_sel", "t2_sel").await;
let (svc, _h) = server.connect_as("svc_sel", "x").await.unwrap();
svc.simple_query("INSERT INTO t2_sel (id, content) VALUES ('a', 'alpha')")
.await
.expect("INSERT under tenant user");
let rows = query_as(&server, "svc_sel", "SELECT id FROM t2_sel")
.await
.expect("SELECT under tenant user must not fail with 'unknown table'");
assert_eq!(rows.len(), 1, "expected 1 row, got {rows:?}");
assert_eq!(rows[0], "a", "row should contain id 'a': {rows:?}");
}
#[tokio::test]
async fn tenant_user_can_insert_into_own_collection() {
let server = TestServer::start().await;
bootstrap_tenant_user(&server, "svc_ins", "t2_ins").await;
let (svc, _h) = server.connect_as("svc_ins", "x").await.unwrap();
svc.simple_query("INSERT INTO t2_ins (id, content) VALUES ('k', 'v')")
.await
.expect("INSERT must succeed for tenant-owned collection");
}
#[tokio::test]
async fn tenant_user_can_update_own_collection() {
let server = TestServer::start().await;
bootstrap_tenant_user(&server, "svc_upd", "t2_upd").await;
let (svc, _h) = server.connect_as("svc_upd", "x").await.unwrap();
svc.simple_query("INSERT INTO t2_upd (id, content) VALUES ('a', 'old')")
.await
.unwrap();
svc.simple_query("UPDATE t2_upd SET content = 'new' WHERE id = 'a'")
.await
.expect("UPDATE must not fail with 'unknown table'");
let rows = query_as(
&server,
"svc_upd",
"SELECT content FROM t2_upd WHERE id = 'a'",
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0], "new",
"row should reflect updated content: {rows:?}"
);
}
#[tokio::test]
async fn tenant_user_can_delete_from_own_collection() {
let server = TestServer::start().await;
bootstrap_tenant_user(&server, "svc_del", "t2_del").await;
let (svc, _h) = server.connect_as("svc_del", "x").await.unwrap();
svc.simple_query("INSERT INTO t2_del (id, content) VALUES ('a', 'x')")
.await
.unwrap();
svc.simple_query("DELETE FROM t2_del WHERE id = 'a'")
.await
.expect("DELETE must not fail with 'unknown table'");
let rows = query_as(&server, "svc_del", "SELECT id FROM t2_del")
.await
.unwrap();
assert!(rows.is_empty(), "row should be deleted, got {rows:?}");
}
#[tokio::test]
async fn tenant_user_prepared_select_resolves_own_collection() {
let server = TestServer::start().await;
bootstrap_tenant_user(&server, "svc_prep", "t2_prep").await;
let (svc, _h) = server.connect_as("svc_prep", "x").await.unwrap();
svc.simple_query("INSERT INTO t2_prep (id, content) VALUES ('a', 'alpha')")
.await
.unwrap();
svc.prepare("SELECT content FROM t2_prep WHERE id = 'a'")
.await
.expect("prepare must resolve tenant-owned collection via parser.rs");
}
#[tokio::test]
async fn tenant_user_cannot_see_other_tenants_collection_as_empty() {
let server = TestServer::start().await;
server
.exec(
"CREATE COLLECTION t1_only \
(id TEXT PRIMARY KEY, secret TEXT NOT NULL) WITH (engine='document_strict')",
)
.await
.unwrap();
server
.exec("INSERT INTO t1_only (id, secret) VALUES ('a', 'classified')")
.await
.unwrap();
server.exec("CREATE TENANT acme ID 2").await.unwrap();
server
.exec("CREATE USER svc_xtn WITH PASSWORD 'x' ROLE readwrite TENANT 2")
.await
.unwrap();
let result = query_as(&server, "svc_xtn", "SELECT secret FROM t1_only").await;
match result {
Err(msg) => {
let lower = msg.to_lowercase();
assert!(
lower.contains("unknown table") || lower.contains("table not found"),
"expected isolation error, got: {msg}"
);
}
Ok(rows) => panic!(
"tenant-2 user must not see tenant-1 collection via silent empty result; got rows={rows:?}"
),
}
}
#[tokio::test]
async fn trust_mode_rejects_unknown_username() {
let server = TestServer::start().await;
let result = server
.connect_as("nosuchuser_ever_created", "anything")
.await;
assert!(
result.is_err(),
"Trust mode must reject a username that was never CREATE USER'd; got an accepted connection"
);
}
#[tokio::test]
async fn trust_mode_unknown_user_cannot_run_superuser_ddl() {
let server = TestServer::start().await;
let Ok((client, _h)) = server.connect_as("ghost_admin", "anything").await else {
return;
};
let result = client
.simple_query("CREATE USER mallory WITH PASSWORD 'y' ROLE readwrite TENANT 1")
.await;
assert!(
result.is_err(),
"unknown Trust-mode user must not be granted superuser privileges; CREATE USER succeeded"
);
if let Err(e) = result {
let msg = pg_err(&e);
assert!(
msg.contains("42501") || msg.to_lowercase().contains("permission"),
"expected a permission-denied error, got: {msg}"
);
}
}
async fn exec_as(server: &TestServer, user: &str, sql: &str) -> Result<(), String> {
let (client, _h) = server.connect_as(user, "x").await?;
client
.simple_query(sql)
.await
.map(|_| ())
.map_err(|e| pg_err(&e))
}
fn assert_rejected_with_any(result: Result<(), String>, codes: &[&str], context: &str) {
match result {
Ok(()) => panic!(
"{context}: spec requires rejection so silent identity misrouting is impossible, \
but the server returned success"
),
Err(msg) => {
let lower = msg.to_lowercase();
let code_hit = codes.iter().any(|c| msg.contains(c));
let wording_hit = lower.contains("not supported")
|| lower.contains("unrecognized")
|| lower.contains("unknown")
|| lower.contains("permission")
|| lower.contains("insufficient");
assert!(
code_hit || wording_hit,
"{context}: expected one of {codes:?} or an explanatory wording, got: {msg}"
);
}
}
}
#[tokio::test]
async fn set_tenant_as_superuser_switches_effective_tenant() {
let server = TestServer::start().await;
server.exec("CREATE TENANT acme ID 2").await.unwrap();
let (svc, _h) = server.connect_as("nodedb", "nodedb").await.unwrap();
svc.simple_query("SET TENANT = 'acme'")
.await
.expect("superuser SET TENANT must succeed");
svc.simple_query(
"CREATE COLLECTION switched_under_acme \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')",
)
.await
.expect("CREATE COLLECTION under switched tenant must succeed");
svc.simple_query("INSERT INTO switched_under_acme (id, content) VALUES ('a', 'x')")
.await
.expect("INSERT under switched tenant must succeed");
let msgs = svc.simple_query("SHOW TENANT").await.expect("SHOW TENANT");
let row = msgs
.iter()
.find_map(|m| {
if let tokio_postgres::SimpleQueryMessage::Row(r) = m {
Some(r)
} else {
None
}
})
.expect("SHOW TENANT must return a row");
assert_eq!(
row.get("tenant_id"),
Some("2"),
"SHOW TENANT must report the switched tenant id"
);
assert_eq!(
row.get("tenant_name"),
Some("acme"),
"SHOW TENANT must report the tenant name"
);
server
.exec(
"CREATE COLLECTION switched_under_acme \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')",
)
.await
.expect("same name in tenant 0 must succeed — proof switched session was tenant 2");
}
#[tokio::test]
async fn set_tenant_as_tenant_user_must_not_silently_noop() {
let server = TestServer::start().await;
bootstrap_tenant_user(&server, "svc_settnt", "t2_settnt").await;
server.exec("CREATE TENANT other ID 3").await.unwrap();
let res = exec_as(&server, "svc_settnt", "SET TENANT = 'other'").await;
assert_rejected_with_any(
res,
&["0A000", "42501"],
"SET TENANT as tenant-scoped user crossing tenants",
);
}
#[tokio::test]
async fn set_nodedb_tenant_id_switches_effective_tenant() {
let server = TestServer::start().await;
server.exec("CREATE TENANT acme ID 2").await.unwrap();
let (svc, _h) = server.connect_as("nodedb", "nodedb").await.unwrap();
svc.simple_query("SET nodedb.tenant_id = 2")
.await
.expect("SET nodedb.tenant_id must succeed for superuser");
svc.simple_query(
"CREATE COLLECTION numeric_alias_check \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')",
)
.await
.expect("CREATE under integer-alias switch must succeed");
server
.exec(
"CREATE COLLECTION numeric_alias_check \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')",
)
.await
.expect("same name under tenant 0 must succeed — proof switched session was tenant 2");
}
#[tokio::test]
async fn reset_tenant_restores_identity_bound_tenant() {
let server = TestServer::start().await;
server.exec("CREATE TENANT acme ID 2").await.unwrap();
let (svc, _h) = server.connect_as("nodedb", "nodedb").await.unwrap();
svc.simple_query("SET TENANT = 'acme'").await.unwrap();
svc.simple_query("RESET TENANT")
.await
.expect("RESET TENANT must succeed");
svc.simple_query(
"CREATE COLLECTION post_reset_check \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')",
)
.await
.expect("CREATE after RESET must land in tenant 0");
let res = server
.exec(
"CREATE COLLECTION post_reset_check \
(id TEXT PRIMARY KEY, content TEXT NOT NULL) WITH (engine='document_strict')",
)
.await;
assert!(
res.is_err(),
"duplicate name in tenant 0 must conflict — proves RESET TENANT actually restored"
);
}
#[tokio::test]
async fn set_tenant_default_clears_override() {
let server = TestServer::start().await;
server.exec("CREATE TENANT acme ID 2").await.unwrap();
let (svc, _h) = server.connect_as("nodedb", "nodedb").await.unwrap();
svc.simple_query("SET TENANT = 'acme'").await.unwrap();
svc.simple_query("SET TENANT = DEFAULT")
.await
.expect("SET TENANT = DEFAULT must succeed");
let msgs = svc.simple_query("SHOW TENANT").await.unwrap();
let row = msgs
.iter()
.find_map(|m| {
if let tokio_postgres::SimpleQueryMessage::Row(r) = m {
Some(r)
} else {
None
}
})
.expect("SHOW TENANT row");
assert_ne!(
row.get("tenant_id"),
Some("2"),
"after DEFAULT, SHOW TENANT must not still report tenant 2"
);
}
#[tokio::test]
async fn set_tenant_inside_transaction_is_rejected() {
let server = TestServer::start().await;
server.exec("CREATE TENANT acme ID 2").await.unwrap();
let (svc, _h) = server.connect_as("nodedb", "nodedb").await.unwrap();
svc.simple_query("BEGIN").await.unwrap();
let res = svc.simple_query("SET TENANT = 'acme'").await;
match res {
Err(e) => {
let msg = pg_err(&e);
assert!(
msg.contains("25001") || msg.to_lowercase().contains("transaction"),
"expected 25001 active_sql_transaction, got: {msg}"
);
}
Ok(_) => panic!(
"SET TENANT inside an active transaction must reject — \
tenant context cannot change while snapshot / locks are held"
),
}
let _ = svc.simple_query("ROLLBACK").await;
}
#[tokio::test]
async fn set_nodedb_tenant_id_non_integer_value_is_rejected() {
let server = TestServer::start().await;
let res = exec_as(&server, "nodedb", "SET nodedb.tenant_id = 'not-an-int'").await;
match res {
Err(msg) => assert!(
msg.contains("22023"),
"expected 22023 invalid_parameter_value, got: {msg}"
),
Ok(()) => panic!("non-integer nodedb.tenant_id must be rejected with 22023"),
}
}
#[tokio::test]
async fn set_role_must_not_silently_noop() {
let server = TestServer::start().await;
let res = exec_as(&server, "nodedb", "SET ROLE readonly").await;
assert_rejected_with_any(
res,
&["0A000"],
"SET ROLE (no runtime role-switch path is wired)",
);
}
#[tokio::test]
async fn set_session_authorization_must_not_silently_noop() {
let server = TestServer::start().await;
server
.exec("CREATE USER svc_other WITH PASSWORD 'x' ROLE readwrite TENANT 1")
.await
.unwrap();
let res = exec_as(&server, "nodedb", "SET SESSION AUTHORIZATION 'svc_other'").await;
assert_rejected_with_any(
res,
&["0A000"],
"SET SESSION AUTHORIZATION (no runtime identity-switch path is wired)",
);
}
#[tokio::test]
async fn set_unknown_runtime_parameter_is_rejected_like_show() {
let server = TestServer::start().await;
let res = exec_as(&server, "nodedb", "SET nodedb.no_such_knob = 'x'").await;
assert_rejected_with_any(res, &["42704"], "SET on an unknown runtime parameter");
}
#[tokio::test]
async fn show_tenants_includes_tenant_name() {
let server = TestServer::start().await;
server
.exec("CREATE TENANT acme ID 7")
.await
.expect("CREATE TENANT");
let rows = server
.query_named_rows("SHOW TENANTS")
.await
.expect("SHOW TENANTS");
let acme = rows
.iter()
.find(|r| r.get("tenant_id").map(String::as_str) == Some("7"))
.expect("tenant 7 present in SHOW TENANTS");
assert_eq!(
acme.get("name").map(String::as_str),
Some("acme"),
"SHOW TENANTS must report the tenant name; row was {acme:?}"
);
}