use crate::sql::Row;
use acme_proxy_core::datetime::rfc3339;
use serde_json::Value;
use tracing::{debug, info};
use uuid::Uuid;
use crate::db::Database;
use crate::nonce::now_secs;
use crate::status::{self, OrderStatus};
use acme_proxy_core::identifier::Identifier;
#[derive(Debug)]
pub struct Order {
pub id: Uuid,
pub profile: String,
pub account_id: Uuid,
pub status: OrderStatus,
pub identifiers: Vec<Identifier>,
pub expires: i64,
pub not_before: Option<i64>,
pub not_after: Option<i64>,
pub error: Option<Value>,
pub certificate: Option<String>,
pub replaces: Option<String>,
pub cert_serial: Option<String>,
pub cert_pubkey: Option<Vec<u8>>,
pub cert_not_after: Option<i64>,
pub revoked_at: Option<i64>,
pub revocation_reason: Option<i64>,
pub created_at: i64,
pub created_ip: Option<String>,
pub created_ptr: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct OrderQuery {
pub profile: Option<String>,
pub account_id: Option<String>,
pub status: Option<OrderStatus>,
pub identifier: Option<String>,
pub identifier_contains: Option<String>,
pub cert_serial: Option<String>,
pub limit: i64,
pub offset: i64,
}
impl OrderQuery {
fn push_predicates(&self, builder: &mut crate::sql::Builder) {
let mut separator = crate::query::push_equalities(
builder,
crate::query::WHERE,
&[
("profile = ", self.profile.as_deref()),
("status = ", self.status.map(OrderStatus::as_str)),
],
);
if let Some(account_id) = self.account_id.as_deref() {
builder.push(separator).push("account_id = ");
builder.push_bind(super::id::parse(account_id));
separator = " AND ";
}
if let Some(identifier) = self.identifier.as_deref() {
let source = builder.dialect().json_array_source("orders.identifiers");
let member = builder.dialect().json_member("value");
builder.push(separator).push(format!(
"EXISTS (SELECT 1 FROM {source} WHERE lower({member}) = "
));
builder.push_bind(identifier.to_lowercase());
builder.push(")");
separator = " AND ";
}
if let Some(fragment) = self.identifier_contains.as_deref() {
let source = builder.dialect().json_array_source("orders.identifiers");
let member = builder.dialect().json_member("value");
let position = builder.dialect().substring_position();
builder.push(separator).push(format!(
"EXISTS (SELECT 1 FROM {source} WHERE {position}(lower({member}), "
));
builder.push_bind(fragment.to_lowercase());
builder.push(") > 0)");
separator = " AND ";
}
if let Some(cert_serial) = self.cert_serial.as_deref() {
builder.push(separator).push("cert_serial = ");
builder.push_bind(cert_serial.to_string());
separator = " AND ";
}
let _ = separator;
}
}
macro_rules! columns {
() => {
"id, profile, account_id, status, identifiers, expires, not_before, not_after, \
error, certificate, replaces, cert_serial, cert_pubkey, cert_not_after, \
revoked_at, revocation_reason, created_at, created_ip, created_ptr"
};
}
macro_rules! live_certificate {
() => {
"(certificate IS NOT NULL AND revoked_at IS NULL \
AND (cert_not_after IS NULL OR cert_not_after < 0 OR cert_not_after > ?))"
};
}
pub(crate) use live_certificate;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardedDelete {
NotFound,
LiveCertificates(u64),
Deleted,
}
pub const UNPARSABLE_NOT_AFTER: i64 = -1;
fn push_expiring_predicates(profile: Option<&str>, before: i64, builder: &mut crate::sql::Builder) {
builder.push(" FROM orders WHERE certificate IS NOT NULL AND revoked_at IS NULL");
builder.push(" AND cert_not_after >= 0 AND cert_not_after <= ");
builder.push_bind(before);
if let Some(profile) = profile {
builder.push(" AND profile = ");
builder.push_bind(profile.to_string());
}
}
impl Order {
fn from_row(row: Row) -> Result<Self, sqlx::Error> {
let identifiers_json: String = row.try_get("identifiers")?;
let identifiers: Vec<Identifier> = serde_json::from_str(&identifiers_json)
.map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
let error_json: Option<String> = row.try_get("error")?;
let error: Option<Value> = match error_json {
Some(text) => {
Some(serde_json::from_str(&text).map_err(|e| sqlx::Error::Decode(Box::new(e)))?)
}
None => None,
};
Ok(Order {
id: row.try_get("id")?,
profile: row.try_get("profile")?,
account_id: row.try_get("account_id")?,
status: status::from_column(row.try_get::<String>("status")?.as_str())?,
identifiers,
expires: row.try_get("expires")?,
not_before: row.try_get("not_before")?,
not_after: row.try_get("not_after")?,
error,
certificate: row.try_get("certificate")?,
replaces: row.try_get("replaces")?,
cert_serial: row.try_get("cert_serial")?,
cert_pubkey: row.try_get("cert_pubkey")?,
cert_not_after: row.try_get("cert_not_after")?,
revoked_at: row.try_get("revoked_at")?,
revocation_reason: row.try_get("revocation_reason")?,
created_at: row.try_get("created_at")?,
created_ip: row.try_get("created_ip")?,
created_ptr: row.try_get("created_ptr")?,
})
}
pub fn new(
profile: &str,
account_id: Uuid,
identifiers: Vec<Identifier>,
expires: i64,
not_before: Option<i64>,
not_after: Option<i64>,
) -> Order {
Order {
id: crate::id::mint(),
profile: profile.to_string(),
account_id,
status: OrderStatus::Pending,
identifiers,
expires,
not_before,
not_after,
error: None,
certificate: None,
replaces: None,
cert_serial: None,
cert_pubkey: None,
cert_not_after: None,
revoked_at: None,
revocation_reason: None,
created_at: now_secs(),
created_ip: None,
created_ptr: None,
}
}
#[must_use]
pub fn with_client(mut self, client: &acme_proxy_core::audit::ClientContext) -> Order {
self.created_ip = client.ip.clone();
self.created_ptr = client.ptr.clone();
self
}
pub async fn insert<'e>(
&self,
executor: impl Into<crate::sql::Exec<'e>>,
) -> Result<(), sqlx::Error> {
let identifiers_json = serde_json::to_string(&self.identifiers)
.map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
debug!(event = "db_order_create_started", outcome = "progress", order_id = ?self.id, profile = %self.profile, account_id = ?self.account_id);
crate::sql::query(
"INSERT INTO orders (id, profile, account_id, status, identifiers, expires, not_before, not_after, error, certificate, replaces, created_at, created_ip, created_ptr) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?);",
)
.bind(self.id)
.bind(&self.profile)
.bind(self.account_id)
.bind(self.status.as_str())
.bind(identifiers_json)
.bind(self.expires)
.bind(self.not_before)
.bind(self.not_after)
.bind(&self.replaces)
.bind(self.created_at)
.bind(&self.created_ip)
.bind(&self.created_ptr)
.execute(executor)
.await?;
debug!(event = "db_order_created", outcome = "success", order_id = ?self.id, account_id = ?self.account_id);
Ok(())
}
pub async fn create(
profile: &str,
account_id: Uuid,
identifiers: Vec<Identifier>,
expires: i64,
not_before: Option<i64>,
not_after: Option<i64>,
database: &Database,
) -> Result<Order, sqlx::Error> {
let order = Order::new(
profile,
account_id,
identifiers,
expires,
not_before,
not_after,
);
order.insert(database).await?;
Ok(order)
}
pub async fn find_by_id(id: &str, database: &Database) -> Result<Option<Order>, sqlx::Error> {
debug!(event = "db_order_find_by_id_started", outcome = "progress", order_id = ?id);
let Some(id) = crate::id::parse(id) else {
return Ok(None);
};
let row = crate::sql::query(concat!("SELECT ", columns!(), " FROM orders WHERE id = ?;"))
.bind(id)
.fetch_optional(database)
.await?;
let result = row.map(Order::from_row).transpose()?;
if result.is_some() {
info!(event = "db_order_found_by_id", outcome = "success", order_id = ?id);
} else {
debug!(event = "db_order_not_found_by_id", outcome = "failure", order_id = ?id);
}
Ok(result)
}
pub async fn find_by_account(
account_id: Uuid,
database: &Database,
) -> Result<Vec<Order>, sqlx::Error> {
debug!(event = "db_order_find_by_account_started", outcome = "progress", account_id = ?account_id);
let rows = crate::sql::query(concat!(
"SELECT ",
columns!(),
" FROM orders WHERE account_id = ? ORDER BY created_at DESC;"
))
.bind(account_id)
.fetch_all(database)
.await?;
rows.into_iter().map(Order::from_row).collect()
}
pub async fn find_active_by_account(
account_id: Uuid,
database: &Database,
) -> Result<Vec<Order>, sqlx::Error> {
debug!(event = "db_order_find_active_by_account_started", outcome = "progress", account_id = ?account_id);
let rows =
crate::sql::query(concat!("SELECT ", columns!(), " FROM orders WHERE account_id = ? AND status != 'invalid' AND (status = 'valid' OR expires > ?) ORDER BY created_at DESC;"))
.bind(account_id)
.bind(now_secs())
.fetch_all(database)
.await?;
rows.into_iter().map(Order::from_row).collect()
}
pub async fn search(
query: &OrderQuery,
database: &Database,
) -> Result<(Vec<Order>, i64), sqlx::Error> {
debug!(event = "db_order_search_started",
outcome = "progress",
profile = ?query.profile,
account_id = ?query.account_id,
status = ?query.status,
identifier = ?query.identifier,
identifier_contains = ?query.identifier_contains,
cert_serial = ?query.cert_serial,
limit = query.limit,
offset = query.offset);
let mut page = crate::sql::Builder::new(
database.dialect(),
concat!("SELECT ", columns!(), " FROM orders"),
);
query.push_predicates(&mut page);
page.push(" ORDER BY created_at DESC, id DESC LIMIT ");
page.push_bind(query.limit);
page.push(" OFFSET ");
page.push_bind(query.offset);
let rows = page.build().fetch_all(database).await?;
let orders: Vec<Order> = rows
.into_iter()
.map(Order::from_row)
.collect::<Result<_, _>>()?;
let mut count = crate::sql::Builder::new(database.dialect(), "SELECT COUNT(*) FROM orders");
query.push_predicates(&mut count);
let total: i64 = count.build().fetch_one(database).await?.try_get::<i64>(0)?;
Ok((orders, total))
}
pub async fn cleanup(
profile: &str,
cutoff: i64,
database: &Database,
) -> Result<u64, sqlx::Error> {
debug!(event = "db_order_cleanup_started", outcome = "progress", profile = %profile, cutoff = cutoff);
let removed = crate::sql::query(
"DELETE FROM orders WHERE profile = ? AND status != 'valid' AND expires < ?;",
)
.bind(profile)
.bind(cutoff)
.execute(database)
.await?
.rows_affected();
debug!(event = "db_order_cleanup_completed", outcome = "success", profile = %profile, rows_removed = removed);
Ok(removed)
}
pub async fn count_by_account(
account_id: Uuid,
database: &Database,
) -> Result<i64, sqlx::Error> {
let row = crate::sql::query("SELECT COUNT(*) FROM orders WHERE account_id = ?;")
.bind(account_id)
.fetch_one(database)
.await?;
row.try_get::<i64>(0)
}
pub async fn delete(id: &str, database: &Database) -> Result<GuardedDelete, sqlx::Error> {
debug!(event = "db_order_delete_started", outcome = "progress", order_id = ?id);
let Some(id) = crate::id::parse(id) else {
return Ok(GuardedDelete::NotFound);
};
let now = now_secs();
let result = crate::sql::query(concat!(
"DELETE FROM orders WHERE id = ? AND NOT ",
live_certificate!(),
";"
))
.bind(id)
.bind(now)
.execute(database)
.await?;
if result.rows_affected() > 0 {
info!(event = "db_order_deleted", outcome = "success", order_id = ?id);
return Ok(GuardedDelete::Deleted);
}
let live = Self::count_live_certificates(id, database).await?;
if live > 0 {
info!(event = "db_order_delete_blocked", outcome = "failure", order_id = ?id, live_certificates = live);
Ok(GuardedDelete::LiveCertificates(live))
} else {
debug!(event = "db_order_delete_missing", outcome = "success", order_id = ?id);
Ok(GuardedDelete::NotFound)
}
}
pub async fn count_live_certificates(
order_id: Uuid,
database: &Database,
) -> Result<u64, sqlx::Error> {
let live: i64 = crate::sql::query(concat!(
"SELECT COUNT(*) FROM orders WHERE id = ? AND ",
live_certificate!(),
";"
))
.bind(order_id)
.bind(now_secs())
.fetch_one(database)
.await?
.try_get(0)?;
Ok(live as u64)
}
pub async fn finalize(
&mut self,
chain: String,
cert_serial: String,
cert_pubkey: Vec<u8>,
cert_not_after: Option<i64>,
database: &Database,
) -> Result<(), sqlx::Error> {
debug!(event = "db_order_finalize_started", outcome = "progress", order_id = ?self.id);
crate::sql::query(
"UPDATE orders SET certificate = ?, cert_serial = ?, cert_pubkey = ?, \
cert_not_after = ?, status = 'valid' WHERE id = ?;",
)
.bind(&chain)
.bind(&cert_serial)
.bind(&cert_pubkey)
.bind(cert_not_after)
.bind(self.id)
.execute(database)
.await?;
self.certificate = Some(chain);
self.cert_serial = Some(cert_serial);
self.cert_pubkey = Some(cert_pubkey);
self.cert_not_after = cert_not_after;
self.status = OrderStatus::Valid;
debug!(event = "db_order_finalized", outcome = "success", order_id = ?self.id);
Ok(())
}
pub async fn find_expiring(
profile: Option<&str>,
before: i64,
limit: i64,
offset: i64,
database: &Database,
) -> Result<(Vec<Order>, i64), sqlx::Error> {
debug!(
event = "db_order_find_expiring_started",
outcome = "progress",
profile = ?profile,
before,
limit,
offset
);
let mut page = crate::sql::Builder::new(database.dialect(), concat!("SELECT ", columns!()));
push_expiring_predicates(profile, before, &mut page);
page.push(" ORDER BY cert_not_after ASC, id ASC LIMIT ");
page.push_bind(limit);
page.push(" OFFSET ");
page.push_bind(offset);
let rows = page.build().fetch_all(database).await?;
let orders: Vec<Order> = rows
.into_iter()
.map(Order::from_row)
.collect::<Result<_, _>>()?;
let mut count = crate::sql::Builder::new(database.dialect(), "SELECT COUNT(*)");
push_expiring_predicates(profile, before, &mut count);
let total: i64 = count.build().fetch_one(database).await?.try_get::<i64>(0)?;
Ok((orders, total))
}
pub async fn find_unstamped(
profile: &str,
limit: i64,
database: &Database,
) -> Result<Vec<(Uuid, String)>, sqlx::Error> {
let rows = crate::sql::query(
"SELECT id, certificate FROM orders WHERE profile = ? \
AND certificate IS NOT NULL AND cert_not_after IS NULL LIMIT ?;",
)
.bind(profile)
.bind(limit)
.fetch_all(database)
.await?;
rows.into_iter()
.map(|row| Ok((row.try_get("id")?, row.try_get("certificate")?)))
.collect()
}
pub async fn set_cert_not_after(
id: Uuid,
cert_not_after: i64,
database: &Database,
) -> Result<(), sqlx::Error> {
crate::sql::query("UPDATE orders SET cert_not_after = ? WHERE id = ?;")
.bind(cert_not_after)
.bind(id)
.execute(database)
.await?;
Ok(())
}
pub async fn find_by_cert_serial(
profile: &str,
serial: &str,
database: &Database,
) -> Result<Option<Order>, sqlx::Error> {
debug!(event = "db_order_find_by_cert_serial_started", outcome = "progress", profile = %profile, cert_serial = ?serial);
let row = crate::sql::query(concat!(
"SELECT ",
columns!(),
" FROM orders WHERE profile = ? AND cert_serial = ?;"
))
.bind(profile)
.bind(serial)
.fetch_optional(database)
.await?;
let result = row.map(Order::from_row).transpose()?;
if result.is_some() {
info!(event = "db_order_found_by_cert_serial", outcome = "success", cert_serial = ?serial);
} else {
debug!(event = "db_order_not_found_by_cert_serial", outcome = "failure", cert_serial = ?serial);
}
Ok(result)
}
pub async fn find_by_replaces(
profile: &str,
cert_id: &str,
database: &Database,
) -> Result<Option<Order>, sqlx::Error> {
debug!(event = "db_order_find_by_replaces_started", outcome = "progress", profile = %profile, replaces = %cert_id);
let row = crate::sql::query(concat!(
"SELECT ",
columns!(),
" FROM orders WHERE profile = ? AND replaces = ? AND status != 'invalid' LIMIT 1;"
))
.bind(profile)
.bind(cert_id)
.fetch_optional(database)
.await?;
row.map(Order::from_row).transpose()
}
pub async fn revoke(
&mut self,
reason: Option<i64>,
database: &Database,
) -> Result<bool, sqlx::Error> {
let now = now_secs();
debug!(event = "db_order_revoke_started", outcome = "progress", order_id = ?self.id, reason = ?reason);
if !Self::set_revoked(self.id, reason, now, database).await? {
return Ok(false);
}
self.revoked_at = Some(now);
self.revocation_reason = reason;
Ok(true)
}
pub async fn set_revoked<'e>(
id: Uuid,
reason: Option<i64>,
revoked_at: i64,
executor: impl Into<crate::sql::Exec<'e>>,
) -> Result<bool, sqlx::Error> {
debug!(event = "db_order_revoke_started", outcome = "progress", order_id = ?id, reason = ?reason);
let written = crate::sql::query(
"UPDATE orders SET revoked_at = ?, revocation_reason = ? \
WHERE id = ? AND revoked_at IS NULL;",
)
.bind(revoked_at)
.bind(reason)
.bind(id)
.execute(executor)
.await?
.rows_affected();
if written == 1 {
info!(event = "db_order_revoked", outcome = "success", order_id = ?id, reason = ?reason);
}
Ok(written == 1)
}
pub async fn set_invalid<'e>(
id: Uuid,
error: &Value,
executor: impl Into<crate::sql::Exec<'e>>,
) -> Result<bool, sqlx::Error> {
let written = crate::sql::query(
"UPDATE orders SET error = ?, status = 'invalid' \
WHERE id = ? AND status IN ('pending', 'ready', 'processing');",
)
.bind(error.to_string())
.bind(id)
.execute(executor)
.await?
.rows_affected();
Ok(written == 1)
}
pub async fn set_ready<'e>(
id: Uuid,
executor: impl Into<crate::sql::Exec<'e>>,
) -> Result<bool, sqlx::Error> {
let written = crate::sql::query(
"UPDATE orders SET status = 'ready' WHERE id = ? AND status = 'pending';",
)
.bind(id)
.execute(executor)
.await?
.rows_affected();
Ok(written == 1)
}
pub async fn set_pending<'e>(
id: Uuid,
executor: impl Into<crate::sql::Exec<'e>>,
) -> Result<bool, sqlx::Error> {
let written = crate::sql::query(
"UPDATE orders SET status = 'pending' WHERE id = ? AND status = 'ready';",
)
.bind(id)
.execute(executor)
.await?
.rows_affected();
Ok(written == 1)
}
pub async fn mark_invalid(
&mut self,
error: Value,
database: &Database,
) -> Result<bool, sqlx::Error> {
debug!(event = "db_order_mark_invalid_started", outcome = "progress", order_id = ?self.id);
if !Self::set_invalid(self.id, &error, database).await? {
return Ok(false);
}
self.error = Some(error);
self.status = OrderStatus::Invalid;
info!(event = "db_order_marked_invalid", outcome = "failure", order_id = ?self.id);
Ok(true)
}
pub async fn mark_ready(&mut self, database: &Database) -> Result<bool, sqlx::Error> {
debug!(event = "db_order_mark_ready_started", outcome = "progress", order_id = ?self.id);
if !Self::set_ready(self.id, database).await? {
return Ok(false);
}
self.status = OrderStatus::Ready;
info!(event = "db_order_marked_ready", outcome = "success", order_id = ?self.id);
Ok(true)
}
pub async fn claim_for_finalize(&mut self, database: &Database) -> Result<bool, sqlx::Error> {
self.claim_for_finalize_on(database).await
}
pub async fn claim_for_finalize_on<'e>(
&mut self,
executor: impl Into<crate::sql::Exec<'e>>,
) -> Result<bool, sqlx::Error> {
debug!(event = "db_order_mark_processing_started", outcome = "progress", order_id = ?self.id);
let claimed = crate::sql::query(
"UPDATE orders SET status = 'processing' WHERE id = ? AND status = 'ready';",
)
.bind(self.id)
.execute(executor)
.await?
.rows_affected()
== 1;
if !claimed {
debug!(event = "db_order_finalize_claim_refused", outcome = "failure", order_id = ?self.id);
return Ok(false);
}
self.status = OrderStatus::Processing;
info!(event = "db_order_marked_processing", outcome = "success", order_id = ?self.id);
Ok(true)
}
#[must_use]
pub fn to_json(&self, base_url: &str, authz_ids: &[Uuid]) -> Value {
let mut object = serde_json::Map::new();
object.insert(
"status".to_string(),
Value::String(self.status.as_str().to_string()),
);
object.insert("expires".to_string(), Value::String(rfc3339(self.expires)));
object.insert(
"identifiers".to_string(),
serde_json::to_value(&self.identifiers).expect("Identifier is always serializable"),
);
if let Some(nb) = self.not_before {
object.insert("notBefore".to_string(), Value::String(rfc3339(nb)));
}
if let Some(na) = self.not_after {
object.insert("notAfter".to_string(), Value::String(rfc3339(na)));
}
let authorizations: Vec<Value> = authz_ids
.iter()
.map(|id| Value::String(format!("{base_url}/authz/{id}")))
.collect();
object.insert("authorizations".to_string(), Value::Array(authorizations));
object.insert(
"finalize".to_string(),
Value::String(format!("{base_url}/order/{}/finalize", self.id)),
);
if self.status == OrderStatus::Valid {
object.insert(
"certificate".to_string(),
Value::String(format!("{base_url}/certificate/{}", self.id)),
);
}
if let Some(ref error) = self.error {
object.insert("error".to_string(), error.clone());
}
if let Some(ref replaces) = self.replaces {
object.insert("replaces".to_string(), Value::String(replaces.clone()));
}
Value::Object(object)
}
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_core::audit::ClientContext;
#[tokio::test]
async fn with_client_persists_and_an_order_without_one_stays_null() {
let db = std::sync::Arc::new(Database::connect_for_test().await.unwrap());
let account = account_id(&db).await;
let stamped = Order::new(
"default",
account,
vec![Identifier::dns("a.example.com")],
0,
None,
None,
)
.with_client(&ClientContext {
ip: Some("203.0.113.7".to_string()),
ptr: Some("host.example.com".to_string()),
user_agent: Some("lego".to_string()),
request_id: Some("req-1".to_string()),
});
stamped.insert(&db).await.unwrap();
let reloaded = Order::find_by_id(stamped.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.created_ip.as_deref(), Some("203.0.113.7"));
assert_eq!(reloaded.created_ptr.as_deref(), Some("host.example.com"));
let bare = Order::new(
"default",
account,
vec![Identifier::dns("b.example.com")],
0,
None,
None,
);
bare.insert(&db).await.unwrap();
let reloaded = Order::find_by_id(bare.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.created_ip, None);
assert_eq!(reloaded.created_ptr, None);
let json = reloaded.to_json("http://localhost:3000", &[]);
let object = json.as_object().unwrap();
assert!(!object.contains_key("createdIp"));
assert!(!object.contains_key("createdPtr"));
assert!(
!stamped
.to_json("http://localhost:3000", &[])
.to_string()
.contains("203.0.113.7")
);
}
use crate::testutil::account_id;
use serde_json::json;
use std::sync::Arc;
#[tokio::test]
async fn create_then_find_by_id_round_trip() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let created = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
assert_eq!(created.status, OrderStatus::Pending);
let found = Order::find_by_id(created.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(found.account_id, acct);
assert_eq!(found.identifiers, vec![Identifier::dns("example.com")]);
assert!(found.certificate.is_none());
}
#[tokio::test]
async fn find_by_account_lists_all() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
Order::create(
"default",
acct,
vec![Identifier::dns("a.example")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
Order::create(
"default",
acct,
vec![Identifier::dns("b.example")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let orders = Order::find_by_account(acct, &db).await.unwrap();
assert_eq!(orders.len(), 2);
}
#[tokio::test]
async fn absent_lookup_returns_none() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
assert!(Order::find_by_id("nope", &db).await.unwrap().is_none());
}
#[tokio::test]
async fn to_json_shape_when_pending() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = crate::id::mint();
let json = order.to_json("http://localhost:3000", &[authz]);
assert_eq!(json["status"], "pending");
assert_eq!(
json["authorizations"],
json!([format!("http://localhost:3000/authz/{authz}")])
);
assert_eq!(
json["finalize"],
format!("http://localhost:3000/order/{}/finalize", order.id)
);
assert_eq!(
json["identifiers"],
json!([{"type": "dns", "value": "example.com"}])
);
assert!(json.get("certificate").is_none());
assert!(json.get("notBefore").is_none());
assert!(json.get("notAfter").is_none());
assert!(json["expires"].as_str().unwrap().ends_with('Z'));
}
#[tokio::test]
async fn to_json_includes_optional_fields() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
Some(now_secs()),
Some(now_secs() + 7200),
&db,
)
.await
.unwrap();
let json = order.to_json("http://localhost:3000", &[]);
assert!(json["notBefore"].as_str().unwrap().ends_with('Z'));
assert!(json["notAfter"].as_str().unwrap().ends_with('Z'));
}
#[tokio::test]
async fn finalize_persists_and_syncs() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\n...".to_string(),
"aabbcc".to_string(),
vec![1, 2, 3],
Some(now_secs() + 90 * 24 * 60 * 60),
&db,
)
.await
.unwrap();
assert_eq!(order.status, OrderStatus::Valid);
assert!(order.certificate.is_some());
assert_eq!(order.cert_serial.as_deref(), Some("aabbcc"));
assert_eq!(order.cert_pubkey.as_deref(), Some(&[1u8, 2, 3][..]));
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status, OrderStatus::Valid);
assert_eq!(reloaded.cert_serial.as_deref(), Some("aabbcc"));
assert_eq!(reloaded.cert_pubkey.as_deref(), Some(&[1u8, 2, 3][..]));
assert!(reloaded.cert_not_after.is_some());
let json = reloaded.to_json("http://localhost:3000", &[]);
assert_eq!(
json["certificate"],
format!("http://localhost:3000/certificate/{}", order.id)
);
}
#[tokio::test]
async fn only_one_caller_can_claim_an_order_for_finalize() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
order.mark_ready(&db).await.unwrap();
let mut rival = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(order.claim_for_finalize(&db).await.unwrap());
assert_eq!(order.status, OrderStatus::Processing);
assert!(!rival.claim_for_finalize(&db).await.unwrap());
assert_eq!(rival.status, OrderStatus::Ready);
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status, OrderStatus::Processing);
}
#[tokio::test]
async fn an_order_that_is_not_ready_cannot_be_claimed() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
for prepare in [
None,
Some(OrderStatus::Valid),
Some(OrderStatus::Invalid),
] {
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
match prepare {
None => {}
Some(OrderStatus::Valid) => order
.finalize("chain".to_string(), "aa".to_string(), vec![1], None, &db)
.await
.unwrap(),
Some(_) => assert!(
order
.mark_invalid(serde_json::json!({}), &db)
.await
.unwrap()
),
}
let before = order.status;
assert!(
!order.claim_for_finalize(&db).await.unwrap(),
"claimed an order in {before}"
);
assert_eq!(order.status, before);
}
}
async fn finalized_order(db: Arc<Database>, serial: &str) -> Order {
let acct = account_id(&db).await;
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\n...".to_string(),
serial.to_string(),
vec![9, 9, 9],
None,
&db,
)
.await
.unwrap();
order
}
#[tokio::test]
async fn find_by_cert_serial_round_trip() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let order = finalized_order(db.clone(), "deadbeef").await;
let found = Order::find_by_cert_serial("default", "deadbeef", &db)
.await
.unwrap()
.unwrap();
assert_eq!(found.id, order.id);
assert!(
Order::find_by_cert_serial("default", "unknown", &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn revoke_persists_and_syncs() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let mut order = finalized_order(db.clone(), "aa11bb22").await;
order.revoke(Some(1), &db).await.unwrap();
assert!(order.revoked_at.is_some());
assert_eq!(order.revocation_reason, Some(1));
assert_eq!(order.status, OrderStatus::Valid);
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(reloaded.revoked_at.is_some());
assert_eq!(reloaded.revocation_reason, Some(1));
assert_eq!(reloaded.status, OrderStatus::Valid);
}
#[tokio::test]
async fn revoke_with_no_reason_persists_null() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let mut order = finalized_order(db.clone(), "cc33dd44").await;
order.revoke(None, &db).await.unwrap();
assert!(order.revoked_at.is_some());
assert!(order.revocation_reason.is_none());
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(reloaded.revocation_reason.is_none());
}
#[tokio::test]
async fn to_json_never_exposes_revocation_state() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let mut order = finalized_order(db.clone(), "ee55ff66").await;
order.revoke(Some(1), &db).await.unwrap();
let json = order.to_json("http://localhost:3000", &[]);
assert!(json.get("revokedAt").is_none());
assert!(json.get("revocationReason").is_none());
assert_eq!(json["status"], "valid");
}
#[tokio::test]
async fn mark_invalid_persists_and_syncs() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let error = json!({
"type": "urn:ietf:params:acme:error:serverInternal",
"detail": "boom",
"status": 500,
});
order.mark_invalid(error.clone(), &db).await.unwrap();
assert_eq!(order.status, OrderStatus::Invalid);
assert_eq!(order.error, Some(error.clone()));
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status, OrderStatus::Invalid);
let json = reloaded.to_json("http://localhost:3000", &[]);
assert_eq!(json["error"], error);
}
#[tokio::test]
async fn delete_removes_the_row_and_reports_deleted() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
assert_eq!(
Order::delete(order.id.to_string().as_str(), &db)
.await
.unwrap(),
GuardedDelete::Deleted
);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn delete_of_unknown_id_reports_not_found() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
assert_eq!(
Order::delete("nope", &db).await.unwrap(),
GuardedDelete::NotFound
);
}
#[tokio::test]
async fn delete_cascades_to_authorizations_and_challenges() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = crate::authz::Authorization::create(
order.id,
Identifier::dns("example.com"),
now_secs() + 3600,
&db,
)
.await
.unwrap();
crate::authz::Challenge::create(authz.id, "http-01", &db)
.await
.unwrap();
Order::delete(order.id.to_string().as_str(), &db)
.await
.unwrap();
assert!(
crate::authz::Authorization::find_by_order(order.id, &db)
.await
.unwrap()
.is_empty()
);
assert!(
crate::authz::Challenge::find_by_authz(authz.id, &db)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn delete_refuses_an_order_holding_a_live_certificate() {
use crate::testutil::certified_order;
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
for not_after in [Some(now + DAY), None, Some(UNPARSABLE_NOT_AFTER)] {
let order = certified_order(&db, acct, not_after).await;
assert_eq!(
Order::count_live_certificates(order.id, &db).await.unwrap(),
1,
"{not_after:?}"
);
assert_eq!(
Order::delete(order.id.to_string().as_str(), &db)
.await
.unwrap(),
GuardedDelete::LiveCertificates(1),
"{not_after:?}"
);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_some(),
"a refused delete must leave the row"
);
}
let mut revoked = certified_order(&db, acct, Some(now + DAY)).await;
revoked.revoke(Some(1), &db).await.unwrap();
let expired = certified_order(&db, acct, Some(now - DAY)).await;
for order in [revoked, expired] {
assert_eq!(
Order::count_live_certificates(order.id, &db).await.unwrap(),
0
);
assert_eq!(
Order::delete(order.id.to_string().as_str(), &db)
.await
.unwrap(),
GuardedDelete::Deleted
);
}
}
async fn seed_orders(
db: &Arc<Database>,
profile: &str,
account_id: Uuid,
count: usize,
) -> Vec<String> {
let base = now_secs();
let mut ids = Vec::new();
for index in 0..count {
let order = Order::create(
profile,
account_id,
vec![Identifier::dns(format!("host-{index}.example.com"))],
base + 3600,
None,
None,
db,
)
.await
.unwrap();
crate::sql::query("UPDATE orders SET created_at = ? WHERE id = ?;")
.bind(base - index as i64)
.bind(order.id)
.execute(db)
.await
.unwrap();
ids.push(order.id);
}
ids.into_iter().map(|v| v.to_string()).collect()
}
fn window(limit: i64, offset: i64) -> OrderQuery {
OrderQuery {
limit,
offset,
..OrderQuery::default()
}
}
#[tokio::test]
async fn search_pages_newest_first_and_reports_the_unpaged_total() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let ids = seed_orders(&db, "default", acct, 5).await;
let (page, total) = Order::search(&window(2, 0), &db).await.unwrap();
assert_eq!(total, 5, "the total must ignore the page window");
assert_eq!(
page.iter().map(|o| o.id.to_string()).collect::<Vec<_>>(),
ids[..2]
);
let (second, total) = Order::search(&window(2, 2), &db).await.unwrap();
assert_eq!(total, 5);
assert_eq!(
second.iter().map(|o| o.id.to_string()).collect::<Vec<_>>(),
ids[2..4]
);
let (last, _) = Order::search(&window(2, 4), &db).await.unwrap();
assert_eq!(last.len(), 1);
let (beyond, total) = Order::search(&window(2, 99), &db).await.unwrap();
assert!(beyond.is_empty());
assert_eq!(total, 5, "a page past the end still reports the real total");
}
#[tokio::test]
async fn paging_one_row_at_a_time_sees_every_order_exactly_once() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let mut expected = Vec::new();
for index in 0..4 {
let order = Order::create(
"default",
acct,
vec![Identifier::dns(format!("same-second-{index}.example.com"))],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
expected.push(order.id);
}
expected.sort();
let mut seen = Vec::new();
for offset in 0..4 {
let (page, total) = Order::search(&window(1, offset), &db).await.unwrap();
assert_eq!(total, 4);
assert_eq!(page.len(), 1);
seen.push(page[0].id);
}
seen.sort();
assert_eq!(
seen, expected,
"pages must be disjoint and cover everything"
);
}
#[tokio::test]
async fn search_filters_by_profile_account_and_status_together() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let (other_account, _) = crate::account::Account::find_or_create(
"default",
&[9u8, 9, 9],
vec![],
&ClientContext::default(),
&db,
)
.await
.unwrap();
seed_orders(&db, "default", acct, 3).await;
seed_orders(&db, "default", other_account.id, 2).await;
let mut ready = seed_orders(&db, "default", acct, 1).await;
let ready_id = ready.pop().unwrap();
Order::find_by_id(&ready_id, &db)
.await
.unwrap()
.unwrap()
.mark_ready(&db)
.await
.unwrap();
let (_, total) = Order::search(&window(50, 0), &db).await.unwrap();
assert_eq!(total, 6);
let by_account = OrderQuery {
account_id: Some(acct.clone().to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&by_account, &db).await.unwrap();
assert_eq!(total, 4);
assert!(rows.iter().all(|o| o.account_id == acct));
let by_status = OrderQuery {
status: Some(OrderStatus::Ready),
..window(50, 0)
};
let (rows, total) = Order::search(&by_status, &db).await.unwrap();
assert_eq!(total, 1);
assert_eq!(rows[0].id.to_string(), ready_id);
let combined = OrderQuery {
profile: Some("default".to_string()),
account_id: Some(acct.clone().to_string()),
status: Some(OrderStatus::Pending),
..window(50, 0)
};
let (rows, total) = Order::search(&combined, &db).await.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(total, 3);
let none = OrderQuery {
profile: Some("no-such-profile".to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&none, &db).await.unwrap();
assert!(rows.is_empty());
assert_eq!(total, 0);
}
#[tokio::test]
async fn search_scopes_by_profile() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
seed_orders(&db, "default", acct, 2).await;
seed_orders(&db, "other", acct, 3).await;
let scoped = OrderQuery {
profile: Some("other".to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&scoped, &db).await.unwrap();
assert_eq!(total, 3);
assert!(rows.iter().all(|o| o.profile == "other"));
}
#[tokio::test]
async fn a_filter_value_is_bound_not_interpolated() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
seed_orders(&db, "default", acct, 2).await;
for hostile in ["' OR 1=1 --", "default'; DROP TABLE orders; --"] {
let by_profile = OrderQuery {
profile: Some(hostile.to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&by_profile, &db).await.unwrap();
assert!(rows.is_empty(), "the value must be compared, not executed");
assert_eq!(total, 0);
let by_account = OrderQuery {
account_id: Some(hostile.to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&by_account, &db).await.unwrap();
assert!(rows.is_empty(), "the value must be compared, not executed");
assert_eq!(total, 0);
for query in [
OrderQuery {
identifier: Some(hostile.to_string()),
..window(50, 0)
},
OrderQuery {
identifier_contains: Some(hostile.to_string()),
..window(50, 0)
},
OrderQuery {
cert_serial: Some(hostile.to_string()),
..window(50, 0)
},
] {
let (rows, total) = Order::search(&query, &db).await.unwrap();
assert!(rows.is_empty(), "the value must be compared, not executed");
assert_eq!(total, 0);
}
}
let (_, total) = Order::search(&window(50, 0), &db).await.unwrap();
assert_eq!(total, 2);
}
async fn seed_named(
db: &Arc<Database>,
profile: &str,
account_id: Uuid,
names: &[&str],
) -> Vec<String> {
let mut ids = Vec::new();
for name in names {
let order = Order::create(
profile,
account_id,
vec![Identifier::dns(*name)],
now_secs() + 3600,
None,
None,
db,
)
.await
.unwrap();
ids.push(order.id.to_string());
}
ids
}
#[tokio::test]
async fn search_filters_by_identifier_exactly() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let ids = seed_named(
&db,
"default",
acct,
&["example.com", "sub.example.com", "evil-example.com"],
)
.await;
for needle in ["example.com", "EXAMPLE.CoM"] {
let query = OrderQuery {
identifier: Some(needle.to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&query, &db).await.unwrap();
assert_eq!(total, 1, "needle {needle}");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id.to_string(), ids[0]);
}
let query = OrderQuery {
identifier: Some("other.example.com".to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&query, &db).await.unwrap();
assert!(rows.is_empty());
assert_eq!(total, 0);
}
#[tokio::test]
async fn an_exact_identifier_hunt_does_not_reach_through_a_wildcard() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
seed_named(&db, "default", acct, &["*.example.com"]).await;
let hunt = |needle: &str| {
let query = OrderQuery {
identifier: Some(needle.to_string()),
..window(50, 0)
};
let db = db.clone();
async move { Order::search(&query, &db).await.unwrap().1 }
};
assert_eq!(hunt("host.example.com").await, 0, "exact means exact");
assert_eq!(hunt("*.example.com").await, 1, "the stored form matches");
let query = OrderQuery {
identifier_contains: Some("example.com".to_string()),
..window(50, 0)
};
assert_eq!(Order::search(&query, &db).await.unwrap().1, 1);
}
#[tokio::test]
async fn search_filters_by_identifier_substring() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
seed_named(
&db,
"default",
acct,
&[
"example.com",
"sub.example.com",
"evil-example.com",
"elsewhere.test",
],
)
.await;
let query = OrderQuery {
identifier_contains: Some("example.com".to_string()),
..window(50, 0)
};
let (_, total) = Order::search(&query, &db).await.unwrap();
assert_eq!(total, 3);
let query = OrderQuery {
identifier_contains: Some("%".to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&query, &db).await.unwrap();
assert!(rows.is_empty(), "`%` is a literal here, not a wildcard");
assert_eq!(total, 0);
}
#[tokio::test]
async fn search_filters_by_cert_serial() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let order = finalized_order(db.clone(), "0a1b2c3d").await;
let query = OrderQuery {
cert_serial: Some("0a1b2c3d".to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&query, &db).await.unwrap();
assert_eq!(total, 1);
assert_eq!(rows[0].id, order.id);
let query = OrderQuery {
cert_serial: Some("ffffffff".to_string()),
..window(50, 0)
};
let (rows, total) = Order::search(&query, &db).await.unwrap();
assert!(rows.is_empty());
assert_eq!(total, 0);
let query = OrderQuery {
cert_serial: Some("0A1B2C3D".to_string()),
..window(50, 0)
};
assert_eq!(
Order::search(&query, &db).await.unwrap().1,
0,
"the model compares raw; the front ends normalize"
);
assert_eq!(
acme_proxy_core::cert::normalize_serial("0A:1B:2C:3D"),
"0a1b2c3d"
);
}
async fn expiring_order(
db: &Database,
account: uuid::Uuid,
names: &[&str],
not_after: Option<i64>,
) -> Order {
expiring_order_on(db, "default", account, names, not_after).await
}
async fn expiring_order_on(
db: &Database,
profile: &str,
account: uuid::Uuid,
names: &[&str],
not_after: Option<i64>,
) -> Order {
let mut order = Order::create(
profile,
account,
names.iter().map(|name| Identifier::dns(*name)).collect(),
now_secs() + 3600,
None,
None,
db,
)
.await
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\n...".to_string(),
format!("serial-{}", &order.id.to_string()[..8]),
vec![1],
not_after,
db,
)
.await
.unwrap();
order
}
const DAY: i64 = 24 * 60 * 60;
#[tokio::test]
async fn find_expiring_returns_the_window_soonest_first() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
let far = expiring_order(&db, acct, &["far.example.com"], Some(now + 60 * DAY)).await;
let soon = expiring_order(&db, acct, &["soon.example.com"], Some(now + 2 * DAY)).await;
let mid = expiring_order(&db, acct, &["mid.example.com"], Some(now + 9 * DAY)).await;
let (page, total) = Order::find_expiring(Some("default"), now + 14 * DAY, 10, 0, &db)
.await
.unwrap();
let ids: Vec<String> = page.iter().map(|order| order.id.to_string()).collect();
assert_eq!(ids, vec![soon.id.to_string(), mid.id.to_string()]);
assert_eq!(total, 2);
assert!(
!ids.contains(&far.id.to_string()),
"a certificate outside the window is not expiring yet"
);
}
#[tokio::test]
async fn find_expiring_skips_revoked_unstamped_and_unparsable_rows() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
let live = expiring_order(&db, acct, &["live.example.com"], Some(now + DAY)).await;
let mut revoked =
expiring_order(&db, acct, &["revoked.example.com"], Some(now + DAY)).await;
revoked.revoke(Some(1), &db).await.unwrap();
expiring_order(&db, acct, &["old.example.com"], None).await;
let broken = expiring_order(&db, acct, &["broken.example.com"], None).await;
Order::set_cert_not_after(broken.id, -1, &db).await.unwrap();
let (page, total) = Order::find_expiring(Some("default"), now + 14 * DAY, 10, 0, &db)
.await
.unwrap();
let ids: Vec<String> = page.iter().map(|order| order.id.to_string()).collect();
assert_eq!(ids, vec![live.id.to_string()]);
assert_eq!(total, 1);
}
#[tokio::test]
async fn find_expiring_reports_the_unpaged_total() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
for index in 0..5 {
let name = format!("host-{index}.example.com");
expiring_order(&db, acct, &[name.as_str()], Some(now + DAY)).await;
}
let (page, total) = Order::find_expiring(Some("default"), now + 14 * DAY, 2, 0, &db)
.await
.unwrap();
assert_eq!(page.len(), 2);
assert_eq!(total, 5);
}
#[tokio::test]
async fn find_expiring_scopes_by_profile() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
expiring_order(&db, acct, &["a.example.com"], Some(now + DAY)).await;
let (page, total) = Order::find_expiring(Some("other"), now + 14 * DAY, 10, 0, &db)
.await
.unwrap();
assert!(page.is_empty());
assert_eq!(total, 0);
}
#[tokio::test]
async fn find_expiring_unscoped_spans_every_profile() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
let here =
expiring_order_on(&db, "default", acct, &["a.example.com"], Some(now + DAY)).await;
let there =
expiring_order_on(&db, "other", acct, &["b.example.com"], Some(now + 2 * DAY)).await;
let (page, total) = Order::find_expiring(None, now + 14 * DAY, 10, 0, &db)
.await
.unwrap();
let ids: Vec<String> = page.iter().map(|order| order.id.to_string()).collect();
assert_eq!(ids, vec![here.id.to_string(), there.id.to_string()]);
assert_eq!(total, 2);
let mut revoked =
expiring_order_on(&db, "other", acct, &["c.example.com"], Some(now + DAY)).await;
revoked.revoke(Some(1), &db).await.unwrap();
let (page, total) = Order::find_expiring(None, now + 14 * DAY, 10, 0, &db)
.await
.unwrap();
assert_eq!(page.len(), 2);
assert_eq!(total, 2);
}
#[tokio::test]
async fn find_expiring_pages_without_overlap_and_keeps_the_unpaged_total() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let now = now_secs();
for index in 0..5 {
let name = format!("host-{index}.example.com");
expiring_order(&db, acct, &[name.as_str()], Some(now + (index + 1) * DAY)).await;
}
let (first, total) = Order::find_expiring(None, now + 14 * DAY, 2, 0, &db)
.await
.unwrap();
let (second, second_total) = Order::find_expiring(None, now + 14 * DAY, 2, 2, &db)
.await
.unwrap();
assert_eq!(total, 5);
assert_eq!(second_total, 5, "the total is unpaged on every window");
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
let firsts: Vec<String> = first.iter().map(|order| order.id.to_string()).collect();
for order in &second {
assert!(
!firsts.contains(&order.id.to_string()),
"a row must not appear on two pages"
);
}
let (past, _) = Order::find_expiring(None, now + 14 * DAY, 2, 50, &db)
.await
.unwrap();
assert!(past.is_empty());
}
#[tokio::test]
async fn find_unstamped_finds_only_issued_rows_with_no_stamp() {
let db = Arc::new(Database::connect_for_test().await.unwrap());
let acct = account_id(&db).await;
let unstamped = expiring_order(&db, acct, &["old.example.com"], None).await;
expiring_order(&db, acct, &["new.example.com"], Some(now_secs())).await;
Order::create(
"default",
acct,
vec![Identifier::dns("pending.example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let rows = Order::find_unstamped("default", 10, &db).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, unstamped.id);
Order::set_cert_not_after(unstamped.id, -1, &db)
.await
.unwrap();
assert!(
Order::find_unstamped("default", 10, &db)
.await
.unwrap()
.is_empty()
);
}
}