acme-proxy 0.5.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
//! The periodic table sweeps, as jobs.
//!
//! Four tables accumulate rows nobody deletes on the way past — spent nonces,
//! audit rows past their retention, expired admin sessions, and the queue's own
//! settled jobs. Each used to be its own `tokio::spawn` + `tokio::time::interval`
//! loop, and each was a near-copy of the others: the same
//! `MissedTickBehavior::Delay`, the same skipped first tick, the same
//! `match cleanup(...)` arms. Only the fourth was ever a job.
//!
//! They are all one handler now, over a [`SweepTarget`] that says which table.
//! Three things come out of that beyond the deletion of three spawn functions:
//!
//! - **A sweep that dies is reclaimed.** An interval loop that panicked was gone
//!   until the next restart, and nothing said so. A lease expires and another
//!   runner takes the row.
//! - **The schedule survives a restart.** A server restarting more often than
//!   once a day used to skip the daily sweeps for ever, because each start reset
//!   the interval. The row keeps its own `run_at`.
//! - **One mechanism.** [`JobOutcome::Reschedule`] is how periodic work is
//!   spelled here, and there is no longer a second answer to "how do I run
//!   something every N seconds".
//!
//! What it costs is a dependency: the sweeps stop if the runner does. That is
//! the trade, and it is the reason `run` **never returns
//! [`JobOutcome::Failed`]** — a retired periodic job does not re-enqueue itself,
//! so a single transient database error would silently stop a sweep for the life
//! of the process.

use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use tracing::{debug, error, info, warn};

use super::{JobHandler, JobOutcome, JobQueue, JobSpec};
use crate::sqlite::db::Database;
use crate::sqlite::job::Job;

/// The one row's `dedup_key`, for every sweep.
///
/// A constant, because there is exactly one occurrence of each — and the partial
/// identity index is what turns that sentence into a guarantee.
const SWEEP_KEY: &str = "all";

/// The `jobs.kind` the queue's own retention sweep runs under.
///
/// Named for the job it has always been rather than renamed to match its three
/// new siblings: rows already carry this string, and the freeze on `migrations/`
/// is not the only place a stored value outlives the code that wrote it.
pub const RETENTION_JOB_KIND: &str = "job_retention_sweep";

/// The `jobs.kind` the expired-nonce sweep runs under.
pub const NONCE_SWEEP_KIND: &str = "nonce_sweep";

/// The `jobs.kind` the audit-retention sweep runs under.
pub const AUDIT_SWEEP_KIND: &str = "audit_sweep";

/// The `jobs.kind` the admin-session sweep runs under.
pub const ADMIN_SESSION_SWEEP_KIND: &str = "admin_session_sweep";

/// The order-retention sweep's job kind.
pub const ORDER_SWEEP_KIND: &str = "order_sweep";

/// How often the daily sweeps run.
///
/// Both settings behind them have a resolution of days, so anything finer is
/// work for nothing — and an audit row a few hours past a 90-day retention is
/// nobody's problem.
const DAILY: Duration = Duration::from_secs(24 * 60 * 60);

/// Which table a sweep deletes from, and what it needs to know to do it.
///
/// `Clone` but not `Copy`: the order sweep carries one entry per mounted
/// profile, `order.retention_days` being a per-profile key.
#[derive(Debug, Clone)]
enum SweepTarget {
    /// Expired nonces.
    Nonces { ttl: Duration },
    /// Audit rows past `audit.retention_days`.
    AuditLog { retention_days: u64 },
    /// Admin sessions past their absolute or idle deadline.
    AdminSessions { idle_timeout: Duration },
    /// Settled job rows past `jobs.retention_days`.
    Jobs { retention_days: u64 },
    /// Expired, non-`valid` orders past each profile's `order.retention_days`,
    /// as `(profile, retention_days)`.
    Orders { retention: Vec<(String, u64)> },
}

/// One periodic table sweep.
pub struct SweepJob {
    target: SweepTarget,
    interval: Duration,
    database: Arc<Database>,
}

