use sqlx::PgConnection;
use syrup_rail::{
GatewayAccountRegistration, GatewayConfigurationActivation,
GatewayConfigurationActivationOutcome,
};
pub async fn register_gateway_account(
connection: &mut PgConnection,
registration: &GatewayAccountRegistration,
) -> Result<(), sqlx::Error> {
let gateway_account_id = registration.gateway_account_id().into_uuid();
let billing_scope_id = registration.billing_scope_id().into_uuid();
let gateway_configuration_id = registration.gateway_configuration_id().into_uuid();
sqlx::query!(
r#"
WITH registered_provider AS (
INSERT INTO billing_gateway_provider_rate_limits (
provider_key,
rate_limited_until
)
VALUES ($1, '-infinity')
ON CONFLICT (provider_key) DO NOTHING
)
INSERT INTO billing_gateway_accounts (
id,
billing_scope_id,
provider_key,
gateway_configuration_id
)
VALUES ($2, $3, $1, $4)
"#,
registration.provider_key().as_str(),
gateway_account_id,
billing_scope_id,
gateway_configuration_id,
)
.execute(connection)
.await?;
Ok(())
}
pub async fn activate_gateway_configuration(
connection: &mut PgConnection,
activation: &GatewayConfigurationActivation,
) -> Result<GatewayConfigurationActivationOutcome, sqlx::Error> {
let billing_scope_id = activation.billing_scope_id().into_uuid();
let gateway_account_id = activation.gateway_account_id().into_uuid();
let expected_configuration_id = activation.expected_configuration_id().into_uuid();
let new_configuration_id = activation.new_configuration_id().into_uuid();
let result = sqlx::query!(
r#"
WITH candidate AS MATERIALIZED (
SELECT id, provider_key, gateway_configuration_id
FROM billing_gateway_accounts
WHERE billing_scope_id = $1
FOR UPDATE
),
activated AS (
UPDATE billing_gateway_accounts AS accounts
SET gateway_configuration_id = $5,
updated_at = clock_timestamp()
FROM candidate
WHERE accounts.billing_scope_id = $1
AND accounts.id = candidate.id
AND candidate.id = $2
AND candidate.provider_key = $3
AND candidate.gateway_configuration_id = $4
RETURNING accounts.id
)
SELECT
EXISTS (SELECT 1 FROM candidate) AS "account_exists!",
EXISTS (SELECT 1 FROM activated) AS "activated!"
"#,
billing_scope_id,
gateway_account_id,
activation.provider_key().as_str(),
expected_configuration_id,
new_configuration_id,
)
.fetch_one(connection)
.await?;
Ok(if result.activated {
GatewayConfigurationActivationOutcome::Activated
} else if result.account_exists {
GatewayConfigurationActivationOutcome::IdentityChanged
} else {
GatewayConfigurationActivationOutcome::AccountNotFound
})
}
#[cfg(test)]
mod tests {
use std::{error::Error, io};
use syrup_rail::{
BillingScopeId, GatewayAccountId, GatewayAccountRegistration,
GatewayConfigurationActivation, GatewayConfigurationActivationOutcome,
GatewayConfigurationId, GatewayProviderKey,
};
use uuid::Uuid;
use super::{activate_gateway_configuration, register_gateway_account};
use crate::test_support::TestDatabase;
#[tokio::test]
async fn gateway_account_metadata_operations_preserve_identity_and_cooldowns()
-> Result<(), Box<dyn Error>> {
let database = TestDatabase::start("sr_accounts_v1").await?;
let result = async {
let provider = GatewayProviderKey::new("test_gateway")?;
let scope = BillingScopeId::new(Uuid::now_v7());
let account = GatewayAccountId::new(Uuid::now_v7());
let initial_configuration = GatewayConfigurationId::new(Uuid::now_v7());
let registration = GatewayAccountRegistration::new(
scope,
account,
provider.clone(),
initial_configuration,
);
let mut rolled_back = database.pool.begin().await?;
register_gateway_account(&mut rolled_back, ®istration).await?;
rolled_back.rollback().await?;
let rolled_back_rows: i64 =
sqlx::query_scalar("SELECT count(*) FROM billing_gateway_accounts WHERE id = $1")
.bind(account.as_uuid())
.fetch_one(&database.pool)
.await?;
if rolled_back_rows != 0 {
return Err(io::Error::other("registration escaped caller rollback").into());
}
let mut transaction = database.pool.begin().await?;
register_gateway_account(&mut transaction, ®istration).await?;
transaction.commit().await?;
let provider_deadline = "2035-02-03T04:05:06Z";
let account_deadline = "2035-03-04T05:06:07Z";
sqlx::query(
r#"
UPDATE billing_gateway_provider_rate_limits
SET rate_limited_until = $2::timestamptz
WHERE provider_key = $1
"#,
)
.bind(provider.as_str())
.bind(provider_deadline)
.execute(&database.pool)
.await?;
sqlx::query(
r#"
UPDATE billing_gateway_accounts
SET mutation_rate_limited_until = $2::timestamptz
WHERE id = $1
"#,
)
.bind(account.as_uuid())
.bind(account_deadline)
.execute(&database.pool)
.await?;
let sibling_registration = GatewayAccountRegistration::new(
BillingScopeId::new(Uuid::now_v7()),
GatewayAccountId::new(Uuid::now_v7()),
provider.clone(),
GatewayConfigurationId::new(Uuid::now_v7()),
);
let mut sibling_transaction = database.pool.begin().await?;
register_gateway_account(&mut sibling_transaction, &sibling_registration).await?;
sibling_transaction.commit().await?;
let retained_provider_deadline: String = sqlx::query_scalar(
r#"
SELECT rate_limited_until::text
FROM billing_gateway_provider_rate_limits
WHERE provider_key = $1
"#,
)
.bind(provider.as_str())
.fetch_one(&database.pool)
.await?;
if !retained_provider_deadline.starts_with("2035-02-03 04:05:06") {
return Err(io::Error::other(
"repeated provider registration changed its cooldown",
)
.into());
}
let other_provider = GatewayProviderKey::new("other_gateway")?;
let other_registration = GatewayAccountRegistration::new(
BillingScopeId::new(Uuid::now_v7()),
GatewayAccountId::new(Uuid::now_v7()),
other_provider.clone(),
GatewayConfigurationId::new(Uuid::now_v7()),
);
let mut other_transaction = database.pool.begin().await?;
register_gateway_account(&mut other_transaction, &other_registration).await?;
other_transaction.commit().await?;
let other_provider_is_open: bool = sqlx::query_scalar(
r#"
SELECT rate_limited_until = '-infinity'::timestamptz
FROM billing_gateway_provider_rate_limits
WHERE provider_key = $1
"#,
)
.bind(other_provider.as_str())
.fetch_one(&database.pool)
.await?;
if !other_provider_is_open {
return Err(io::Error::other(
"one provider's registration or cooldown changed another provider",
)
.into());
}
let next_configuration = GatewayConfigurationId::new(Uuid::now_v7());
let activation = GatewayConfigurationActivation::new(
scope,
account,
provider.clone(),
initial_configuration,
next_configuration,
);
let mut activation_transaction = database.pool.begin().await?;
let outcome =
activate_gateway_configuration(&mut activation_transaction, &activation).await?;
activation_transaction.commit().await?;
if outcome != GatewayConfigurationActivationOutcome::Activated {
return Err(io::Error::other("exact configuration was not activated").into());
}
let stored = sqlx::query_as::<_, (Uuid, String)>(
r#"
SELECT gateway_configuration_id, mutation_rate_limited_until::text
FROM billing_gateway_accounts
WHERE id = $1
"#,
)
.bind(account.as_uuid())
.fetch_one(&database.pool)
.await?;
if stored.0 != next_configuration.into_uuid()
|| !stored.1.starts_with("2035-03-04 05:06:07")
{
return Err(io::Error::other(
"configuration activation changed identity or account cooldown",
)
.into());
}
let stale = GatewayConfigurationActivation::new(
scope,
account,
provider.clone(),
initial_configuration,
GatewayConfigurationId::new(Uuid::now_v7()),
);
let wrong_provider = GatewayConfigurationActivation::new(
scope,
account,
GatewayProviderKey::new("another_gateway")?,
next_configuration,
GatewayConfigurationId::new(Uuid::now_v7()),
);
let wrong_account = GatewayConfigurationActivation::new(
scope,
GatewayAccountId::new(Uuid::now_v7()),
provider.clone(),
next_configuration,
GatewayConfigurationId::new(Uuid::now_v7()),
);
for mismatch in [&stale, &wrong_provider, &wrong_account] {
let mut mismatch_transaction = database.pool.begin().await?;
let outcome =
activate_gateway_configuration(&mut mismatch_transaction, mismatch).await?;
mismatch_transaction.rollback().await?;
if outcome != GatewayConfigurationActivationOutcome::IdentityChanged {
return Err(
io::Error::other("stale gateway identity did not fail closed").into(),
);
}
}
let missing = GatewayConfigurationActivation::new(
BillingScopeId::new(Uuid::now_v7()),
GatewayAccountId::new(Uuid::now_v7()),
provider,
next_configuration,
GatewayConfigurationId::new(Uuid::now_v7()),
);
let mut missing_transaction = database.pool.begin().await?;
let missing_outcome =
activate_gateway_configuration(&mut missing_transaction, &missing).await?;
missing_transaction.rollback().await?;
if missing_outcome != GatewayConfigurationActivationOutcome::AccountNotFound {
return Err(io::Error::other("missing account was not distinguished").into());
}
Ok::<_, Box<dyn Error>>(())
}
.await;
let cleanup = database.cleanup().await;
result?;
cleanup
}
}