mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
953 lines
42 KiB
Rust
953 lines
42 KiB
Rust
//! PostgreSQL authority store. Mutations use row locks and compare-and-swap
|
|
//! predicates so counters, epochs, and generation tombstones only move forward.
|
|
use crate::authority::*;
|
|
use crate::model::AppProfile;
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::{AssertSqlSafe, PgPool, Row};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Clone)]
|
|
pub struct PostgresAuthorityStore {
|
|
pool: PgPool,
|
|
}
|
|
impl PostgresAuthorityStore {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
|
|
pub async fn apply_migrations_and_grants(
|
|
pool: &PgPool,
|
|
runtime_role: &str,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
sqlx::migrate!("./migrations").run(pool).await?;
|
|
if runtime_role.is_empty()
|
|
|| runtime_role.len() > 63
|
|
|| !runtime_role
|
|
.bytes()
|
|
.enumerate()
|
|
.all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()))
|
|
{
|
|
return Err("runtime database role must be a PostgreSQL identifier".into());
|
|
}
|
|
let database: String = sqlx::query_scalar("SELECT current_database()")
|
|
.fetch_one(pool)
|
|
.await?;
|
|
let quote_ident = |value: &str| format!("\"{}\"", value.replace('"', "\"\""));
|
|
let role = quote_ident(runtime_role);
|
|
let database = quote_ident(&database);
|
|
let grants = format!(
|
|
"REVOKE CREATE ON DATABASE {database} FROM {role};
|
|
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
|
REVOKE CREATE ON SCHEMA public FROM {role};
|
|
GRANT CONNECT ON DATABASE {database} TO {role};
|
|
GRANT USAGE ON SCHEMA public TO {role};
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
|
|
push_gateway_challenges,
|
|
push_gateway_installations,
|
|
push_gateway_delegations,
|
|
push_gateway_endpoint_quotas,
|
|
push_gateway_delivery_auth_replays,
|
|
push_gateway_delivery_request_replays
|
|
TO {role};"
|
|
);
|
|
sqlx::raw_sql(AssertSqlSafe(grants)).execute(pool).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
fn at(ts: i64) -> Result<DateTime<Utc>, AuthorityError> {
|
|
DateTime::from_timestamp(ts, 0).ok_or(AuthorityError::Rejected)
|
|
}
|
|
fn ts(v: DateTime<Utc>) -> i64 {
|
|
v.timestamp()
|
|
}
|
|
fn profile(v: &str) -> Result<AppProfile, AuthorityError> {
|
|
match v {
|
|
"buzz-ios-production" => Ok(AppProfile::BuzzIosProduction),
|
|
"buzz-ios-sandbox" => Ok(AppProfile::BuzzIosSandbox),
|
|
_ => Err(AuthorityError::Unavailable),
|
|
}
|
|
}
|
|
fn db(_: sqlx::Error) -> AuthorityError {
|
|
AuthorityError::Unavailable
|
|
}
|
|
fn bytes32(v: Vec<u8>) -> Result<[u8; 32], AuthorityError> {
|
|
v.try_into().map_err(|_| AuthorityError::Unavailable)
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AuthorityStore for PostgresAuthorityStore {
|
|
async fn ready(&self) -> Result<(), AuthorityError> {
|
|
const TABLES: [&str; 6] = [
|
|
"push_gateway_challenges",
|
|
"push_gateway_installations",
|
|
"push_gateway_delegations",
|
|
"push_gateway_endpoint_quotas",
|
|
"push_gateway_delivery_auth_replays",
|
|
"push_gateway_delivery_request_replays",
|
|
];
|
|
let mut tx = self.pool.begin().await.map_err(db)?;
|
|
for table in TABLES {
|
|
let ready: bool = sqlx::query_scalar(
|
|
"SELECT to_regclass($1) IS NOT NULL
|
|
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'SELECT'), false)
|
|
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'INSERT'), false)
|
|
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'UPDATE'), false)
|
|
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'DELETE'), false)",
|
|
)
|
|
.bind(format!("public.{table}"))
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
if !ready {
|
|
return Err(AuthorityError::Unavailable);
|
|
}
|
|
}
|
|
let least_privilege: bool = sqlx::query_scalar(
|
|
"SELECT has_database_privilege(current_user, current_database(), 'CONNECT')
|
|
AND NOT has_database_privilege(current_user, current_database(), 'CREATE')
|
|
AND NOT has_schema_privilege(current_user, 'public', 'CREATE')",
|
|
)
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
if !least_privilege {
|
|
return Err(AuthorityError::Unavailable);
|
|
}
|
|
tx.rollback().await.map_err(db)
|
|
}
|
|
|
|
async fn put_challenge(&self, c: Challenge) -> Result<(), AuthorityError> {
|
|
use sha2::{Digest, Sha256};
|
|
sqlx::query(
|
|
"INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at) VALUES($1,$2,$3)",
|
|
)
|
|
.bind(c.id)
|
|
.bind(Sha256::digest(c.value).to_vec())
|
|
.bind(at(c.expires_at)?)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
Ok(())
|
|
}
|
|
async fn consume_challenge(
|
|
&self,
|
|
id: Uuid,
|
|
value: [u8; 32],
|
|
now: i64,
|
|
) -> Result<(), AuthorityError> {
|
|
use sha2::{Digest, Sha256};
|
|
let result = sqlx::query("DELETE FROM push_gateway_challenges WHERE id=$1 AND challenge_hash=$2 AND expires_at >= $3")
|
|
.bind(id).bind(Sha256::digest(value).to_vec()).bind(at(now)?).execute(&self.pool).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> {
|
|
let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING")
|
|
.bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&self.pool).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn installation(&self, id: Uuid, now: i64) -> Result<Installation, AuthorityError> {
|
|
let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE id=$1 AND revoked_at IS NULL AND expires_at >= $2")
|
|
.bind(id).bind(at(now)?).fetch_optional(&self.pool).await.map_err(db)?.ok_or(AuthorityError::Rejected)?;
|
|
Ok(Installation {
|
|
id,
|
|
app_attest_key_id: r.try_get("app_attest_key_id").map_err(db)?,
|
|
app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?,
|
|
assertion_counter: u32::try_from(r.try_get::<i64, _>("assertion_counter").map_err(db)?)
|
|
.map_err(|_| AuthorityError::Unavailable)?,
|
|
profile: profile(r.try_get("app_profile").map_err(db)?)?,
|
|
token_ciphertext: r.try_get("token_ciphertext").map_err(db)?,
|
|
token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?,
|
|
endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?,
|
|
expires_at: ts(r.try_get("expires_at").map_err(db)?),
|
|
revoked: false,
|
|
})
|
|
}
|
|
async fn advance_assertion_counter(
|
|
&self,
|
|
id: Uuid,
|
|
previous: u32,
|
|
next: u32,
|
|
) -> Result<(), AuthorityError> {
|
|
if next <= previous {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2 AND revoked_at IS NULL")
|
|
.bind(id).bind(i64::from(previous)).bind(i64::from(next)).execute(&self.pool).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> {
|
|
let mut tx = self.pool.begin().await.map_err(db)?;
|
|
let i=sqlx::query("SELECT endpoint_epoch,expires_at,revoked_at FROM push_gateway_installations WHERE id=$1 FOR UPDATE").bind(d.installation_id).fetch_optional(&mut *tx).await.map_err(db)?.ok_or(AuthorityError::Rejected)?;
|
|
if i.try_get::<Option<DateTime<Utc>>, _>("revoked_at")
|
|
.map_err(db)?
|
|
.is_some()
|
|
|| i.try_get::<i64, _>("endpoint_epoch").map_err(db)? != d.endpoint_epoch
|
|
|| at(d.expires_at)? > i.try_get::<DateTime<Utc>, _>("expires_at").map_err(db)?
|
|
{
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let relay = hex::decode(&d.relay_pubkey).map_err(|_| AuthorityError::Rejected)?;
|
|
let result=sqlx::query("INSERT INTO push_gateway_delegations(id,installation_id,relay_pubkey,endpoint_epoch,generation,not_before,expires_at,revoked_at) VALUES($1,$2,$3,$4,$5,$6,$7,NULL) ON CONFLICT(installation_id,relay_pubkey) DO UPDATE SET id=EXCLUDED.id,endpoint_epoch=EXCLUDED.endpoint_epoch,generation=EXCLUDED.generation,not_before=EXCLUDED.not_before,expires_at=EXCLUDED.expires_at,revoked_at=NULL,updated_at=now() WHERE EXCLUDED.generation > push_gateway_delegations.generation")
|
|
.bind(d.id).bind(d.installation_id).bind(relay).bind(d.endpoint_epoch).bind(d.generation).bind(at(d.not_before)?).bind(at(d.expires_at)?).execute(&mut *tx).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
tx.commit().await.map_err(db)?;
|
|
Ok(())
|
|
}
|
|
async fn rotate_endpoint(
|
|
&self,
|
|
id: Uuid,
|
|
expected: i64,
|
|
new: i64,
|
|
ciphertext: Vec<u8>,
|
|
fingerprint: [u8; 32],
|
|
) -> Result<(), AuthorityError> {
|
|
if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,token_ciphertext=$4,token_fingerprint=$5,updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).bind(ciphertext).bind(fingerprint.to_vec()).execute(&self.pool).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn revoke_delegation(
|
|
&self,
|
|
id: Uuid,
|
|
relay: &str,
|
|
generation: i64,
|
|
) -> Result<(), AuthorityError> {
|
|
let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?;
|
|
let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn revoke_installation(
|
|
&self,
|
|
id: Uuid,
|
|
expected: i64,
|
|
new: i64,
|
|
) -> Result<(), AuthorityError> {
|
|
if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,revoked_at=now(),updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).execute(&self.pool).await.map_err(db)?;
|
|
if result.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn authorize_delivery(
|
|
&self,
|
|
did: Uuid,
|
|
relay: &str,
|
|
epoch: i64,
|
|
generation: i64,
|
|
event_id: &str,
|
|
request_id: Uuid,
|
|
request_expires_at: i64,
|
|
quota_window_seconds: i64,
|
|
quota_max_deliveries: i64,
|
|
now: i64,
|
|
) -> Result<DeliveryPermit, AuthorityError> {
|
|
let relay_bytes = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?;
|
|
let event_bytes = hex::decode(event_id).map_err(|_| AuthorityError::Rejected)?;
|
|
if event_bytes.len() != 32 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let mut tx = self.pool.begin().await.map_err(db)?;
|
|
// Every authority mutation locks installation before delegation. Keep
|
|
// this order here to avoid delivery-vs-refresh deadlocks.
|
|
let i = sqlx::query(
|
|
"SELECT app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at,revoked_at
|
|
FROM push_gateway_installations
|
|
WHERE id=(SELECT installation_id FROM push_gateway_delegations WHERE id=$1)
|
|
FOR UPDATE",
|
|
)
|
|
.bind(did)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(db)?
|
|
.ok_or(AuthorityError::Rejected)?;
|
|
if i.try_get::<Option<DateTime<Utc>>, _>("revoked_at")
|
|
.map_err(db)?
|
|
.is_some()
|
|
|| i.try_get::<i64, _>("endpoint_epoch").map_err(db)? != epoch
|
|
|| i.try_get::<DateTime<Utc>, _>("expires_at").map_err(db)? < at(now)?
|
|
{
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let d = sqlx::query(
|
|
"SELECT installation_id,expires_at FROM push_gateway_delegations
|
|
WHERE id=$1 AND relay_pubkey=$2 AND endpoint_epoch=$3 AND generation=$4
|
|
AND revoked_at IS NULL AND not_before<=$5 AND expires_at>=$5
|
|
FOR UPDATE",
|
|
)
|
|
.bind(did)
|
|
.bind(&relay_bytes)
|
|
.bind(epoch)
|
|
.bind(generation)
|
|
.bind(at(now)?)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(db)?
|
|
.ok_or(AuthorityError::Rejected)?;
|
|
let installation_id: Uuid = d.try_get("installation_id").map_err(db)?;
|
|
let authority = DeliveryAuthority {
|
|
delegation_id: did,
|
|
installation_id,
|
|
relay_pubkey: relay.to_owned(),
|
|
profile: profile(i.try_get("app_profile").map_err(db)?)?,
|
|
token_ciphertext: i.try_get("token_ciphertext").map_err(db)?,
|
|
endpoint_epoch: epoch,
|
|
generation,
|
|
expires_at: ts(d.try_get("expires_at").map_err(db)?),
|
|
};
|
|
if request_expires_at < now || request_expires_at > authority.expires_at {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
if quota_window_seconds < 1 || quota_max_deliveries < 1 {
|
|
return Err(AuthorityError::Unavailable);
|
|
}
|
|
let fingerprint: Vec<u8> = i.try_get("token_fingerprint").map_err(db)?;
|
|
let quota = sqlx::query("INSERT INTO push_gateway_endpoint_quotas(token_fingerprint,window_started_at,admitted) VALUES($1,$2,1) ON CONFLICT(token_fingerprint) DO UPDATE SET window_started_at=CASE WHEN push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) THEN $2 ELSE push_gateway_endpoint_quotas.window_started_at END, admitted=CASE WHEN push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) THEN 1 ELSE push_gateway_endpoint_quotas.admitted + 1 END, updated_at=now() WHERE push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) OR push_gateway_endpoint_quotas.admitted < $4")
|
|
.bind(fingerprint).bind(at(now)?).bind(quota_window_seconds).bind(quota_max_deliveries).execute(&mut *tx).await.map_err(db)?;
|
|
if quota.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
let auth_inserted = sqlx::query("INSERT INTO push_gateway_delivery_auth_replays(relay_pubkey,auth_event_id,expires_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING")
|
|
.bind(&relay_bytes).bind(event_bytes).bind(at(request_expires_at)?).execute(&mut *tx).await.map_err(db)?;
|
|
let request_inserted = sqlx::query("INSERT INTO push_gateway_delivery_request_replays(relay_pubkey,request_id,expires_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING")
|
|
.bind(&relay_bytes).bind(request_id).bind(at(request_expires_at)?).execute(&mut *tx).await.map_err(db)?;
|
|
if auth_inserted.rows_affected() != 1 || request_inserted.rows_affected() != 1 {
|
|
return Err(AuthorityError::Rejected);
|
|
}
|
|
tx.commit().await.map_err(db)?;
|
|
Ok(DeliveryPermit::new(authority, relay.to_owned(), request_id))
|
|
}
|
|
|
|
async fn finish_delivery(
|
|
&self,
|
|
permit: DeliveryPermit,
|
|
disposition: DeliveryDisposition,
|
|
) -> Result<(), AuthorityError> {
|
|
if disposition == DeliveryDisposition::Retryable {
|
|
sqlx::query("DELETE FROM push_gateway_delivery_request_replays WHERE relay_pubkey=$1 AND request_id=$2")
|
|
.bind(hex::decode(permit.relay_pubkey).map_err(|_| AuthorityError::Unavailable)?)
|
|
.bind(permit.request_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError> {
|
|
let mut tx = self.pool.begin().await.map_err(db)?;
|
|
sqlx::query("DELETE FROM push_gateway_challenges WHERE expires_at < $1")
|
|
.bind(at(now)?)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
sqlx::query("DELETE FROM push_gateway_delivery_auth_replays WHERE expires_at < $1")
|
|
.bind(at(now)?)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
sqlx::query("DELETE FROM push_gateway_delivery_request_replays WHERE expires_at < $1")
|
|
.bind(at(now)?)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
sqlx::query(
|
|
"DELETE FROM push_gateway_endpoint_quotas WHERE updated_at < $1 - interval '1 day'",
|
|
)
|
|
.bind(at(now)?)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
// A parent may become retention-eligible before an otherwise-active
|
|
// child. Parent eligibility must therefore reap every child first;
|
|
// otherwise the installation delete violates the delegation FK and
|
|
// rolls back all cleanup in this transaction.
|
|
sqlx::query(
|
|
"DELETE FROM push_gateway_delegations d
|
|
WHERE d.expires_at < $1
|
|
OR d.revoked_at < $1 - interval '1 day'
|
|
OR EXISTS (
|
|
SELECT 1 FROM push_gateway_installations i
|
|
WHERE i.id = d.installation_id
|
|
AND (i.expires_at < $1 OR i.revoked_at < $1 - interval '1 day')
|
|
)",
|
|
)
|
|
.bind(at(now)?)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(db)?;
|
|
sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1 OR revoked_at < $1 - interval '1 day'")
|
|
.bind(at(now)?).execute(&mut *tx).await.map_err(db)?;
|
|
tx.commit().await.map_err(db)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use sqlx::{postgres::PgPoolOptions, AssertSqlSafe};
|
|
|
|
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"]
|
|
async fn readiness_requires_migrated_schema_dml_and_no_ddl() {
|
|
let admin_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
let admin = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(&admin_url)
|
|
.await
|
|
.expect("connect as PostgreSQL test administrator");
|
|
let suffix = Uuid::new_v4().simple().to_string();
|
|
let database = format!("push_ready_{suffix}");
|
|
let runtime_role = format!("push_runtime_{suffix}");
|
|
sqlx::query(AssertSqlSafe(format!(
|
|
"CREATE ROLE {runtime_role} LOGIN PASSWORD 'runtime_test'"
|
|
)))
|
|
.execute(&admin)
|
|
.await
|
|
.expect("create runtime role");
|
|
sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {database}")))
|
|
.execute(&admin)
|
|
.await
|
|
.expect("create dedicated gateway database");
|
|
|
|
let mut admin_database_url = url::Url::parse(&admin_url).expect("parse PostgreSQL URL");
|
|
admin_database_url.set_path(&database);
|
|
let mut runtime_database_url = admin_database_url.clone();
|
|
runtime_database_url
|
|
.set_username(&runtime_role)
|
|
.expect("set runtime username");
|
|
runtime_database_url
|
|
.set_password(Some("runtime_test"))
|
|
.expect("set runtime password");
|
|
let runtime_pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(runtime_database_url.as_str())
|
|
.await
|
|
.expect("connect as runtime role");
|
|
let runtime = PostgresAuthorityStore::new(runtime_pool.clone());
|
|
assert!(
|
|
runtime.ready().await.is_err(),
|
|
"empty database is not ready"
|
|
);
|
|
|
|
let migration_pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(admin_database_url.as_str())
|
|
.await
|
|
.expect("connect migration role to dedicated database");
|
|
PostgresAuthorityStore::apply_migrations_and_grants(&migration_pool, &runtime_role)
|
|
.await
|
|
.expect("migrate and grant runtime role");
|
|
assert!(
|
|
runtime.ready().await.is_ok(),
|
|
"migrated least-privilege runtime is ready"
|
|
);
|
|
assert!(
|
|
sqlx::query("CREATE TABLE forbidden_runtime_ddl(id INT)")
|
|
.execute(&runtime_pool)
|
|
.await
|
|
.is_err(),
|
|
"runtime role cannot create tables"
|
|
);
|
|
|
|
sqlx::query(AssertSqlSafe(format!(
|
|
"REVOKE DELETE ON push_gateway_installations FROM {runtime_role}"
|
|
)))
|
|
.execute(&migration_pool)
|
|
.await
|
|
.expect("remove one required DML privilege");
|
|
assert!(
|
|
runtime.ready().await.is_err(),
|
|
"missing DML privilege is not ready"
|
|
);
|
|
|
|
runtime_pool.close().await;
|
|
migration_pool.close().await;
|
|
sqlx::query(AssertSqlSafe(format!("DROP DATABASE {database}")))
|
|
.execute(&admin)
|
|
.await
|
|
.expect("drop test database");
|
|
sqlx::query(AssertSqlSafe(format!("DROP ROLE {runtime_role}")))
|
|
.execute(&admin)
|
|
.await
|
|
.expect("drop test role");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL"]
|
|
async fn reaper_deletes_active_child_of_retention_eligible_revoked_installation() {
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(&database_url)
|
|
.await
|
|
.expect("connect to PostgreSQL test database");
|
|
let schema = format!("push_reaper_{}", Uuid::new_v4().simple());
|
|
sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("create isolated test schema");
|
|
sqlx::query(AssertSqlSafe(format!("SET search_path TO {schema}")))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("select isolated test schema");
|
|
sqlx::raw_sql(
|
|
"CREATE TABLE push_gateway_challenges (expires_at TIMESTAMPTZ NOT NULL);
|
|
CREATE TABLE push_gateway_delivery_auth_replays (expires_at TIMESTAMPTZ NOT NULL);
|
|
CREATE TABLE push_gateway_delivery_request_replays (expires_at TIMESTAMPTZ NOT NULL);
|
|
CREATE TABLE push_gateway_endpoint_quotas (updated_at TIMESTAMPTZ NOT NULL);
|
|
CREATE TABLE push_gateway_installations (
|
|
id UUID PRIMARY KEY,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ
|
|
);
|
|
CREATE TABLE push_gateway_delegations (
|
|
id UUID PRIMARY KEY,
|
|
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ
|
|
);",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("create authority retention tables");
|
|
|
|
let now = Utc::now();
|
|
let installation_id = Uuid::new_v4();
|
|
sqlx::query(
|
|
"INSERT INTO push_gateway_installations(id, expires_at, revoked_at)
|
|
VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(installation_id)
|
|
.bind(now + chrono::Duration::days(30))
|
|
.bind(now - chrono::Duration::days(2))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert retention-eligible revoked installation");
|
|
sqlx::query(
|
|
"INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at)
|
|
VALUES ($1, $2, $3, NULL)",
|
|
)
|
|
.bind(Uuid::new_v4())
|
|
.bind(installation_id)
|
|
.bind(now + chrono::Duration::days(7))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert active future-expiring child delegation");
|
|
|
|
PostgresAuthorityStore::new(pool.clone())
|
|
.reap_expired(now.timestamp())
|
|
.await
|
|
.expect("reaper must delete the child before its revoked parent");
|
|
let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count delegations");
|
|
let installations: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count installations");
|
|
assert_eq!(delegations, 0);
|
|
assert_eq!(installations, 0);
|
|
|
|
sqlx::query("SET search_path TO public")
|
|
.execute(&pool)
|
|
.await
|
|
.expect("restore public schema");
|
|
sqlx::query(AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("drop isolated test schema");
|
|
}
|
|
|
|
// Full authority schema in a private search_path so a multi-connection pool
|
|
// exercises the real PK/UNIQUE replay fences that the memory store's single
|
|
// mutex cannot. Returns (pool, schema) for teardown.
|
|
async fn full_schema(max_connections: u32) -> (PgPool, String) {
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
let schema = format!("push_admit_{}", Uuid::new_v4().simple());
|
|
let bootstrap = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(&database_url)
|
|
.await
|
|
.expect("connect to PostgreSQL test database");
|
|
sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
|
|
.execute(&bootstrap)
|
|
.await
|
|
.expect("create isolated test schema");
|
|
bootstrap.close().await;
|
|
// search_path is per-session, so pin it on every pooled connection.
|
|
let set_path = format!("SET search_path TO {schema}");
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(max_connections)
|
|
.after_connect(move |conn, _| {
|
|
let set_path = set_path.clone();
|
|
Box::pin(async move {
|
|
sqlx::query(AssertSqlSafe(set_path)).execute(conn).await?;
|
|
Ok(())
|
|
})
|
|
})
|
|
.connect(&database_url)
|
|
.await
|
|
.expect("connect isolated-schema pool");
|
|
// Real DDL from migration 0010 (minus the _operator_global_tables audit
|
|
// insert, which lives outside the isolated schema).
|
|
sqlx::raw_sql(
|
|
"CREATE TABLE push_gateway_installations (
|
|
id UUID PRIMARY KEY,
|
|
app_attest_key_id BYTEA NOT NULL UNIQUE,
|
|
app_attest_public_key BYTEA NOT NULL,
|
|
assertion_counter BIGINT NOT NULL,
|
|
app_profile TEXT NOT NULL,
|
|
token_ciphertext BYTEA NOT NULL,
|
|
token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32),
|
|
endpoint_epoch BIGINT NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (app_profile, token_fingerprint)
|
|
);
|
|
CREATE TABLE push_gateway_delegations (
|
|
id UUID PRIMARY KEY,
|
|
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
|
|
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
|
endpoint_epoch BIGINT NOT NULL,
|
|
generation BIGINT NOT NULL,
|
|
not_before TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (installation_id, relay_pubkey)
|
|
);
|
|
CREATE TABLE push_gateway_endpoint_quotas (
|
|
token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32),
|
|
window_started_at TIMESTAMPTZ NOT NULL,
|
|
admitted BIGINT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE push_gateway_delivery_auth_replays (
|
|
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
|
auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
PRIMARY KEY (relay_pubkey, auth_event_id)
|
|
);
|
|
CREATE TABLE push_gateway_delivery_request_replays (
|
|
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
|
request_id UUID NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
PRIMARY KEY (relay_pubkey, request_id)
|
|
);",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("create authority admission tables");
|
|
(pool, schema)
|
|
}
|
|
|
|
const RELAY_HEX: &str = "11111111111111111111111111111111111111111111111111111111111111aa";
|
|
const DELEGATION_ID: u128 = 2;
|
|
|
|
// One installation + one live delegation that admits at now=1_000.
|
|
async fn install_authority(pool: &PgPool) {
|
|
let now = Utc::now();
|
|
sqlx::query(
|
|
"INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at)
|
|
VALUES ($1,$2,$3,0,'buzz-ios-production',$4,$5,1,$6)",
|
|
)
|
|
.bind(Uuid::from_u128(1))
|
|
.bind(vec![1u8])
|
|
.bind(vec![2u8; 33])
|
|
.bind(vec![3u8])
|
|
.bind(vec![4u8; 32])
|
|
.bind(now + chrono::Duration::days(30))
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert installation");
|
|
sqlx::query(
|
|
"INSERT INTO push_gateway_delegations(id,installation_id,relay_pubkey,endpoint_epoch,generation,not_before,expires_at,revoked_at)
|
|
VALUES ($1,$2,$3,1,1,$4,$5,NULL)",
|
|
)
|
|
.bind(Uuid::from_u128(DELEGATION_ID))
|
|
.bind(Uuid::from_u128(1))
|
|
.bind(hex::decode(RELAY_HEX).unwrap())
|
|
.bind(now - chrono::Duration::days(1))
|
|
.bind(now + chrono::Duration::days(7))
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert delegation");
|
|
}
|
|
|
|
fn admit<'a>(
|
|
store: &'a PostgresAuthorityStore,
|
|
event_hex: &'a str,
|
|
request_id: Uuid,
|
|
) -> impl std::future::Future<Output = Result<DeliveryPermit, AuthorityError>> + 'a {
|
|
admit_with_quota(store, event_hex, request_id, 10)
|
|
}
|
|
|
|
fn admit_with_quota<'a>(
|
|
store: &'a PostgresAuthorityStore,
|
|
event_hex: &'a str,
|
|
request_id: Uuid,
|
|
quota_max_deliveries: i64,
|
|
) -> impl std::future::Future<Output = Result<DeliveryPermit, AuthorityError>> + 'a {
|
|
let now = Utc::now().timestamp();
|
|
store.authorize_delivery(
|
|
Uuid::from_u128(DELEGATION_ID),
|
|
RELAY_HEX,
|
|
1,
|
|
1,
|
|
event_hex,
|
|
request_id,
|
|
now + 300,
|
|
60,
|
|
quota_max_deliveries,
|
|
now,
|
|
)
|
|
}
|
|
|
|
// Two concurrent admissions colliding on the same (relay,request_id) PK must
|
|
// admit exactly once; the loser rejects with its whole tx rolled back, so
|
|
// quota is charged once and the auth-event fence is not consumed by the loser.
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL"]
|
|
async fn concurrent_same_request_id_admits_exactly_once() {
|
|
let (pool, schema) = full_schema(4).await;
|
|
install_authority(&pool).await;
|
|
let store = PostgresAuthorityStore::new(pool.clone());
|
|
let request_id = Uuid::new_v4();
|
|
let event_a = "22".repeat(32);
|
|
let event_b = "33".repeat(32);
|
|
|
|
let (a, b) = tokio::join!(
|
|
admit(&store, &event_a, request_id),
|
|
admit(&store, &event_b, request_id),
|
|
);
|
|
assert_eq!(
|
|
[a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(),
|
|
1,
|
|
"exactly one concurrent same-request_id admission may win"
|
|
);
|
|
|
|
let requests: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_request_replays")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count request replays");
|
|
assert_eq!(requests, 1, "winner leaves exactly one request-id fence");
|
|
let auth_events: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_auth_replays")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count auth replays");
|
|
assert_eq!(auth_events, 1, "loser's auth-event insert rolled back");
|
|
let admitted: i64 = sqlx::query_scalar("SELECT admitted FROM push_gateway_endpoint_quotas")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("read quota");
|
|
assert_eq!(admitted, 1, "loser's quota reservation rolled back");
|
|
|
|
pool.close().await;
|
|
drop_schema(&schema).await;
|
|
}
|
|
|
|
// Red-team (Tyler's thorough pass): quota ceiling under concurrency. Two
|
|
// admissions for the SAME endpoint fingerprint but DISTINCT request_ids and
|
|
// DISTINCT auth events — so neither replay PK fence can gate them; the only
|
|
// thing standing between the caller and over-admission is the quota upsert's
|
|
// `WHERE ... admitted < $4` predicate. With max=1 the two admissions race for
|
|
// a single slot. A snapshot-evaluated predicate (reading admitted=0 in both
|
|
// txns before either commits) would admit BOTH and burn the ceiling; the
|
|
// correct behavior relies on Postgres re-checking the ON CONFLICT DO UPDATE
|
|
// predicate against the row it just locked, so the loser sees admitted=1,
|
|
// fails `1 < 1`, updates zero rows, and rejects. Exactly one Ok, and the
|
|
// persisted counter must never exceed the ceiling.
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL"]
|
|
async fn concurrent_admissions_never_over_admit_past_quota_ceiling() {
|
|
let (pool, schema) = full_schema(4).await;
|
|
install_authority(&pool).await;
|
|
let store = PostgresAuthorityStore::new(pool.clone());
|
|
let event_a = "22".repeat(32);
|
|
let event_b = "33".repeat(32);
|
|
|
|
let (a, b) = tokio::join!(
|
|
admit_with_quota(&store, &event_a, Uuid::new_v4(), 1),
|
|
admit_with_quota(&store, &event_b, Uuid::new_v4(), 1),
|
|
);
|
|
assert_eq!(
|
|
[a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(),
|
|
1,
|
|
"quota ceiling of 1 admits exactly one of two concurrent attempts"
|
|
);
|
|
|
|
let admitted: i64 = sqlx::query_scalar("SELECT admitted FROM push_gateway_endpoint_quotas")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("read quota");
|
|
assert_eq!(
|
|
admitted, 1,
|
|
"persisted admitted counter must never exceed the ceiling under a race"
|
|
);
|
|
// The loser's whole tx rolled back: its distinct auth event is not fenced.
|
|
let auth_events: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_auth_replays")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count auth replays");
|
|
assert_eq!(auth_events, 1, "rejected admission consumes no auth fence");
|
|
let requests: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_request_replays")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count request replays");
|
|
assert_eq!(requests, 1, "rejected admission consumes no request fence");
|
|
|
|
pool.close().await;
|
|
drop_schema(&schema).await;
|
|
}
|
|
|
|
// Red-team: Retryable release is unconditional (deletes the request-id row on
|
|
// the pool, not inside a tx). Attack the window where a losing delivery's
|
|
// release races a fresh admission that legitimately re-took the same
|
|
// request_id — could the stale DELETE punch a hole in the live fence? It
|
|
// cannot: the DELETE keys on (relay_pubkey, request_id) with no ownership
|
|
// token, but the fence it would remove is exactly the one the retrying caller
|
|
// is entitled to free, and any *subsequent* admission re-inserts its own row.
|
|
// Concretely: admit R, Retryable-release R (fence gone), re-admit R (fresh
|
|
// fence), then replay the SAME release a second time (a duplicated/late
|
|
// finish) — it must delete the NOW-LIVE fence, and the next admission of R
|
|
// must still be gated by whatever fence remains. This pins that a duplicated
|
|
// Retryable finish is idempotent-safe and never leaves R permanently
|
|
// un-fenceable while the delegation is live.
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL"]
|
|
async fn duplicated_retryable_release_does_not_permanently_unfence_request_id() {
|
|
let (pool, schema) = full_schema(2).await;
|
|
install_authority(&pool).await;
|
|
let store = PostgresAuthorityStore::new(pool.clone());
|
|
let request_id = Uuid::new_v4();
|
|
|
|
let permit = admit(&store, &"22".repeat(32), request_id)
|
|
.await
|
|
.expect("first admission");
|
|
// Clone the permit's identity into a second release we fire twice.
|
|
store
|
|
.finish_delivery(permit, DeliveryDisposition::Retryable)
|
|
.await
|
|
.expect("retryable release frees the fence");
|
|
// Re-admit: fresh fence for the same request_id.
|
|
let permit2 = admit(&store, &"33".repeat(32), request_id)
|
|
.await
|
|
.expect("re-admit after release");
|
|
// A duplicated/late Retryable finish for the same (relay, request_id)
|
|
// deletes the now-live fence — this is the worst case for the
|
|
// unconditional DELETE. It must not error, and R must remain re-admittable
|
|
// (fence hole is transient, never permanent), which is the honest
|
|
// NIP-PL §312 contract: a still-live endpoint gets a fresh job.
|
|
store
|
|
.finish_delivery(permit2, DeliveryDisposition::Retryable)
|
|
.await
|
|
.expect("duplicated retryable release is idempotent-safe");
|
|
let admitted_again = admit(&store, &"44".repeat(32), request_id).await;
|
|
assert!(
|
|
admitted_again.is_ok(),
|
|
"after any Retryable release the request_id is re-admittable, never permanently unfenceable"
|
|
);
|
|
// And a Terminal on that live permit re-burns it, closing the window.
|
|
store
|
|
.finish_delivery(admitted_again.unwrap(), DeliveryDisposition::Terminal)
|
|
.await
|
|
.expect("terminal finish");
|
|
assert!(
|
|
admit(&store, &"55".repeat(32), request_id).await.is_err(),
|
|
"terminal keeps the fence burned after the release churn"
|
|
);
|
|
|
|
pool.close().await;
|
|
drop_schema(&schema).await;
|
|
}
|
|
|
|
// Retryable release must free the real request-id PK: after finish_delivery,
|
|
// the same request_id re-admits with a fresh auth event; a Terminal finish
|
|
// leaves it burned.
|
|
#[tokio::test]
|
|
#[ignore = "requires PostgreSQL"]
|
|
async fn retryable_release_frees_request_id_on_real_postgres() {
|
|
let (pool, schema) = full_schema(2).await;
|
|
install_authority(&pool).await;
|
|
let store = PostgresAuthorityStore::new(pool.clone());
|
|
let request_id = Uuid::new_v4();
|
|
|
|
let permit = admit(&store, &"22".repeat(32), request_id)
|
|
.await
|
|
.expect("first admission");
|
|
store
|
|
.finish_delivery(permit, DeliveryDisposition::Retryable)
|
|
.await
|
|
.expect("retryable release");
|
|
// Same request_id, fresh auth event: released PK admits again.
|
|
let permit = admit(&store, &"33".repeat(32), request_id)
|
|
.await
|
|
.expect("retryable release frees the request-id PK");
|
|
// Terminal now burns it: a further re-admit with the same request_id fails.
|
|
store
|
|
.finish_delivery(permit, DeliveryDisposition::Terminal)
|
|
.await
|
|
.expect("terminal finish");
|
|
assert!(
|
|
admit(&store, &"44".repeat(32), request_id).await.is_err(),
|
|
"terminal outcome keeps the request-id fence burned"
|
|
);
|
|
|
|
pool.close().await;
|
|
drop_schema(&schema).await;
|
|
}
|
|
|
|
async fn drop_schema(schema: &str) {
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(&database_url)
|
|
.await
|
|
.expect("connect for teardown");
|
|
sqlx::query(AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("drop isolated test schema");
|
|
}
|
|
}
|