use std::collections::{BTreeSet, HashMap};
use std::io::BufRead;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use crate::admin::prompt::confirm;
use crate::audit::{Actor, AuditEvent, AuditRecord, ClientContext};
use crate::config::Config;
use crate::signer::relay::{RELAY_JOB_KIND, abandon_relayed_order};
use crate::signer::{SignerBackend, SignerError};
use crate::sqlite::account::Account;
use crate::sqlite::audit::{AuditEntry, AuditQuery};
use crate::sqlite::authz::{Authorization, Challenge};
use crate::sqlite::db::Database;
use crate::sqlite::job::Job;
use crate::sqlite::nonce::{Nonce, now_secs};
use crate::sqlite::order::{Order, UNPARSABLE_NOT_AFTER};
use crate::sqlite::status::JobStatus;
use crate::sqlite::upstream_order::{UpstreamOrder, UpstreamOrderRow};
#[derive(Debug, PartialEq, Eq)]
pub enum DeleteOutcome {
NotFound,
Cancelled,
Deleted(Deleted),
}
#[derive(Debug, PartialEq, Eq)]
pub struct Deleted {
pub cascaded: u64,
}
#[derive(Debug)]
pub struct OrderDetail {
pub order: Order,
pub authorizations: Vec<(Authorization, Vec<Challenge>)>,
}
#[derive(Debug)]
pub enum RevokeOutcome {
NotFound,
NotIssued,
AlreadyRevoked,
Revoked(Box<Order>),
}
fn signer_detail(error: &SignerError) -> String {
match error {
SignerError::Internal(detail) => detail.clone(),
SignerError::BadCsr => "unexpected badCsr from revoke".to_string(),
}
}
#[derive(Debug, thiserror::Error)]
pub enum RevokeError {
#[error("database error: {0}")]
Database(sqlx::Error),
#[error("signer error: {}", signer_detail(.0))]
Signer(SignerError),
#[error("internal error: {0}")]
Internal(String),
#[error("unsupported revocation reason code {0}")]
BadReason(u32),
}
impl From<sqlx::Error> for RevokeError {
fn from(error: sqlx::Error) -> Self {
Self::Database(error)
}
}
impl From<SignerError> for RevokeError {
fn from(error: SignerError) -> Self {
Self::Signer(error)
}
}
pub async fn delete_account(
id: &str,
database: Arc<Database>,
) -> Result<Option<Deleted>, sqlx::Error> {
let Some(cascaded) = account_cascade(id, database.clone()).await? else {
return Ok(None);
};
Account::delete(id, &database).await?;
Ok(Some(Deleted { cascaded }))
}
pub async fn confirm_delete_account(
id: &str,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<DeleteOutcome, sqlx::Error> {
let Some(account) = Account::find_any_by_id(id, &database).await? else {
return Ok(DeleteOutcome::NotFound);
};
let order_count = Order::count_by_account(account.id, &database).await?;
let prompt = format!(
"Delete account {id} (status: {}, {order_count} order(s) will cascade)?",
account.status
);
if !confirm(&prompt, assume_yes, reader) {
return Ok(DeleteOutcome::Cancelled);
}
Account::delete(id, &database).await?;
Ok(DeleteOutcome::Deleted(Deleted {
cascaded: order_count as u64,
}))
}
pub async fn delete_order(
id: &str,
database: Arc<Database>,
) -> Result<Option<Deleted>, sqlx::Error> {
let Some(cascaded) = order_cascade(id, database.clone()).await? else {
return Ok(None);
};
Order::delete(id, &database).await?;
Ok(Some(Deleted { cascaded }))
}
pub async fn confirm_delete_order(
id: &str,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<DeleteOutcome, sqlx::Error> {
let Some(order) = Order::find_by_id(id, &database).await? else {
return Ok(DeleteOutcome::NotFound);
};
let authz_count = Authorization::count_by_order(order.id, &database).await?;
let prompt = format!(
"Delete order {id} (status: {}, {authz_count} authorization(s) will cascade)?",
order.status
);
if !confirm(&prompt, assume_yes, reader) {
return Ok(DeleteOutcome::Cancelled);
}
Order::delete(id, &database).await?;
Ok(DeleteOutcome::Deleted(Deleted {
cascaded: authz_count as u64,
}))
}
pub async fn cleanup_nonces(ttl: Duration, database: Arc<Database>) -> Result<u64, sqlx::Error> {
Nonce::cleanup(&database, ttl).await
}
pub async fn confirm_cleanup_nonces(
ttl: Duration,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<Option<u64>, sqlx::Error> {
let prompt = format!("Delete all nonces older than {}s?", ttl.as_secs());
if !confirm(&prompt, assume_yes, reader) {
return Ok(None);
}
Ok(Some(cleanup_nonces(ttl, database).await?))
}
async fn account_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
let Some(account) = Account::find_any_by_id(id, &database).await? else {
return Ok(None);
};
Ok(Some(
Order::count_by_account(account.id, &database).await? as u64,
))
}
async fn order_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
let Some(order) = Order::find_by_id(id, &database).await? else {
return Ok(None);
};
Ok(Some(
Authorization::count_by_order(order.id, &database).await? as u64,
))
}
pub async fn update_account_contact(
id: &str,
contact: Vec<String>,
database: Arc<Database>,
) -> Result<Option<Account>, sqlx::Error> {
let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
return Ok(None);
};
account.update_contact(contact, &database).await?;
Ok(Some(account))
}
pub async fn deactivate_account(
id: &str,
database: Arc<Database>,
) -> Result<Option<Account>, sqlx::Error> {
let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
return Ok(None);
};
account.deactivate(&database).await?;
Ok(Some(account))
}
pub async fn revoke_order(
id: &str,
reason: Option<u32>,
actor: Actor,
client: ClientContext,
database: Arc<Database>,
signer: Arc<dyn SignerBackend>,
) -> Result<RevokeOutcome, RevokeError> {
let Some(mut order) = Order::find_by_id(id, &database).await? else {
return Ok(RevokeOutcome::NotFound);
};
let Some(chain) = order.certificate.clone() else {
return Ok(RevokeOutcome::NotIssued);
};
if order.revoked_at.is_some() {
return Ok(RevokeOutcome::AlreadyRevoked);
}
if let Some(r) = reason
&& !crate::cert::is_valid_revocation_reason(r)
{
return Err(RevokeError::BadReason(r));
}
let cert_der = crate::cert::leaf_der_from_chain(&chain).map_err(|error| {
RevokeError::Internal(format!("stored certificate chain is unparsable: {error}"))
})?;
let mut record = AuditRecord::new(
AuditEvent::CertificateRevoked,
&order.profile,
actor.clone(),
)
.with_order(&order)
.with_client(client.clone());
if let Some(serial) = order.cert_serial.clone() {
record = record.with_serial(serial);
}
if let Some(reason) = reason {
record = record.with_reason(reason.to_string());
}
if let Err(error) = signer.revoke(&cert_der, reason).await {
crate::audit::write(
AuditRecord::new(AuditEvent::CertificateRevokeFailed, &order.profile, actor)
.with_order(&order)
.with_client(client)
.with_reason("serverInternal")
.with_detail(error.to_string()),
&database,
)
.await;
return Err(error.into());
}
order.revoke(reason.map(i64::from), &database).await?;
crate::audit::write(record, &database).await;
Ok(RevokeOutcome::Revoked(Box::new(order)))
}
#[must_use]
pub fn audit_cutoff(days: u64) -> i64 {
let seconds = i64::try_from(days.saturating_mul(24 * 60 * 60)).unwrap_or(i64::MAX);
crate::sqlite::nonce::now_secs().saturating_sub(seconds)
}
pub async fn list_audit(
query: &AuditQuery,
database: Arc<Database>,
) -> Result<(Vec<AuditEntry>, i64), sqlx::Error> {
AuditEntry::search(query, &database).await
}
pub async fn find_audit(
id: i64,
database: Arc<Database>,
) -> Result<Option<AuditEntry>, sqlx::Error> {
AuditEntry::find_by_id(id, &database).await
}
pub async fn cleanup_audit(days: u64, database: Arc<Database>) -> Result<u64, sqlx::Error> {
AuditEntry::cleanup(audit_cutoff(days), &database).await
}
pub async fn confirm_cleanup_audit(
days: u64,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<Option<u64>, sqlx::Error> {
let cutoff = audit_cutoff(days);
let doomed = AuditEntry::count_older_than(cutoff, &database).await?;
let prompt =
format!("Delete {doomed} audit row(s) older than {days} day(s)? This cannot be undone.");
if !confirm(&prompt, assume_yes, reader) {
return Ok(None);
}
Ok(Some(AuditEntry::cleanup(cutoff, &database).await?))
}
pub async fn load_order_detail(
id: &str,
database: Arc<Database>,
) -> Result<Option<OrderDetail>, sqlx::Error> {
let Some(order) = Order::find_by_id(id, &database).await? else {
return Ok(None);
};
let authzs = Authorization::find_by_order(order.id, &database).await?;
let mut authorizations = Vec::with_capacity(authzs.len());
for authz in authzs {
let challenges = Challenge::find_by_authz(authz.id, &database).await?;
authorizations.push((authz, challenges));
}
Ok(Some(OrderDetail {
order,
authorizations,
}))
}
#[derive(Debug)]
pub struct JobDetail {
pub job: Job,
pub upstream_order: Option<UpstreamOrderRow>,
}
#[derive(Debug)]
pub struct UpstreamOrderDetail {
pub upstream_order: UpstreamOrderRow,
pub job: Option<Job>,
}
const PERIODIC_JOB_KINDS: &[&str] = &[
crate::jobs::sweep::RETENTION_JOB_KIND,
crate::jobs::sweep::NONCE_SWEEP_KIND,
crate::jobs::sweep::AUDIT_SWEEP_KIND,
crate::jobs::sweep::ADMIN_SESSION_SWEEP_KIND,
crate::jobs::sweep::ORDER_SWEEP_KIND,
crate::signer::local_ca::sweep::CRL_SWEEP_KIND,
crate::notify::expiry::EXPIRY_JOB_KIND,
];
#[must_use]
pub fn is_periodic_job_kind(kind: &str) -> bool {
PERIODIC_JOB_KINDS.contains(&kind)
}
pub async fn load_job_detail(
id: &str,
database: Arc<Database>,
) -> Result<Option<JobDetail>, sqlx::Error> {
let Some(job_id) = crate::sqlite::id::parse(id) else {
return Ok(None);
};
let Some(job) = Job::find_by_id(job_id, &database).await? else {
return Ok(None);
};
let upstream_order = if job.kind == RELAY_JOB_KIND {
UpstreamOrder::find_row_by_order_id(&job.dedup_key, &database).await?
} else {
None
};
Ok(Some(JobDetail {
job,
upstream_order,
}))
}
pub async fn load_upstream_order_detail(
order_id: &str,
database: Arc<Database>,
) -> Result<Option<UpstreamOrderDetail>, sqlx::Error> {
let Some(upstream_order) = UpstreamOrder::find_row_by_order_id(order_id, &database).await?
else {
return Ok(None);
};
let job = Job::find_latest_by_dedup(
RELAY_JOB_KIND,
&upstream_order.order_id.to_string(),
&database,
)
.await?;
Ok(Some(UpstreamOrderDetail {
upstream_order,
job,
}))
}
#[derive(Debug)]
pub enum CancelJobOutcome {
NotFound,
NotCancellable(String),
Cancelled(Box<Job>),
CancelledAndOrderAbandoned {
job: Box<Job>,
order_id: String,
},
}
#[derive(Debug)]
pub enum RunJobNowOutcome {
NotFound,
Refused(String),
Nudged(Box<Job>),
Revived(Box<Job>),
}
#[derive(Debug, thiserror::Error)]
pub enum CancelJobError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
}
pub async fn cancel_job(
id: &str,
actor: Actor,
client: ClientContext,
metrics: Option<&Arc<crate::metrics::Metrics>>,
database: Arc<Database>,
) -> Result<CancelJobOutcome, CancelJobError> {
let Some(job_id) = crate::sqlite::id::parse(id) else {
return Ok(CancelJobOutcome::NotFound);
};
let (job, was_in_flight) = match Job::cancel_row(job_id, JobStatus::Ready, &database).await? {
Some(job) => (job, true),
None => match Job::cancel_row(job_id, JobStatus::Failed, &database).await? {
Some(job) => (job, false),
None => {
return Ok(match Job::find_by_id(job_id, &database).await? {
None => CancelJobOutcome::NotFound,
Some(job) => CancelJobOutcome::NotCancellable(job.status),
});
}
},
};
if job.kind == RELAY_JOB_KIND && was_in_flight {
let order_id = job.dedup_key.clone();
if let Some(mut order) = Order::find_by_id(&order_id, &database).await? {
abandon_relayed_order(
&mut order,
"issuance cancelled by operator",
actor,
client,
&database,
metrics,
)
.await?;
return Ok(CancelJobOutcome::CancelledAndOrderAbandoned {
job: Box::new(job),
order_id,
});
}
}
crate::audit::write(
crate::audit::admin::job_cancelled(actor, client, &job.kind, &job.id.to_string()),
&database,
)
.await;
Ok(CancelJobOutcome::Cancelled(Box::new(job)))
}
pub async fn confirm_cancel_job(
id: &str,
assume_yes: bool,
reader: &mut impl BufRead,
actor: Actor,
client: ClientContext,
database: Arc<Database>,
) -> Result<Option<CancelJobOutcome>, CancelJobError> {
let Some(job_id) = crate::sqlite::id::parse(id) else {
return Ok(Some(CancelJobOutcome::NotFound));
};
let Some(job) = Job::find_by_id(job_id, &database).await? else {
return Ok(Some(CancelJobOutcome::NotFound));
};
let prompt = if job.kind == RELAY_JOB_KIND && job.status == JobStatus::Ready.as_str() {
format!(
"Cancel relay job {id}? Order {} will be marked invalid.",
job.dedup_key
)
} else if is_periodic_job_kind(&job.kind) {
format!(
"Cancel job {id} ({})? This periodic sweep will not run again until \
the server restarts.",
job.kind
)
} else {
format!("Cancel job {id} ({})?", job.kind)
};
if !confirm(&prompt, assume_yes, reader) {
return Ok(None);
}
Ok(Some(cancel_job(id, actor, client, None, database).await?))
}
pub async fn run_job_now(
id: &str,
actor: Actor,
client: ClientContext,
database: Arc<Database>,
) -> Result<RunJobNowOutcome, sqlx::Error> {
let Some(job_id) = crate::sqlite::id::parse(id) else {
return Ok(RunJobNowOutcome::NotFound);
};
if let Some(job) = Job::advance_row(job_id, &database).await? {
crate::audit::write(
crate::audit::admin::job_advanced(actor, client, &job.id.to_string(), false),
&database,
)
.await;
return Ok(RunJobNowOutcome::Nudged(Box::new(job)));
}
if let Some(job) = Job::revive_row(job_id, &database).await? {
crate::audit::write(
crate::audit::admin::job_advanced(actor, client, &job.id.to_string(), true),
&database,
)
.await;
return Ok(RunJobNowOutcome::Revived(Box::new(job)));
}
Ok(match Job::find_by_id(job_id, &database).await? {
None => RunJobNowOutcome::NotFound,
Some(job) => RunJobNowOutcome::Refused(job.status),
})
}
const DEFAULT_LEAD_DAYS: u64 = 30;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SupersededBy {
pub order_id: String,
pub cert_serial: String,
pub not_after: i64,
pub via: String,
}
#[derive(Debug)]
pub struct ExpiringEntry {
pub order: Order,
pub days_remaining: i64,
pub superseded_by: Option<SupersededBy>,
}
pub struct ExpiringQuery {
pub profile: Option<String>,
pub before: i64,
pub include_superseded: bool,
pub limit: i64,
pub offset: i64,
}
#[must_use]
pub fn expiring_horizon(days: u64) -> i64 {
let seconds = i64::try_from(days.saturating_mul(24 * 60 * 60)).unwrap_or(i64::MAX);
now_secs().saturating_add(seconds)
}
#[must_use]
pub fn days_remaining(not_after: i64, now: i64) -> i64 {
not_after.saturating_sub(now).max(0) / (24 * 60 * 60)
}
#[must_use]
pub fn default_lead_days(config: &Config) -> u64 {
match config.notify.expiry.lead_days {
0 => DEFAULT_LEAD_DAYS,
days => days,
}
}
pub async fn superseded_by(
order: &Order,
candidates: &[Order],
database: &Database,
) -> Result<Option<SupersededBy>, sqlx::Error> {
if let Some(chain) = order.certificate.as_deref()
&& let Some(cert_id) = ari_cert_id(chain)
&& let Some(successor) = Order::find_by_replaces(&order.profile, &cert_id, database).await?
&& successor.certificate.is_some()
&& successor.revoked_at.is_none()
{
return Ok(Some(SupersededBy {
order_id: successor.id.to_string(),
cert_serial: successor.cert_serial.unwrap_or_default(),
not_after: successor.cert_not_after.unwrap_or_default(),
via: "replaces".to_string(),
}));
}
let names: BTreeSet<&str> = order
.identifiers
.iter()
.map(|identifier| identifier.value.as_str())
.collect();
let expires = order.cert_not_after.unwrap_or_default();
for candidate in candidates {
if candidate.id == order.id
|| candidate.certificate.is_none()
|| candidate.revoked_at.is_some()
|| candidate.cert_not_after.unwrap_or(UNPARSABLE_NOT_AFTER) <= expires
{
continue;
}
let covered: BTreeSet<&str> = candidate
.identifiers
.iter()
.map(|identifier| identifier.value.as_str())
.collect();
if names.is_subset(&covered) {
return Ok(Some(SupersededBy {
order_id: candidate.id.to_string(),
cert_serial: candidate.cert_serial.clone().unwrap_or_default(),
not_after: candidate.cert_not_after.unwrap_or_default(),
via: "identifiers".to_string(),
}));
}
}
Ok(None)
}
pub async fn annotate_expiring(
orders: Vec<Order>,
database: &Database,
) -> Result<Vec<ExpiringEntry>, sqlx::Error> {
let now = now_secs();
let mut by_account: HashMap<Uuid, Vec<Order>> = HashMap::new();
let mut entries = Vec::with_capacity(orders.len());
for order in orders {
if let std::collections::hash_map::Entry::Vacant(slot) = by_account.entry(order.account_id)
{
slot.insert(Order::find_by_account(order.account_id, database).await?);
}
let candidates = by_account
.get(&order.account_id)
.map_or(&[][..], Vec::as_slice);
let superseded = superseded_by(&order, candidates, database).await?;
entries.push(ExpiringEntry {
days_remaining: days_remaining(order.cert_not_after.unwrap_or_default(), now),
superseded_by: superseded,
order,
});
}
Ok(entries)
}
pub async fn list_expiring(
query: &ExpiringQuery,
database: Arc<Database>,
) -> Result<(Vec<ExpiringEntry>, i64, i64), sqlx::Error> {
let (orders, total) = Order::find_expiring(
query.profile.as_deref(),
query.before,
query.limit,
query.offset,
&database,
)
.await?;
let entries = annotate_expiring(orders, &database).await?;
if query.include_superseded {
return Ok((entries, total, 0));
}
let annotated = i64::try_from(entries.len()).unwrap_or(i64::MAX);
let kept: Vec<ExpiringEntry> = entries
.into_iter()
.filter(|entry| entry.superseded_by.is_none())
.collect();
let hidden = annotated.saturating_sub(i64::try_from(kept.len()).unwrap_or(i64::MAX));
Ok((kept, total, hidden))
}
fn ari_cert_id(chain: &str) -> Option<String> {
crate::cert::leaf_der_from_chain(chain)
.ok()
.and_then(|der| crate::cert::ari_cert_id(&der).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sqlite::order::Identifier;
use crate::testutil::{account_id, issued_order};
const DAY: i64 = 24 * 60 * 60;
async fn db() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
async fn issued(
db: &Database,
account: uuid::Uuid,
names: &[&str],
not_after_days: i64,
) -> Order {
issued_order(db, "default", account, names, not_after_days).await
}
async fn annotation(order: &Order, db: &Database) -> Option<SupersededBy> {
let candidates = Order::find_by_account(order.account_id, db).await.unwrap();
superseded_by(order, &candidates, db).await.unwrap()
}
fn cli_actor() -> Actor {
Actor::cli()
}
async fn audit_rows(db: &Arc<Database>) -> Vec<AuditEntry> {
AuditEntry::search(
&AuditQuery {
limit: 50,
..AuditQuery::default()
},
db,
)
.await
.unwrap()
.0
}
#[test]
fn the_audit_cutoff_is_days_before_now_and_saturates_rather_than_overflowing() {
let now = crate::sqlite::nonce::now_secs();
assert!((audit_cutoff(0) - now).abs() <= 1);
let week = audit_cutoff(7);
assert!((now - week - 7 * 24 * 60 * 60).abs() <= 1, "{week}");
assert!(audit_cutoff(u64::MAX) <= now);
}
#[tokio::test]
async fn cleaning_the_audit_trail_is_confirm_gated_and_bounded_by_age() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
&db,
)
.await
.unwrap();
let mut declined: &[u8] = b"n\n";
assert_eq!(
confirm_cleanup_audit(0, false, &mut declined, db.clone())
.await
.unwrap(),
None
);
assert_eq!(audit_rows(&db).await.len(), 1);
let mut reader: &[u8] = &[];
assert_eq!(
confirm_cleanup_audit(7, true, &mut reader, db.clone())
.await
.unwrap(),
Some(0)
);
assert_eq!(audit_rows(&db).await.len(), 1);
assert_eq!(cleanup_audit(0, db.clone()).await.unwrap(), 0);
assert_eq!(
AuditEntry::cleanup(audit_cutoff(0) + 3600, &db)
.await
.unwrap(),
1
);
assert!(audit_rows(&db).await.is_empty());
}
#[tokio::test]
async fn listing_and_finding_audit_rows_pages_and_resolves() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let mut ids = Vec::new();
for _ in 0..3 {
ids.push(
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
&db,
)
.await
.unwrap(),
);
}
let (page, total) = list_audit(
&AuditQuery {
limit: 2,
..AuditQuery::default()
},
db.clone(),
)
.await
.unwrap();
assert_eq!(total, 3);
assert_eq!(page.len(), 2);
assert!(find_audit(ids[0], db.clone()).await.unwrap().is_some());
assert!(find_audit(9_999, db).await.unwrap().is_none());
}
#[tokio::test]
async fn revoking_writes_one_audit_row_naming_the_caller() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca();
let order = finalized_order(db.clone(), &signer).await;
let outcome = revoke_order(
order.id.to_string().as_str(),
Some(1),
Actor::admin("root"),
ClientContext {
ip: Some("203.0.113.7".to_string()),
ptr: Some("desk.example.com".to_string()),
..ClientContext::default()
},
db.clone(),
signer.clone(),
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::Revoked(_)));
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1, "{rows:?}");
let row = &rows[0];
assert_eq!(row.event, "certificate_revoked");
assert_eq!(row.outcome, "success");
assert_eq!(row.actor_kind, "admin");
assert_eq!(row.actor_id.as_deref(), Some("root"));
assert_eq!(row.account_id, Some(order.account_id.to_string()));
assert_eq!(row.order_id, Some(order.id.to_string()));
assert_eq!(row.cert_serial, order.cert_serial);
assert_eq!(row.client_ip.as_deref(), Some("203.0.113.7"));
assert_eq!(row.client_ptr.as_deref(), Some("desk.example.com"));
assert_eq!(row.reason.as_deref(), Some("1"));
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
Actor::admin("root"),
ClientContext::default(),
db.clone(),
signer,
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
assert_eq!(audit_rows(&db).await.len(), 1);
}
#[tokio::test]
async fn a_revocation_with_no_reason_leaves_the_column_absent() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca();
let order = finalized_order(db.clone(), &signer).await;
revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
db.clone(),
signer,
)
.await
.unwrap();
let rows = audit_rows(&db).await;
assert_eq!(rows[0].reason, None);
assert_eq!(rows[0].actor_kind, "cli");
assert_eq!(rows[0].client_ip, None);
assert_eq!(rows[0].client_ptr, None);
}
#[tokio::test]
async fn delete_account_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let mut reader: &[u8] = &[];
let outcome = confirm_delete_account("nope", true, &mut reader, db)
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::NotFound);
}
#[tokio::test]
async fn delete_account_cancelled_leaves_row() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let mut reader = b"n\n".as_slice();
let outcome =
confirm_delete_account(acct.to_string().as_str(), false, &mut reader, db.clone())
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::Cancelled);
assert!(
Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn delete_account_confirmed_deletes_and_cascades() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
let outcome =
confirm_delete_account(acct.to_string().as_str(), true, &mut reader, db.clone())
.await
.unwrap();
assert!(matches!(outcome, DeleteOutcome::Deleted(_)));
assert!(
Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn delete_order_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let mut reader: &[u8] = &[];
let outcome = confirm_delete_order("nope", true, &mut reader, db)
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::NotFound);
}
#[tokio::test]
async fn delete_order_cancelled_leaves_row() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let mut reader = b"no\n".as_slice();
let outcome = confirm_delete_order(
order.id.to_string().as_str(),
false,
&mut reader,
db.clone(),
)
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::Cancelled);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn delete_order_confirmed_deletes_and_cascades() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = Authorization::create(
order.id,
Identifier::dns("example.com"),
crate::sqlite::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
let outcome =
confirm_delete_order(order.id.to_string().as_str(), true, &mut reader, db.clone())
.await
.unwrap();
assert!(matches!(outcome, DeleteOutcome::Deleted(_)));
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
assert!(
Authorization::find_by_id(authz.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn bare_delete_account_reports_none_for_an_unknown_id() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert_eq!(delete_account("nope", db).await.unwrap(), None);
}
#[tokio::test]
async fn bare_delete_account_deletes_and_counts_the_cascade() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
for _ in 0..2 {
Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
}
assert_eq!(
delete_account(acct.to_string().as_str(), db.clone())
.await
.unwrap(),
Some(Deleted { cascaded: 2 })
);
assert!(
Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn bare_delete_order_reports_none_for_an_unknown_id() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert_eq!(delete_order("nope", db).await.unwrap(), None);
}
#[tokio::test]
async fn bare_delete_order_deletes_and_counts_the_cascade() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
Authorization::create(
order.id,
Identifier::dns("example.com"),
crate::sqlite::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
assert_eq!(
delete_order(order.id.to_string().as_str(), db.clone())
.await
.unwrap(),
Some(Deleted { cascaded: 1 })
);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn bare_cleanup_nonces_removes_stale_rows_without_asking() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let stale = Nonce {
value: "stale".to_string(),
created_at: crate::sqlite::nonce::now_secs() - 10_000,
};
stale.save(&db).await.unwrap();
Nonce::new().save(&db).await.unwrap();
assert_eq!(
cleanup_nonces(Duration::from_secs(300), db.clone())
.await
.unwrap(),
1
);
assert!(
!Nonce::verify("stale", &db, Duration::from_secs(300))
.await
.unwrap()
);
}
fn in_memory_ca() -> Arc<dyn SignerBackend> {
Arc::new(
crate::signer::local_ca::LocalCa::generate_in_memory("ecdsa-p256", 90)
.expect("in-memory CA"),
)
}
async fn finalized_order(db: Arc<Database>, signer: &Arc<dyn SignerBackend>) -> Order {
let acct = account_id(&db).await;
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
let csr = params.serialize_request(&key_pair).unwrap();
let chain = match signer
.issue(
order.id.to_string().as_str(),
csr.der(),
&order.identifiers,
crate::signer::RequestedValidity::default(),
)
.await
.unwrap()
{
crate::signer::IssueOutcome::Issued(chain) => chain,
crate::signer::IssueOutcome::Processing => {
panic!("the in-memory local CA issues synchronously")
}
};
let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
let (serial, pubkey) = crate::cert::cert_serial_and_spki(&leaf).unwrap();
let not_after = crate::cert::cert_validity(&leaf).ok().map(|(_, na)| na);
order
.finalize(chain, serial, pubkey, not_after, &db)
.await
.unwrap();
order
}
#[tokio::test]
async fn revoke_order_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let outcome = revoke_order(
"nope",
None,
cli_actor(),
ClientContext::default(),
db,
in_memory_ca(),
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::NotFound));
}
#[tokio::test]
async fn revoke_order_without_a_certificate_is_refused() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
db,
in_memory_ca(),
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::NotIssued));
}
#[tokio::test]
async fn revoke_order_persists() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca();
let order = finalized_order(db.clone(), &signer).await;
let outcome = revoke_order(
order.id.to_string().as_str(),
Some(1),
cli_actor(),
ClientContext::default(),
db.clone(),
signer.clone(),
)
.await
.unwrap();
let RevokeOutcome::Revoked(revoked) = outcome else {
panic!("expected Revoked, got {outcome:?}");
};
assert!(revoked.revoked_at.is_some());
assert_eq!(revoked.revocation_reason, Some(1));
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(reloaded.revoked_at.is_some());
use x509_parser::prelude::FromDer;
let der = signer.crl_der().await.unwrap();
let (_, crl) =
x509_parser::revocation_list::CertificateRevocationList::from_der(&der).unwrap();
assert_eq!(crl.iter_revoked_certificates().count(), 1);
}
#[tokio::test]
async fn revoke_order_already_revoked() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca();
let order = finalized_order(db.clone(), &signer).await;
revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
db.clone(),
signer.clone(),
)
.await
.unwrap();
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
db,
signer,
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
}
#[tokio::test]
async fn revoke_order_bad_reason_is_refused() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca();
let order = finalized_order(db.clone(), &signer).await;
let error = revoke_order(
order.id.to_string().as_str(),
Some(999),
cli_actor(),
ClientContext::default(),
db,
signer,
)
.await
.unwrap_err();
assert!(matches!(error, RevokeError::BadReason(999)));
}
#[tokio::test]
async fn cleanup_nonces_cancelled_leaves_nonces() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
Nonce {
value: "stale".to_string(),
created_at: crate::sqlite::nonce::now_secs() - 600,
}
.save(&db)
.await
.unwrap();
let mut reader = b"n\n".as_slice();
let outcome =
confirm_cleanup_nonces(Duration::from_secs(300), false, &mut reader, db.clone())
.await
.unwrap();
assert_eq!(outcome, None);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn cleanup_nonces_confirmed_removes_stale_and_reports_count() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
Nonce {
value: "stale".to_string(),
created_at: crate::sqlite::nonce::now_secs() - 600,
}
.save(&db)
.await
.unwrap();
let mut reader: &[u8] = &[];
let outcome =
confirm_cleanup_nonces(Duration::from_secs(300), true, &mut reader, db.clone())
.await
.unwrap();
assert_eq!(outcome, Some(1));
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn update_account_contact_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(
update_account_contact("nope", vec![], db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn update_account_contact_persists() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let contact = vec!["mailto:a@example.com".to_string()];
let updated =
update_account_contact(acct.to_string().as_str(), contact.clone(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(updated.contact, contact);
let reloaded = Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.contact, contact);
}
#[tokio::test]
async fn deactivate_account_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(deactivate_account("nope", db).await.unwrap().is_none());
}
#[tokio::test]
async fn deactivate_account_persists() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let updated = deactivate_account(acct.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(updated.status, "deactivated");
let reloaded = Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status, "deactivated");
}
#[tokio::test]
async fn load_order_detail_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(load_order_detail("nope", db).await.unwrap().is_none());
}
#[tokio::test]
async fn load_order_detail_nests_authorizations_and_challenges() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = Authorization::create(
order.id,
Identifier::dns("example.com"),
crate::sqlite::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
Challenge::create(authz.id, "http-01", &db).await.unwrap();
let detail = load_order_detail(order.id.to_string().as_str(), db)
.await
.unwrap()
.unwrap();
assert_eq!(detail.order.id, order.id);
assert_eq!(detail.authorizations.len(), 1);
assert_eq!(detail.authorizations[0].0.id, authz.id);
assert_eq!(detail.authorizations[0].1.len(), 1);
assert_eq!(detail.authorizations[0].1[0].typ, "http-01");
}
use crate::sqlite::job::{Job, NewJob};
async fn relay_job(db: &Arc<Database>) -> (Order, Job) {
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();
UpstreamOrder::create(
order.id.to_string().as_str(),
"https://up.example/o/1",
None,
b"csr",
db,
)
.await
.unwrap();
let id = crate::sqlite::id::mint();
Job::enqueue(
NewJob {
id,
kind: RELAY_JOB_KIND,
dedup_key: &order.id.to_string(),
payload: &serde_json::json!({ "order_id": order.id.to_string(), "profile": "default" }),
run_at: now_secs(),
deadline: Some(order.expires),
max_attempts: 5,
},
db,
)
.await
.unwrap();
let job = Job::find_by_id(id, db).await.unwrap().unwrap();
(order, job)
}
async fn sweep_job(db: &Arc<Database>) -> Job {
let id = crate::sqlite::id::mint();
Job::enqueue(
NewJob {
id,
kind: "nonce_sweep",
dedup_key: "nonce_sweep",
payload: &serde_json::json!({}),
run_at: now_secs() + 3600,
deadline: None,
max_attempts: 5,
},
db,
)
.await
.unwrap();
Job::find_by_id(id, db).await.unwrap().unwrap()
}
#[tokio::test]
async fn load_job_detail_attaches_the_upstream_order_only_for_a_relay_job() {
let db = db().await;
let (order, job) = relay_job(&db).await;
let detail = load_job_detail(job.id.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(detail.job.id, job.id);
assert_eq!(detail.upstream_order.as_ref().unwrap().order_id, order.id);
let sweep = sweep_job(&db).await;
let detail = load_job_detail(sweep.id.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert!(detail.upstream_order.is_none());
assert!(load_job_detail("nope", db.clone()).await.unwrap().is_none());
assert!(
load_job_detail(crate::sqlite::id::mint().to_string().as_str(), db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn load_job_detail_resolves_the_cross_link_after_the_job_is_done() {
let db = db().await;
let (_order, job) = relay_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(job.id)
.execute(&db.pool)
.await
.unwrap();
let detail = load_job_detail(job.id.to_string().as_str(), db)
.await
.unwrap()
.unwrap();
assert!(detail.upstream_order.is_some());
}
#[tokio::test]
async fn load_upstream_order_detail_finds_the_latest_job_even_when_terminal() {
let db = db().await;
let (order, job) = relay_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'failed' WHERE id = ?;")
.bind(job.id)
.execute(&db.pool)
.await
.unwrap();
let detail = load_upstream_order_detail(order.id.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(detail.upstream_order.order_id, order.id);
assert_eq!(detail.job.as_ref().unwrap().id, job.id);
assert!(
load_upstream_order_detail("nope", db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn cancel_job_on_a_ready_sweep_writes_a_plain_job_cancelled_row_and_no_order_change() {
let db = db().await;
let sweep = sweep_job(&db).await;
let outcome = cancel_job(
sweep.id.to_string().as_str(),
cli_actor(),
ClientContext::default(),
None,
db.clone(),
)
.await
.unwrap();
assert!(matches!(outcome, CancelJobOutcome::Cancelled(_)));
assert_eq!(
Job::find_by_id(sweep.id, &db)
.await
.unwrap()
.unwrap()
.status,
"cancelled"
);
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].event, "job_cancelled");
assert_eq!(rows[0].outcome, "success");
assert_eq!(rows[0].actor_kind, "cli");
assert!(rows[0].order_id.is_none());
assert!(rows[0].account_id.is_none());
}
#[tokio::test]
async fn cancel_job_on_a_ready_relay_job_abandons_the_order_and_audits_the_operator() {
let db = db().await;
let (order, job) = relay_job(&db).await;
let outcome = cancel_job(
job.id.to_string().as_str(),
Actor::admin("root"),
ClientContext {
ip: Some("203.0.113.7".to_string()),
..ClientContext::default()
},
None,
db.clone(),
)
.await
.unwrap();
let CancelJobOutcome::CancelledAndOrderAbandoned { order_id, .. } = outcome else {
panic!("expected CancelledAndOrderAbandoned, got {outcome:?}");
};
assert_eq!(order_id, order.id.to_string());
assert_eq!(
Job::find_by_id(job.id, &db).await.unwrap().unwrap().status,
"cancelled"
);
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status.as_str(), "invalid");
let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(mapping.status, "invalid");
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].event, "certificate_issue_failed");
assert_eq!(rows[0].actor_kind, "admin");
assert_eq!(rows[0].actor_id.as_deref(), Some("root"));
assert_eq!(rows[0].client_ip.as_deref(), Some("203.0.113.7"));
}
#[tokio::test]
async fn cancel_job_on_a_failed_relay_job_keeps_the_upstream_error_and_writes_one_row() {
let db = db().await;
let (order, job) = relay_job(&db).await;
crate::signer::relay::abandon_relayed_order(
&mut Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap(),
"urn:ietf:params:acme:error:rejectedIdentifier from the upstream",
Actor::cli(),
ClientContext::default(),
&db,
None,
)
.await
.unwrap();
Job::claim_next(
"runner-a",
&[RELAY_JOB_KIND],
now_secs() + 60,
now_secs(),
&db,
)
.await
.unwrap()
.expect("the relay job is claimable");
assert!(
Job::abandon(job.id, "runner-a", "gave up", &db)
.await
.unwrap()
);
let rows_before = audit_rows(&db).await.len();
let outcome = cancel_job(
job.id.to_string().as_str(),
Actor::admin("root"),
ClientContext::default(),
None,
db.clone(),
)
.await
.unwrap();
assert!(
matches!(outcome, CancelJobOutcome::Cancelled(_)),
"a failed relay job has nothing left to abandon: {outcome:?}"
);
let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(
mapping.error.as_deref(),
Some("urn:ietf:params:acme:error:rejectedIdentifier from the upstream"),
"the upstream's own reason must survive the cancellation"
);
let rows = audit_rows(&db).await;
assert_eq!(
rows.len(),
rows_before + 1,
"one issuance, one certificate_issue_failed row"
);
assert_eq!(rows[0].event, "job_cancelled");
}
#[tokio::test]
async fn cancel_job_on_a_relay_job_with_no_order_says_so_and_still_audits() {
let db = db().await;
let (order, job) = relay_job(&db).await;
Order::delete(order.id.to_string().as_str(), &db)
.await
.unwrap();
let outcome = cancel_job(
job.id.to_string().as_str(),
Actor::admin("root"),
ClientContext::default(),
None,
db.clone(),
)
.await
.unwrap();
assert!(
matches!(outcome, CancelJobOutcome::Cancelled(_)),
"nothing was abandoned, so nothing may say it was: {outcome:?}"
);
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1, "the cancellation is still an admin action");
assert_eq!(rows[0].event, "job_cancelled");
}
#[tokio::test]
async fn cancel_job_refuses_a_running_job_and_leaves_the_order_alone() {
let db = db().await;
let (order, job) = relay_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'running' WHERE id = ?;")
.bind(job.id)
.execute(&db.pool)
.await
.unwrap();
let outcome = cancel_job(
job.id.to_string().as_str(),
cli_actor(),
ClientContext::default(),
None,
db.clone(),
)
.await
.unwrap();
assert!(matches!(
outcome,
CancelJobOutcome::NotCancellable(s) if s == "running"
));
assert_ne!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap()
.status
.as_str(),
"invalid"
);
}
#[tokio::test]
async fn cancel_job_not_found_versus_not_cancellable() {
let db = db().await;
assert!(matches!(
cancel_job(
"nope",
cli_actor(),
ClientContext::default(),
None,
db.clone()
)
.await
.unwrap(),
CancelJobOutcome::NotFound
));
assert!(matches!(
cancel_job(
crate::sqlite::id::mint().to_string().as_str(),
cli_actor(),
ClientContext::default(),
None,
db.clone()
)
.await
.unwrap(),
CancelJobOutcome::NotFound
));
let sweep = sweep_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(sweep.id)
.execute(&db.pool)
.await
.unwrap();
assert!(matches!(
cancel_job(
sweep.id.to_string().as_str(),
cli_actor(),
ClientContext::default(),
None,
db,
)
.await
.unwrap(),
CancelJobOutcome::NotCancellable(s) if s == "done"
));
}
#[tokio::test]
async fn confirm_cancel_job_declined_leaves_the_job() {
let db = db().await;
let sweep = sweep_job(&db).await;
let mut reader = b"n\n".as_slice();
assert!(
confirm_cancel_job(
sweep.id.to_string().as_str(),
false,
&mut reader,
cli_actor(),
ClientContext::default(),
db.clone(),
)
.await
.unwrap()
.is_none()
);
assert_eq!(
Job::find_by_id(sweep.id, &db)
.await
.unwrap()
.unwrap()
.status,
"ready"
);
}
#[tokio::test]
async fn run_job_now_nudges_ready_revives_failed_and_refuses_the_rest() {
let db = db().await;
let sweep = sweep_job(&db).await; let outcome = run_job_now(
sweep.id.to_string().as_str(),
Actor::cli(),
ClientContext::default(),
db.clone(),
)
.await
.unwrap();
let RunJobNowOutcome::Nudged(job) = outcome else {
panic!("expected Nudged, got {outcome:?}");
};
assert!(job.run_at <= now_secs() + 1);
let (_order, relay) = relay_job(&db).await;
sqlx::query(
"UPDATE jobs SET status = 'failed', attempts = 5, last_error = 'boom' WHERE id = ?;",
)
.bind(relay.id)
.execute(&db.pool)
.await
.unwrap();
let outcome = run_job_now(
relay.id.to_string().as_str(),
Actor::cli(),
ClientContext::default(),
db.clone(),
)
.await
.unwrap();
let RunJobNowOutcome::Revived(job) = outcome else {
panic!("expected Revived, got {outcome:?}");
};
assert_eq!(job.status, "ready");
assert_eq!(job.attempts, 4, "max_attempts - 1");
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(sweep.id)
.execute(&db.pool)
.await
.unwrap();
assert!(matches!(
run_job_now(
sweep.id.to_string().as_str(),
Actor::cli(),
ClientContext::default(),
db.clone(),
).await.unwrap(),
RunJobNowOutcome::Refused(s) if s == "done"
));
assert!(matches!(
run_job_now("nope", Actor::cli(), ClientContext::default(), db)
.await
.unwrap(),
RunJobNowOutcome::NotFound
));
}
#[test]
fn revoke_error_display_formatting() {
let db_err: RevokeError = sqlx::Error::RowNotFound.into();
assert!(format!("{db_err}").contains("database error"));
let signer_internal: RevokeError = SignerError::Internal("test".to_string()).into();
assert!(format!("{signer_internal}").contains("signer error: test"));
let signer_bad_csr: RevokeError = SignerError::BadCsr.into();
assert!(format!("{signer_bad_csr}").contains("unexpected badCsr"));
let internal = RevokeError::Internal("detail".to_string());
assert!(format!("{internal}").contains("internal error: detail"));
let bad_reason = RevokeError::BadReason(7);
assert!(format!("{bad_reason}").contains("unsupported revocation reason code 7"));
}
#[tokio::test]
async fn a_replaces_claim_marks_the_predecessor_superseded() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com"], 3).await;
let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
let successor = issued(&db, acct, &["a.example.com"], 90).await;
sqlx::query("UPDATE orders SET replaces = ? WHERE id = ?;")
.bind(&cert_id)
.bind(successor.id)
.execute(&db.pool)
.await
.unwrap();
let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
let superseded = annotation(&reloaded, &db).await.unwrap();
assert_eq!(superseded.order_id, successor.id.to_string());
assert_eq!(superseded.via, "replaces");
}
#[tokio::test]
async fn a_pending_replaces_claim_supersedes_nothing() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com"], 3).await;
let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
let pending = Order::create(
"default",
acct,
vec![Identifier::dns("a.example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
sqlx::query("UPDATE orders SET replaces = ? WHERE id = ?;")
.bind(&cert_id)
.bind(pending.id)
.execute(&db.pool)
.await
.unwrap();
let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(annotation(&reloaded, &db).await.is_none());
}
#[tokio::test]
async fn a_later_certificate_over_the_same_names_supersedes() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com"], 3).await;
let new = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
let superseded = annotation(&old, &db).await.unwrap();
assert_eq!(superseded.order_id, new.id.to_string());
assert_eq!(
superseded.via, "identifiers",
"a superset covers these names, so it is a renewal"
);
}
#[tokio::test]
async fn a_partial_a_revoked_and_another_accounts_certificate_supersede_nothing() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com", "b.example.com"], 3).await;
issued(&db, acct, &["a.example.com"], 90).await;
assert!(
annotation(&old, &db).await.is_none(),
"a subset is not a renewal"
);
let mut revoked = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
revoked.revoke(Some(1), &db).await.unwrap();
assert!(
annotation(&old, &db).await.is_none(),
"a revoked certificate covers nothing"
);
let (other, _created) = crate::sqlite::account::Account::find_or_create(
"default",
b"other-key",
Vec::new(),
&crate::audit::ClientContext::default(),
&db,
)
.await
.unwrap();
issued(&db, other.id, &["a.example.com", "b.example.com"], 90).await;
assert!(
annotation(&old, &db).await.is_none(),
"another subscriber's certificate is not this one's renewal"
);
}
#[test]
fn the_horizon_and_the_day_count_agree_on_a_whole_day() {
let now = now_secs();
assert!((expiring_horizon(7) - now - 7 * DAY).abs() <= 1);
assert_eq!(expiring_horizon(u64::MAX), i64::MAX);
assert_eq!(days_remaining(1_000 + 3 * DAY + DAY / 2, 1_000), 3);
assert_eq!(days_remaining(1_000 + DAY - 1, 1_000), 0);
assert_eq!(days_remaining(1_000, 1_000 + 5 * DAY), 0);
assert_eq!(days_remaining(i64::MIN, i64::MAX), 0);
}
#[test]
fn the_default_window_falls_back_only_when_the_digest_is_off() {
let mut config = Config::default();
assert_eq!(config.notify.expiry.lead_days, 0, "off by default");
assert_eq!(default_lead_days(&config), DEFAULT_LEAD_DAYS);
config.notify.expiry.lead_days = 3;
assert_eq!(
default_lead_days(&config),
3,
"a deployment that chose a lead time gets it"
);
}
#[tokio::test]
async fn a_listing_reads_each_accounts_orders_once_and_still_annotates_each_row() {
let db = db().await;
let acct = account_id(&db).await;
let a = issued(&db, acct, &["a.example.com"], 3).await;
let b = issued(&db, acct, &["b.example.com"], 5).await;
let renewal = issued(&db, acct, &["a.example.com"], 90).await;
let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
.await
.unwrap();
let entries = annotate_expiring(orders, &db).await.unwrap();
let annotated = |id: &str| -> Option<SupersededBy> {
entries
.iter()
.find(|entry| entry.order.id.to_string() == id)
.and_then(|entry| entry.superseded_by.clone())
};
assert_eq!(
annotated(a.id.to_string().as_str()).unwrap().order_id,
renewal.id.to_string()
);
assert!(
annotated(b.id.to_string().as_str()).is_none(),
"a shared candidate list must not leak one row's renewal onto another"
);
Order::set_cert_not_after(a.id, now_secs() + 3 * DAY + DAY / 2, &db)
.await
.unwrap();
let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
.await
.unwrap();
let entries = annotate_expiring(orders, &db).await.unwrap();
let a_entry = entries.iter().find(|e| e.order.id == a.id).unwrap();
assert_eq!(a_entry.days_remaining, 3, "floored, not rounded");
}
#[tokio::test]
async fn hiding_superseded_rows_reports_the_count_rather_than_shrinking_the_total() {
let db = db().await;
let acct = account_id(&db).await;
let a = issued(&db, acct, &["a.example.com"], 3).await;
issued(&db, acct, &["b.example.com"], 5).await;
issued(&db, acct, &["a.example.com"], 90).await;
let query = |include: bool| ExpiringQuery {
profile: None,
before: expiring_horizon(30),
include_superseded: include,
limit: 50,
offset: 0,
};
let (shown, total, hidden) = list_expiring(&query(true), db.clone()).await.unwrap();
assert_eq!(shown.len(), 2, "both expiring rows, annotated");
assert_eq!(total, 2);
assert_eq!(hidden, 0);
let (kept, total, hidden) = list_expiring(&query(false), db.clone()).await.unwrap();
assert_eq!(kept.len(), 1);
assert!(kept.iter().all(|entry| entry.superseded_by.is_none()));
assert_ne!(kept[0].order.id, a.id, "the replaced row is the one hidden");
assert_eq!(hidden, 1);
assert_eq!(
total, 2,
"the total counts the window, not the answer -- documented on list_expiring"
);
}
#[tokio::test]
async fn the_listing_scopes_by_profile_and_answers_soonest_first() {
let db = db().await;
let acct = account_id(&db).await;
let here = issued_order(&db, "default", acct, &["a.example.com"], 5).await;
let sooner = issued_order(&db, "default", acct, &["b.example.com"], 2).await;
issued_order(&db, "other", acct, &["c.example.com"], 1).await;
let scoped = ExpiringQuery {
profile: Some("default".to_string()),
before: expiring_horizon(30),
include_superseded: true,
limit: 50,
offset: 0,
};
let (entries, total, _) = list_expiring(&scoped, db.clone()).await.unwrap();
let ids: Vec<String> = entries
.iter()
.map(|entry| entry.order.id.to_string())
.collect();
assert_eq!(ids, vec![sooner.id.to_string(), here.id.to_string()]);
assert_eq!(total, 2);
let unscoped = ExpiringQuery {
profile: None,
..scoped
};
let (entries, total, _) = list_expiring(&unscoped, db).await.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(total, 3);
}
}