impl SweepJob {
    /// The expired-nonce sweep.
    ///
    /// Runs every half TTL rather than every TTL: sweeping once per lifetime
    /// leaves a row alive for up to *twice* its TTL, so the table sits at roughly
    /// two TTLs' worth of traffic. Halving that costs one query per period.
    /// Floored at 30s so a very short `nonce.ttl_seconds` cannot turn this into a
    /// busy loop taking the WAL writer lock.
    #[must_use]
    pub fn nonces(database: Arc<Database>, ttl: Duration) -> Self {
        Self {
            target: SweepTarget::Nonces { ttl },
            interval: (ttl / 2).max(Duration::from_secs(30)),
            database,
        }
    }

    /// The audit-retention sweep. Registered only when
    /// `audit.retention_days` is non-zero — `0` means "keep everything", which
    /// is a handler not registered rather than a `DELETE` with a cutoff at the
    /// epoch.
    #[must_use]
    pub fn audit(database: Arc<Database>, retention_days: u64) -> Self {
        Self {
            target: SweepTarget::AuditLog { retention_days },
            interval: DAILY,
            database,
        }
    }

    /// The admin-session sweep, registered only when the admin listener exists.
    ///
    /// Often enough that a revoked session's row does not linger for hours,
    /// rarely enough to be invisible.
    #[must_use]
    pub fn admin_sessions(
        database: Arc<Database>,
        idle_timeout: Duration,
        ttl_seconds: u64,
    ) -> Self {
        Self {
            target: SweepTarget::AdminSessions { idle_timeout },
            interval: Duration::from_secs((ttl_seconds / 4).max(60)),
            database,
        }
    }

    /// The order-retention sweep, one handler covering every mounted profile.
    ///
    /// One handler and not one per profile because `JobRegistry::register`
    /// refuses a second handler for a kind — the same constraint that makes
    /// `SignerBackend::crl_pruner` hand over state rather than a job. Profiles
    /// whose `order.retention_days` is `0` are simply absent from `retention`,
    /// so "keep everything" stays a profile the sweep never touches rather than
    /// a `DELETE` with a cutoff at the epoch.
    #[must_use]
    pub fn orders(database: Arc<Database>, retention: Vec<(String, u64)>) -> Self {
        Self {
            target: SweepTarget::Orders { retention },
            interval: DAILY,
            database,
        }
    }

    /// The queue's own sweep. Registered only when `jobs.retention_days` is
    /// non-zero, for the same reason as the audit one.
    #[must_use]
    pub fn jobs(database: Arc<Database>, retention_days: u64) -> Self {
        Self {
            target: SweepTarget::Jobs { retention_days },
            interval: DAILY,
            database,
        }
    }

    /// How often this sweep runs.
    #[must_use]
    pub fn interval(&self) -> Duration {
        self.interval
    }

    /// Deletes one table's worth, logging what went and swallowing a failure.
    ///
    /// The event names are the ones these sweeps have always emitted, from back
    /// when each was its own reaper task: an operator's dashboards and
    /// `doc/src/operations/monitoring.md` name them, and the mechanism changing
    /// underneath is not a reason to make them re-learn the vocabulary.
    async fn sweep(&self) {
        match &self.target {
            SweepTarget::Nonces { ttl } => {
                match crate::sqlite::nonce::Nonce::cleanup(&self.database, *ttl).await {
                    Ok(removed) => debug!(
                        event = "nonce_reaper_swept",
                        outcome = "success",
                        rows_removed = removed
                    ),
                    Err(error) => {
                        error!(event = "nonce_reaper_failed", outcome = "failure", error = %error);
                    }
                }
            }
            SweepTarget::AuditLog { retention_days } => {
                // The same cutoff arithmetic `audit cleanup --older-than` uses,
                // so "older than N days" means one thing in the process.
                let cutoff = crate::admin::ops::audit_cutoff(*retention_days);
                match crate::sqlite::audit::AuditEntry::cleanup(cutoff, &self.database).await {
                    Ok(removed) => info!(
                        event = "audit_reaper_swept",
                        outcome = "success",
                        rows_removed = removed,
                        cutoff
                    ),
                    Err(error) => {
                        error!(event = "audit_reaper_failed", outcome = "failure", error = %error);
                    }
                }
            }
            SweepTarget::AdminSessions { idle_timeout } => {
                match crate::sqlite::admin_session::AdminSession::cleanup(
                    *idle_timeout,
                    &self.database,
                )
                .await
                {
                    Ok(removed) => debug!(
                        event = "admin_session_reaper_swept",
                        outcome = "success",
                        rows_removed = removed
                    ),
                    Err(error) => {
                        error!(event = "admin_session_reaper_failed", outcome = "failure", error = %error);
                    }
                }
            }
            SweepTarget::Jobs { retention_days } => {
                let cutoff = crate::admin::ops::audit_cutoff(*retention_days);
                if let Err(error) = Job::cleanup(cutoff, &self.database).await {
                    warn!(event = "job_retention_sweep_failed", outcome = "failure", error = %error);
                }
            }
            SweepTarget::Orders { retention } => {
                // One statement per profile rather than one over all of them:
                // the retention is per-profile, and a profile whose delete
                // fails must not stop the others being swept.
                for (profile, retention_days) in retention {
                    let cutoff = crate::admin::ops::audit_cutoff(*retention_days);
                    match crate::sqlite::order::Order::cleanup(profile, cutoff, &self.database)
                        .await
                    {
                        Ok(removed) => info!(
                            event = "order_reaper_swept",
                            outcome = "success",
                            profile = %profile,
                            rows_removed = removed,
                            cutoff
                        ),
                        Err(error) => {
                            error!(event = "order_reaper_failed", outcome = "failure", profile = %profile, error = %error);
                        }
                    }
                }
            }
        }
    }
}

#[async_trait]
impl JobHandler for SweepJob {
    fn kind(&self) -> &'static str {
        match &self.target {
            SweepTarget::Nonces { .. } => NONCE_SWEEP_KIND,
            SweepTarget::AuditLog { .. } => AUDIT_SWEEP_KIND,
            SweepTarget::AdminSessions { .. } => ADMIN_SESSION_SWEEP_KIND,
            SweepTarget::Jobs { .. } => RETENTION_JOB_KIND,
            SweepTarget::Orders { .. } => ORDER_SWEEP_KIND,
        }
    }

    async fn run(&self, _job: &Job) -> JobOutcome {
        self.sweep().await;
        // **Never `Failed`**, whatever happened above — see the module docs.
        JobOutcome::Reschedule(self.interval)
    }

    /// Puts the single occurrence back in the queue at startup, which is also
    /// what performs the first sweep: `run_at` is now, so the runner claims it
    /// on its first pass and there is no separate startup sweep to write.
    ///
    /// Harmless to run again — the identity index refuses a second live row for
    /// this kind, so a restart resumes the existing schedule rather than
    /// resetting it, and a server restarting more often than the interval no
    /// longer skips the sweep for ever.
    async fn recover(&self, queue: &JobQueue) {
        queue
            .enqueue_or_log(JobSpec::now(self.kind(), SWEEP_KEY))
            .await;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::JobsConfig;
    use crate::sqlite::nonce::{Nonce, now_secs};
    use serde_json::json;

    async fn setup() -> (Arc<Database>, JobQueue) {
        let database = Arc::new(Database::connect_in_memory().await.unwrap());
        let queue = JobQueue::new(database.clone(), &JobsConfig::default());
        (database, queue)
    }

    /// A claimed row, as the runner would hand one to `run`.
    fn row(kind: &str) -> Job {
        Job {
            id: crate::sqlite::id::mint(),
            kind: kind.to_string(),
            dedup_key: SWEEP_KEY.to_string(),
            payload: json!({}),
            status: "running".to_string(),
            run_at: now_secs(),
            attempts: 1,
            max_attempts: 5,
            deadline: None,
            lease_until: None,
            lease_owner: None,
            last_error: None,
            created_at: now_secs(),
            updated_at: now_secs(),
        }
    }

    /// Every sweep answers a distinct kind, or two of them would claim each
    /// other's rows — the registry refuses a duplicate, so this would be a
    /// startup failure rather than silent, but it is cheaper to assert here.
    #[tokio::test]
    async fn each_target_answers_its_own_kind() {
        let (database, _queue) = setup().await;
        let kinds = [
            SweepJob::nonces(database.clone(), Duration::from_secs(300)).kind(),
            SweepJob::audit(database.clone(), 7).kind(),
            SweepJob::admin_sessions(database.clone(), Duration::from_secs(3600), 43200).kind(),
            SweepJob::jobs(database.clone(), 7).kind(),
            SweepJob::orders(database, vec![("default".to_string(), 30)]).kind(),
        ];
        let unique: std::collections::BTreeSet<_> = kinds.iter().collect();
        assert_eq!(unique.len(), kinds.len(), "{kinds:?}");
    }

    /// The intervals the three former reapers were derived at, which moved here
    /// with them. The floors are what stop a short TTL becoming a busy loop
    /// taking the WAL writer lock on every tick.
    #[tokio::test]
    async fn the_intervals_keep_their_floors() {
        let (database, _queue) = setup().await;

        // Half the TTL, floored at 30s.
        assert_eq!(
            SweepJob::nonces(database.clone(), Duration::from_secs(300)).interval(),
            Duration::from_secs(150)
        );
        assert_eq!(
            SweepJob::nonces(database.clone(), Duration::from_secs(10)).interval(),
            Duration::from_secs(30)
        );

        // A quarter of the session TTL, floored at 60s.
        assert_eq!(
            SweepJob::admin_sessions(database.clone(), Duration::from_secs(3600), 43200).interval(),
            Duration::from_secs(10800)
        );
        assert_eq!(
            SweepJob::admin_sessions(database.clone(), Duration::from_secs(3600), 4).interval(),
            Duration::from_secs(60)
        );

        // Daily, both of them.
        assert_eq!(SweepJob::audit(database.clone(), 7).interval(), DAILY);
        assert_eq!(SweepJob::jobs(database, 7).interval(), DAILY);
    }

    #[tokio::test]
    async fn the_nonce_sweep_removes_expired_rows_and_reschedules() {
        let (database, _queue) = setup().await;
        let ttl = Duration::from_secs(300);

        let fresh = Nonce::new();
        fresh.save(&database).await.unwrap();
        sqlx::query("INSERT INTO nonces (value, created_at) VALUES ('stale', ?);")
            .bind(now_secs() - 3600)
            .execute(&database.pool)
            .await
            .unwrap();

        let handler = SweepJob::nonces(database.clone(), ttl);
        match handler.run(&row(NONCE_SWEEP_KIND)).await {
            JobOutcome::Reschedule(delay) => assert_eq!(delay, Duration::from_secs(150)),
            other => panic!("{other:?}"),
        }

        assert!(
            !Nonce::verify("stale", &database, ttl).await.unwrap(),
            "the expired nonce is gone"
        );
        assert!(
            Nonce::verify(&fresh.value, &database, ttl).await.unwrap(),
            "a fresh nonce survives its own sweep"
        );
    }

    #[tokio::test]
    async fn the_audit_sweep_removes_rows_past_the_retention() {
        let (database, _queue) = setup().await;
        // Written through the model, then backdated: the row's shape is the
        // production one, and only its age is a fixture.
        crate::sqlite::audit::AuditEntry::insert(
            crate::audit::AuditRecord::new(
                crate::audit::AuditEvent::CertificateIssued,
                "default",
                crate::audit::Actor::system(),
            ),
            &database,
        )
        .await
        .unwrap();
        let stale = now_secs() - 30 * 24 * 60 * 60;
        sqlx::query("UPDATE audit_log SET created_at = ?;")
            .bind(stale)
            .execute(&database.pool)
            .await
            .unwrap();

        let handler = SweepJob::audit(database.clone(), 7);
        assert!(matches!(
            handler.run(&row(AUDIT_SWEEP_KIND)).await,
            JobOutcome::Reschedule(_)
        ));

        let (remaining,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_log;")
            .fetch_one(&database.pool)
            .await
            .unwrap();
        assert_eq!(remaining, 0);
    }

    #[tokio::test]
    async fn the_admin_session_sweep_removes_expired_rows() {
        let (database, _queue) = setup().await;
        crate::sqlite::admin_user::AdminUser::create("ops", "hash", None, &database)
            .await
            .unwrap();
        let user = crate::sqlite::admin_user::AdminUser::find_by_username("ops", &database)
            .await
            .unwrap()
            .unwrap();
        crate::sqlite::admin_session::AdminSession::create(
            crate::sqlite::admin_session::NewSession {
                user_id: user.id,
                token_hash: "hash",
                csrf_token: "csrf",
                created_ip: None,
                user_agent: None,
            },
            Duration::from_secs(3600),
            &database,
        )
        .await
        .unwrap();
        // Backdated past its own absolute deadline, which is what the sweep
        // deletes on.
        sqlx::query("UPDATE admin_sessions SET expires_at = ?;")
            .bind(now_secs() - 60)
            .execute(&database.pool)
            .await
            .unwrap();

        let handler = SweepJob::admin_sessions(database.clone(), Duration::from_secs(3600), 43200);
        assert!(matches!(
            handler.run(&row(ADMIN_SESSION_SWEEP_KIND)).await,
            JobOutcome::Reschedule(_)
        ));

        let (remaining,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM admin_sessions;")
            .fetch_one(&database.pool)
            .await
            .unwrap();
        assert_eq!(remaining, 0);
    }

    #[tokio::test]
    async fn the_queue_sweep_removes_settled_rows() {
        let (database, _queue) = setup().await;
        let stale = now_secs() - 30 * 24 * 60 * 60;
        // A real id, not a readable placeholder: `Job::find_by_id` takes a
        // `Uuid`, so a row this test cannot name is a row it cannot assert the
        // absence of — which is the whole assertion below.
        let old = crate::sqlite::id::mint();
        sqlx::query(
            "INSERT INTO jobs (id, kind, dedup_key, payload, status, run_at, attempts, \
             max_attempts, created_at, updated_at) \
             VALUES (?, 'test', 'k', '{}', 'failed', ?, 1, 1, ?, ?);",
        )
        .bind(old)
        .bind(stale)
        .bind(stale)
        .bind(stale)
        .execute(&database.pool)
        .await
        .unwrap();
        assert!(
            Job::find_by_id(old, &database).await.unwrap().is_some(),
            "the fixture must be there before the sweep, or its absence after \
             proves nothing"
        );

        let handler = SweepJob::jobs(database.clone(), 7);
        match handler.run(&row(RETENTION_JOB_KIND)).await {
            JobOutcome::Reschedule(delay) => assert_eq!(delay, DAILY),
            other => panic!("{other:?}"),
        }
        assert!(Job::find_by_id(old, &database).await.unwrap().is_none());
    }

    /// One row, whatever happens: the property that makes a periodic job a queue
    /// row rather than a growing pile of them.
    #[tokio::test]
    async fn recovery_queues_one_occurrence_however_often_it_runs() {
        let (database, queue) = setup().await;
        let handler = SweepJob::nonces(database.clone(), Duration::from_secs(300));

        handler.recover(&queue).await;
        handler.recover(&queue).await;
        handler.recover(&queue).await;

        assert_eq!(
            Job::count_live(NONCE_SWEEP_KIND, &database).await.unwrap(),
            1
        );
    }

    /// The law the whole module rests on: a sweep that could not reach the
    /// database must still be scheduled again. A `Failed` here would retire the
    /// row, and a retired periodic job never re-enqueues itself — so one
    /// transient error would stop that sweep for the life of the process.
    #[tokio::test]
    async fn no_target_ever_retires_itself_on_a_failure() {
        let (database, _queue) = setup().await;
        database.pool.close().await;

        for handler in [
            SweepJob::nonces(database.clone(), Duration::from_secs(300)),
            SweepJob::audit(database.clone(), 7),
            SweepJob::admin_sessions(database.clone(), Duration::from_secs(3600), 43200),
            SweepJob::jobs(database.clone(), 7),
            SweepJob::orders(database.clone(), vec![("default".to_string(), 30)]),
        ] {
            let kind = handler.kind();
            assert!(
                matches!(handler.run(&row(kind)).await, JobOutcome::Reschedule(_)),
                "{kind} must reschedule rather than retire"
            );
        }
    }

    /// The order sweep takes expired, undecided orders and leaves everything a
    /// certificate depends on.
    ///
    /// The `valid` case is the one that matters. A valid order's row is how
    /// `Order::find_by_cert_serial` resolves a certificate for `revokeCert` and
    /// for the CRL — sweeping one would make an issued certificate unrevokable,
    /// which is a far worse outcome than a large table, and no age makes it
    /// acceptable. So the row is aged well past the cutoff here on purpose: the
    /// exclusion has to be about the *status* and not about the clock.
    #[tokio::test]
    async fn the_order_sweep_spares_valid_orders_and_takes_expired_ones() {
        use crate::sqlite::account::Account;
        use crate::sqlite::order::{Identifier, Order};

        let (database, _queue) = setup().await;
        let (account, _) = Account::find_or_create(
            "default",
            &[3u8; 8],
            vec![],
            &crate::audit::ClientContext::default(),
            &database,
        )
        .await
        .unwrap();

        let ancient = now_secs() - 400 * 24 * 60 * 60;
        let mut ids = Vec::new();
        for _ in 0..3 {
            let order = Order::create(
                "default",
                account.id,
                vec![Identifier::dns("example.com")],
                ancient,
                None,
                None,
                &database,
            )
            .await
            .unwrap();
            ids.push(order.id);
        }
        let (expired_pending, expired_valid, fresh) = (&ids[0], &ids[1], &ids[2]);

        // One long-expired order that nevertheless carries a certificate...
        sqlx::query("UPDATE orders SET status = 'valid' WHERE id = ?;")
            .bind(expired_valid)
            .execute(&database.pool)
            .await
            .unwrap();
        // ...and one that has not expired at all.
        sqlx::query("UPDATE orders SET expires = ? WHERE id = ?;")
            .bind(now_secs() + 3600)
            .bind(fresh)
            .execute(&database.pool)
            .await
            .unwrap();

        let removed = Order::cleanup("default", crate::admin::ops::audit_cutoff(30), &database)
            .await
            .unwrap();

        assert_eq!(removed, 1, "only the expired, undecided order goes");
        assert!(
            Order::find_by_id(&expired_pending.to_string(), &database)
                .await
                .unwrap()
                .is_none()
        );
        assert!(
            Order::find_by_id(&expired_valid.to_string(), &database)
                .await
                .unwrap()
                .is_some(),
            "a valid order is never swept: its row is how a certificate is revoked"
        );
        assert!(
            Order::find_by_id(&fresh.to_string(), &database)
                .await
                .unwrap()
                .is_some()
        );
    }

    /// A profile keeps its own history: the sweep is one handler, so a second
    /// profile's rows must not go out with the first's.
    #[tokio::test]
    async fn the_order_sweep_is_scoped_to_one_profile() {
        use crate::sqlite::account::Account;
        use crate::sqlite::order::{Identifier, Order};

        let (database, _queue) = setup().await;
        let ancient = now_secs() - 400 * 24 * 60 * 60;
        let mut ids = Vec::new();
        for (index, profile) in ["default", "other"].iter().enumerate() {
            let (account, _) = Account::find_or_create(
                profile,
                &[index as u8 + 40; 8],
                vec![],
                &crate::audit::ClientContext::default(),
                &database,
            )
            .await
            .unwrap();
            let order = Order::create(
                profile,
                account.id,
                vec![Identifier::dns("example.com")],
                ancient,
                None,
                None,
                &database,
            )
            .await
            .unwrap();
            ids.push(order.id);
        }

        let removed = Order::cleanup("default", crate::admin::ops::audit_cutoff(30), &database)
            .await
            .unwrap();

        assert_eq!(removed, 1);
        assert!(
            Order::find_by_id(ids[0].to_string().as_str(), &database)
                .await
                .unwrap()
                .is_none()
        );
        assert!(
            Order::find_by_id(ids[1].to_string().as_str(), &database)
                .await
                .unwrap()
                .is_some(),
            "another profile's orders are not this profile's to delete"
        );
    }
}