1use std::sync::Arc;
18use std::time::Duration;
19
20use chrono::{DateTime, Utc};
21use sqlx::{PgPool, Row};
22use tokio_util::sync::CancellationToken;
23use uuid::Uuid;
24
25use crate::error::ProxyResult;
26use crate::tls::AcmeIssuer;
27
28const POLL_INTERVAL: Duration = Duration::from_secs(30);
30
31const MAX_ATTEMPTS: i32 = 10;
37
38const STUCK_AFTER: chrono::TimeDelta = chrono::TimeDelta::minutes(15);
53
54const RENEW_WITHIN: chrono::TimeDelta = chrono::TimeDelta::days(30);
59
60const BASE_BACKOFF: Duration = Duration::from_secs(60);
65
66const MAX_BACKOFF: Duration = Duration::from_secs(4 * 60 * 60);
72
73fn backoff(attempts: i32) -> Duration {
82 let shift = attempts.clamp(0, 12) as u32;
83 Duration::from_secs(BASE_BACKOFF.as_secs() << shift).min(MAX_BACKOFF)
84}
85
86struct Claim {
88 id: Uuid,
89 domain: String,
90 attempts: i32,
91}
92
93pub async fn run(pool: PgPool, issuer: Arc<AcmeIssuer>, cancel: CancellationToken) {
99 tracing::info!(
100 directory = %issuer.directory_url(),
101 "ACME issuance worker started"
102 );
103
104 loop {
105 tokio::select! {
106 _ = cancel.cancelled() => {
107 tracing::info!("ACME issuance worker stopped");
108 return;
109 }
110 _ = tokio::time::sleep(POLL_INTERVAL) => {}
111 }
112
113 if let Err(error) = tick(&pool, &issuer).await {
114 tracing::error!(%error, "ACME issuance pass failed");
115 }
116 }
117}
118
119async fn tick(pool: &PgPool, issuer: &AcmeIssuer) -> ProxyResult<()> {
121 let recovered = reclaim_abandoned(pool).await?;
122 if recovered > 0 {
123 tracing::warn!(
124 recovered,
125 "domains were left mid-issuance and have been requeued; \
126 a restart or a database error interrupted an order"
127 );
128 }
129
130 let renewals = mark_renewals(pool).await?;
131 if renewals > 0 {
132 tracing::info!(renewals, "certificates due for renewal");
133 }
134
135 while let Some(claim) = claim_next(pool).await? {
136 match issuer.issue(&claim.domain).await {
137 Ok(certificate) => {
138 mark_active(pool, claim.id, certificate.expires_at).await?;
139 tracing::info!(
140 domain = %claim.domain,
141 expires_at = ?certificate.expires_at,
142 "certificate active"
143 );
144 }
145 Err(error) => {
146 let attempts = claim.attempts + 1;
147 let give_up = attempts >= MAX_ATTEMPTS;
148 mark_failed(pool, claim.id, &error.to_string(), attempts).await?;
149 if give_up {
150 tracing::error!(
151 domain = %claim.domain,
152 attempts,
153 %error,
154 "giving up on issuance; use the ssl/retry endpoint after fixing the cause"
155 );
156 } else {
157 tracing::warn!(
158 domain = %claim.domain,
159 attempts,
160 retry_in = ?backoff(attempts),
161 %error,
162 "issuance failed, will retry"
163 );
164 }
165 }
166 }
167 }
168
169 Ok(())
170}
171
172async fn claim_next(pool: &PgPool) -> ProxyResult<Option<Claim>> {
192 let row = sqlx::query(
193 "UPDATE proxy_domains SET \
194 ssl_status = 'provisioning', \
195 ssl_last_attempt_at = NOW(), \
196 updated_at = NOW() \
197 WHERE id = ( \
198 SELECT id FROM proxy_domains \
199 WHERE verification_status = 'verified' \
200 AND ssl_provider = 'acme' \
201 AND ssl_status = 'pending' \
202 AND ssl_attempts < $1 \
203 AND ( \
204 ssl_last_attempt_at IS NULL \
205 OR ssl_last_attempt_at < NOW() - make_interval( \
206 secs => LEAST($2 * (2 ^ ssl_attempts), $3) \
207 ) \
208 ) \
209 ORDER BY ssl_last_attempt_at ASC NULLS FIRST \
210 FOR UPDATE SKIP LOCKED \
211 LIMIT 1 \
212 ) \
213 RETURNING id, domain, ssl_attempts",
214 )
215 .bind(MAX_ATTEMPTS)
216 .bind(BASE_BACKOFF.as_secs() as f64)
217 .bind(MAX_BACKOFF.as_secs() as f64)
218 .fetch_optional(pool)
219 .await?;
220
221 let Some(row) = row else { return Ok(None) };
222 Ok(Some(Claim {
223 id: row.try_get("id")?,
224 domain: row.try_get("domain")?,
225 attempts: row.try_get("ssl_attempts")?,
226 }))
227}
228
229async fn mark_active(
230 pool: &PgPool,
231 id: Uuid,
232 expires_at: Option<DateTime<Utc>>,
233) -> ProxyResult<()> {
234 sqlx::query(
235 "UPDATE proxy_domains SET \
236 ssl_status = 'active', \
237 ssl_expires_at = $2, \
238 ssl_error = NULL, \
239 ssl_attempts = 0, \
240 updated_at = NOW() \
241 WHERE id = $1",
242 )
243 .bind(id)
244 .bind(expires_at)
245 .execute(pool)
246 .await?;
247 Ok(())
248}
249
250async fn mark_failed(pool: &PgPool, id: Uuid, error: &str, attempts: i32) -> ProxyResult<()> {
257 let status = if attempts >= MAX_ATTEMPTS {
258 "failed"
259 } else {
260 "pending"
261 };
262
263 sqlx::query(
264 "UPDATE proxy_domains SET \
265 ssl_status = $3, \
266 ssl_error = $2, \
267 ssl_attempts = $4, \
268 updated_at = NOW() \
269 WHERE id = $1",
270 )
271 .bind(id)
272 .bind(error)
273 .bind(status)
274 .bind(attempts)
275 .execute(pool)
276 .await?;
277 Ok(())
278}
279
280async fn reclaim_abandoned(pool: &PgPool) -> ProxyResult<u64> {
287 let result = sqlx::query(
288 "UPDATE proxy_domains SET \
289 ssl_status = 'pending', \
290 ssl_attempts = ssl_attempts + 1, \
291 ssl_error = 'issuance was interrupted before it finished', \
292 updated_at = NOW() \
293 WHERE ssl_status = 'provisioning' \
294 AND ssl_provider = 'acme' \
295 AND ssl_last_attempt_at IS NOT NULL \
296 AND ssl_last_attempt_at < NOW() - make_interval(secs => $1)",
297 )
298 .bind(STUCK_AFTER.num_seconds() as f64)
299 .execute(pool)
300 .await?;
301
302 Ok(result.rows_affected())
303}
304
305async fn mark_renewals(pool: &PgPool) -> ProxyResult<u64> {
311 let deadline = Utc::now() + RENEW_WITHIN;
312
313 let result = sqlx::query(
314 "UPDATE proxy_domains d SET \
315 ssl_status = 'pending', \
316 ssl_attempts = 0, \
317 updated_at = NOW() \
318 WHERE d.ssl_status = 'active' \
319 AND d.ssl_provider = 'acme' \
320 AND d.verification_status = 'verified' \
321 AND EXISTS ( \
322 SELECT 1 FROM proxy_certificates c \
323 WHERE c.domain = d.domain \
324 AND c.auto_renew \
325 AND c.expires_at IS NOT NULL \
326 AND c.expires_at < $1 \
327 )",
328 )
329 .bind(deadline)
330 .execute(pool)
331 .await?;
332
333 Ok(result.rows_affected())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn backoff_grows_then_stops() {
342 assert_eq!(backoff(0), Duration::from_secs(60));
344 assert_eq!(backoff(1), Duration::from_secs(120));
345 assert_eq!(backoff(4), Duration::from_secs(16 * 60));
346
347 assert_eq!(backoff(8), MAX_BACKOFF);
350 assert_eq!(backoff(20), MAX_BACKOFF);
351 assert_eq!(backoff(i32::MAX), MAX_BACKOFF);
352 }
353
354 #[test]
355 fn the_cap_is_reachable() {
356 let first_capped = (0..=MAX_ATTEMPTS)
361 .find(|n| backoff(*n) == MAX_BACKOFF)
362 .expect("the cap is never reached within MAX_ATTEMPTS");
363 assert!(
364 first_capped < MAX_ATTEMPTS,
365 "capped only at the last attempt"
366 );
367 }
368
369 #[test]
370 fn backoff_never_panics_on_a_negative_count() {
371 assert_eq!(backoff(-1), Duration::from_secs(60));
374 assert_eq!(backoff(i32::MIN), Duration::from_secs(60));
375 }
376
377 #[test]
378 fn ten_attempts_of_backoff_span_about_a_day() {
379 let total: u64 = (0..MAX_ATTEMPTS).map(|n| backoff(n).as_secs()).sum();
382 let hours = total / 3600;
383 assert!(
384 (12..=48).contains(&hours),
385 "ten attempts span {hours}h, which no longer matches the documented ~1 day"
386 );
387 }
388
389 #[test]
390 fn renewal_threshold_leaves_room_to_notice() {
391 assert!(RENEW_WITHIN < chrono::TimeDelta::days(90));
393 assert!(RENEW_WITHIN >= chrono::TimeDelta::days(14));
394 }
395}
396
397#[cfg(test)]
404mod db_tests {
405 use super::*;
406 use sqlx::Executor;
407
408 const MIGRATIONS: [&str; 3] = [
409 include_str!("../../migrations/20240115000001_saas_domains.sql"),
410 include_str!("../../migrations/20260901000001_proxy_config.sql"),
411 include_str!("../../migrations/20260901000002_proxy_acme.sql"),
412 ];
413
414 const MIGRATION_LOCK: i64 = 0x706f7374_72757374;
416
417 const CLAIM_LOCK: i64 = 0x706f7374_61636d65;
426
427 struct Exclusive(#[allow(dead_code)] sqlx::PgConnection);
433
434 async fn exclusive() -> Exclusive {
435 use sqlx::Connection;
436 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
437 let mut conn = sqlx::PgConnection::connect(&url)
438 .await
439 .expect("could not open a dedicated connection");
440 sqlx::query("SELECT pg_advisory_lock($1)")
441 .bind(CLAIM_LOCK)
442 .execute(&mut conn)
443 .await
444 .expect("could not take the claim lock");
445
446 sqlx::query("DELETE FROM proxy_domains WHERE ssl_provider = 'acme'")
449 .execute(&mut conn)
450 .await
451 .expect("could not clear the queue");
452
453 Exclusive(conn)
454 }
455
456 async fn pool() -> PgPool {
457 let url = std::env::var("DATABASE_URL")
458 .expect("DATABASE_URL must be set for the database-backed tests");
459 let pool = PgPool::connect(&url)
460 .await
461 .expect("could not connect to DATABASE_URL");
462
463 let mut tx = pool.begin().await.expect("begin");
464 sqlx::query("SELECT pg_advisory_xact_lock($1)")
465 .bind(MIGRATION_LOCK)
466 .execute(&mut *tx)
467 .await
468 .expect("migration lock");
469 for migration in MIGRATIONS {
470 (&mut *tx)
471 .execute(migration)
472 .await
473 .expect("could not apply a migration");
474 }
475 tx.commit().await.expect("commit");
476 pool
477 }
478
479 async fn tenant(pool: &PgPool) -> Uuid {
481 sqlx::query_scalar(
482 "INSERT INTO proxy_tenants (name, slug, email) VALUES ($1, $2, $3) RETURNING id",
483 )
484 .bind("worker test")
485 .bind(format!("t-{}", Uuid::new_v4()))
486 .bind("worker@postrust.invalid")
487 .fetch_one(pool)
488 .await
489 .expect("insert tenant")
490 }
491
492 async fn domain(
494 pool: &PgPool,
495 tenant_id: Uuid,
496 ssl_status: &str,
497 attempts: i32,
498 last_attempt_secs_ago: Option<i64>,
499 ) -> (Uuid, String) {
500 let name = format!("d-{}.example", Uuid::new_v4());
501 let id: Uuid = sqlx::query_scalar(
502 "INSERT INTO proxy_domains \
503 (tenant_id, domain, verification_status, verification_method, \
504 verification_token, ssl_status, ssl_provider, ssl_attempts, \
505 ssl_last_attempt_at) \
506 VALUES ($1, $2, 'verified', 'dns', 'tok', $3, 'acme', $4, \
507 CASE WHEN $5::bigint IS NULL THEN NULL \
508 ELSE NOW() - make_interval(secs => $5::bigint) END) \
509 RETURNING id",
510 )
511 .bind(tenant_id)
512 .bind(&name)
513 .bind(ssl_status)
514 .bind(attempts)
515 .bind(last_attempt_secs_ago)
516 .fetch_one(pool)
517 .await
518 .expect("insert domain");
519 (id, name)
520 }
521
522 async fn claims_include(pool: &PgPool, id: Uuid) -> bool {
524 let mut found = false;
525 while let Some(claim) = claim_next(pool).await.expect("claim") {
526 if claim.id == id {
527 found = true;
528 }
529 }
530 found
531 }
532
533 async fn drain(pool: &PgPool) -> Vec<Uuid> {
534 let mut ids = Vec::new();
535 while let Some(claim) = claim_next(pool).await.expect("claim") {
536 ids.push(claim.id);
537 }
538 ids
539 }
540
541 #[tokio::test]
542 #[ignore = "requires PostgreSQL"]
543 async fn a_fresh_pending_domain_is_claimed() {
544 let pool = pool().await;
545 let _exclusive = exclusive().await;
546 let t = tenant(&pool).await;
547 let (id, _) = domain(&pool, t, "pending", 0, None).await;
548
549 assert!(
550 claims_include(&pool, id).await,
551 "a verified ACME domain with no attempts should be claimed"
552 );
553
554 let status: String =
555 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
556 .bind(id)
557 .fetch_one(&pool)
558 .await
559 .unwrap();
560 assert_eq!(status, "provisioning", "claiming must take the row");
561 }
562
563 #[tokio::test]
564 #[ignore = "requires PostgreSQL"]
565 async fn a_domain_that_failed_recently_waits() {
566 let pool = pool().await;
567 let _exclusive = exclusive().await;
568 let t = tenant(&pool).await;
569 let (id, _) = domain(&pool, t, "pending", 1, Some(10)).await;
571
572 assert!(
573 !claims_include(&pool, id).await,
574 "a domain inside its backoff window should not be claimed"
575 );
576 }
577
578 #[tokio::test]
579 #[ignore = "requires PostgreSQL"]
580 async fn a_domain_whose_backoff_has_elapsed_is_claimed_again() {
581 let pool = pool().await;
582 let _exclusive = exclusive().await;
583 let t = tenant(&pool).await;
584 let (id, _) = domain(&pool, t, "pending", 1, Some(3600)).await;
590
591 assert!(
592 claims_include(&pool, id).await,
593 "a domain past its backoff window must be retried"
594 );
595 }
596
597 #[tokio::test]
598 #[ignore = "requires PostgreSQL"]
599 async fn backoff_grows_with_attempts() {
600 let pool = pool().await;
601 let _exclusive = exclusive().await;
602 let t = tenant(&pool).await;
603
604 let (too_soon, _) = domain(&pool, t, "pending", 4, Some(10 * 60)).await;
607 let (due, _) = domain(&pool, t, "pending", 4, Some(20 * 60)).await;
608
609 let claimed = drain(&pool).await;
610
611 assert!(
612 claimed.contains(&due),
613 "a domain past its grown backoff must be retried"
614 );
615 assert!(
616 !claimed.contains(&too_soon),
617 "the backoff window must grow with the attempt count"
618 );
619 }
620
621 #[tokio::test]
622 #[ignore = "requires PostgreSQL"]
623 async fn the_backoff_is_capped() {
624 let pool = pool().await;
625 let _exclusive = exclusive().await;
626 let t = tenant(&pool).await;
627
628 let (id, _) = domain(&pool, t, "pending", 9, Some(5 * 60 * 60)).await;
631
632 assert!(
633 claims_include(&pool, id).await,
634 "the cap must bind, or a domain waits far longer than intended"
635 );
636 }
637
638 #[tokio::test]
639 #[ignore = "requires PostgreSQL"]
640 async fn a_domain_at_the_attempt_limit_is_left_alone() {
641 let pool = pool().await;
642 let _exclusive = exclusive().await;
643 let t = tenant(&pool).await;
644 let (id, _) = domain(&pool, t, "pending", 10, Some(24 * 60 * 60)).await;
646
647 assert!(
648 !claims_include(&pool, id).await,
649 "a domain at MAX_ATTEMPTS must not be picked up again without a retry"
650 );
651 }
652
653 #[tokio::test]
654 #[ignore = "requires PostgreSQL"]
655 async fn only_pending_acme_domains_are_claimed() {
656 let pool = pool().await;
657 let _exclusive = exclusive().await;
658 let t = tenant(&pool).await;
659
660 let (active, _) = domain(&pool, t, "active", 0, None).await;
661 let (failed, _) = domain(&pool, t, "failed", 0, None).await;
662 let (provisioning, _) = domain(&pool, t, "provisioning", 0, None).await;
663
664 let manual: Uuid = sqlx::query_scalar(
666 "INSERT INTO proxy_domains \
667 (tenant_id, domain, verification_status, verification_token, \
668 ssl_status, ssl_provider) \
669 VALUES ($1, $2, 'verified', 'tok', 'pending', 'manual') RETURNING id",
670 )
671 .bind(t)
672 .bind(format!("manual-{}.example", Uuid::new_v4()))
673 .fetch_one(&pool)
674 .await
675 .unwrap();
676
677 let unverified: Uuid = sqlx::query_scalar(
678 "INSERT INTO proxy_domains \
679 (tenant_id, domain, verification_status, verification_token, \
680 ssl_status, ssl_provider) \
681 VALUES ($1, $2, 'pending', 'tok', 'pending', 'acme') RETURNING id",
682 )
683 .bind(t)
684 .bind(format!("unverified-{}.example", Uuid::new_v4()))
685 .fetch_one(&pool)
686 .await
687 .unwrap();
688
689 let claimed = drain(&pool).await;
690
691 for (id, why) in [
692 (active, "already active"),
693 (failed, "given up on"),
694 (provisioning, "already being worked on"),
695 (manual, "not an ACME domain"),
696 (unverified, "not verified"),
697 ] {
698 assert!(!claimed.contains(&id), "claimed a domain that is {why}");
699 }
700 }
701
702 #[tokio::test]
703 #[ignore = "requires PostgreSQL"]
704 async fn provision_requeues_a_failed_domain_and_clears_its_history() {
705 let pool = pool().await;
706 let _exclusive = exclusive().await;
707 let t = tenant(&pool).await;
708 let (id, _) = domain(&pool, t, "failed", 10, Some(60)).await;
709 sqlx::query("UPDATE proxy_domains SET ssl_error = 'something' WHERE id = $1")
710 .bind(id)
711 .execute(&pool)
712 .await
713 .unwrap();
714
715 let requeued = crate::saas::db::queue_for_issuance(&pool, id, t)
716 .await
717 .expect("retry");
718 assert!(requeued.is_some(), "provision should find the domain");
719
720 let row = sqlx::query(
721 "SELECT ssl_status, ssl_attempts, ssl_error, ssl_last_attempt_at \
722 FROM proxy_domains WHERE id = $1",
723 )
724 .bind(id)
725 .fetch_one(&pool)
726 .await
727 .unwrap();
728
729 use sqlx::Row;
730 assert_eq!(row.get::<String, _>("ssl_status"), "pending");
731 assert_eq!(row.get::<i32, _>("ssl_attempts"), 0);
732 assert!(row.get::<Option<String>, _>("ssl_error").is_none());
733 assert!(row
736 .get::<Option<chrono::DateTime<chrono::Utc>>, _>("ssl_last_attempt_at")
737 .is_none());
738
739 assert!(
740 claims_include(&pool, id).await,
741 "a retried domain must be claimable straight away"
742 );
743 }
744
745 #[tokio::test]
746 #[ignore = "requires PostgreSQL"]
747 async fn a_domain_abandoned_mid_issuance_is_requeued() {
748 let pool = pool().await;
749 let _exclusive = exclusive().await;
750 let t = tenant(&pool).await;
751
752 let (id, _) = domain(&pool, t, "provisioning", 0, Some(3600)).await;
757
758 let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
759 assert_eq!(recovered, 1, "the abandoned domain was not requeued");
760
761 assert!(
762 claims_include(&pool, id).await,
763 "a requeued domain must be claimable again"
764 );
765
766 let attempts: i32 =
767 sqlx::query_scalar("SELECT ssl_attempts FROM proxy_domains WHERE id = $1")
768 .bind(id)
769 .fetch_one(&pool)
770 .await
771 .unwrap();
772 assert_eq!(attempts, 1, "the interrupted attempt was not counted");
775 }
776
777 #[tokio::test]
778 #[ignore = "requires PostgreSQL"]
779 async fn a_live_order_is_not_stolen_from_the_worker_running_it() {
780 let pool = pool().await;
781 let _exclusive = exclusive().await;
782 let t = tenant(&pool).await;
783
784 let (id, _) = domain(&pool, t, "provisioning", 0, Some(60)).await;
788
789 let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
790 assert_eq!(recovered, 0, "a live order was reclaimed");
791
792 let status: String =
793 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
794 .bind(id)
795 .fetch_one(&pool)
796 .await
797 .unwrap();
798 assert_eq!(status, "provisioning");
799 }
800
801 #[tokio::test]
802 #[ignore = "requires PostgreSQL"]
803 async fn reclaiming_leaves_other_providers_and_states_alone() {
804 let pool = pool().await;
805 let _exclusive = exclusive().await;
806 let t = tenant(&pool).await;
807
808 let (pending, _) = domain(&pool, t, "pending", 0, Some(3600)).await;
809 let (active, _) = domain(&pool, t, "active", 0, Some(3600)).await;
810
811 let manual: Uuid = sqlx::query_scalar(
814 "INSERT INTO proxy_domains \
815 (tenant_id, domain, verification_status, verification_token, \
816 ssl_status, ssl_provider, ssl_last_attempt_at) \
817 VALUES ($1, $2, 'verified', 'tok', 'provisioning', 'manual', \
818 NOW() - make_interval(secs => 3600)) RETURNING id",
819 )
820 .bind(t)
821 .bind(format!("manual-{}.example", Uuid::new_v4()))
822 .fetch_one(&pool)
823 .await
824 .unwrap();
825
826 let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
827 assert_eq!(recovered, 0, "reclaimed something it should not have");
828
829 for (id, expected) in [
830 (pending, "pending"),
831 (active, "active"),
832 (manual, "provisioning"),
833 ] {
834 let status: String =
835 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
836 .bind(id)
837 .fetch_one(&pool)
838 .await
839 .unwrap();
840 assert_eq!(
841 status, expected,
842 "a row was changed that should not have been"
843 );
844 }
845 }
846
847 #[tokio::test]
848 #[ignore = "requires PostgreSQL"]
849 async fn provision_refuses_a_domain_that_is_not_verified() {
850 let pool = pool().await;
851 let _exclusive = exclusive().await;
852 let t = tenant(&pool).await;
853
854 let id: Uuid = sqlx::query_scalar(
855 "INSERT INTO proxy_domains \
856 (tenant_id, domain, verification_status, verification_token, \
857 ssl_status, ssl_provider) \
858 VALUES ($1, $2, 'pending', 'tok', 'pending', 'acme') RETURNING id",
859 )
860 .bind(t)
861 .bind(format!("unverified-{}.example", Uuid::new_v4()))
862 .fetch_one(&pool)
863 .await
864 .unwrap();
865
866 let queued = crate::saas::db::queue_for_issuance(&pool, id, t)
870 .await
871 .expect("queue");
872 assert!(queued.is_none(), "queued an unverified domain");
873 }
874
875 #[tokio::test]
876 #[ignore = "requires PostgreSQL"]
877 async fn provision_refuses_a_manual_domain() {
878 let pool = pool().await;
879 let _exclusive = exclusive().await;
880 let t = tenant(&pool).await;
881
882 let id: Uuid = sqlx::query_scalar(
883 "INSERT INTO proxy_domains \
884 (tenant_id, domain, verification_status, verification_token, \
885 ssl_status, ssl_provider) \
886 VALUES ($1, $2, 'verified', 'tok', 'pending', 'manual') RETURNING id",
887 )
888 .bind(t)
889 .bind(format!("manual-{}.example", Uuid::new_v4()))
890 .fetch_one(&pool)
891 .await
892 .unwrap();
893
894 let queued = crate::saas::db::queue_for_issuance(&pool, id, t)
895 .await
896 .expect("queue");
897 assert!(
898 queued.is_none(),
899 "queued a domain whose certificate does not come from ACME"
900 );
901 }
902
903 #[tokio::test]
904 #[ignore = "requires PostgreSQL"]
905 async fn switching_a_verified_domain_to_acme_queues_it() {
906 let pool = pool().await;
907 let _exclusive = exclusive().await;
908 let t = tenant(&pool).await;
909
910 let id: Uuid = sqlx::query_scalar(
912 "INSERT INTO proxy_domains \
913 (tenant_id, domain, verification_status, verification_token, \
914 ssl_status, ssl_provider, ssl_attempts, ssl_error) \
915 VALUES ($1, $2, 'verified', 'tok', 'active', 'manual', 4, 'old') \
916 RETURNING id",
917 )
918 .bind(t)
919 .bind(format!("switch-{}.example", Uuid::new_v4()))
920 .fetch_one(&pool)
921 .await
922 .unwrap();
923
924 let updated = crate::saas::db::update_domain(
925 &pool,
926 id,
927 t,
928 None,
929 Some(&crate::saas::types::SslProvider::Acme),
930 )
931 .await
932 .expect("update")
933 .expect("domain should be found");
934
935 assert_eq!(
938 format!("{:?}", updated.ssl_status),
939 "Pending",
940 "switching to acme must queue the domain"
941 );
942 assert!(
943 claims_include(&pool, id).await,
944 "a domain switched to acme must become claimable"
945 );
946
947 let row = sqlx::query("SELECT ssl_attempts, ssl_error FROM proxy_domains WHERE id = $1")
948 .bind(id)
949 .fetch_one(&pool)
950 .await
951 .unwrap();
952 use sqlx::Row as _;
953 assert_eq!(row.get::<i32, _>("ssl_attempts"), 0, "history not cleared");
954 assert!(row.get::<Option<String>, _>("ssl_error").is_none());
955 }
956
957 #[tokio::test]
958 #[ignore = "requires PostgreSQL"]
959 async fn switching_an_unverified_domain_to_acme_does_not_queue_it() {
960 let pool = pool().await;
961 let _exclusive = exclusive().await;
962 let t = tenant(&pool).await;
963
964 let id: Uuid = sqlx::query_scalar(
965 "INSERT INTO proxy_domains \
966 (tenant_id, domain, verification_status, verification_token, \
967 ssl_status, ssl_provider) \
968 VALUES ($1, $2, 'pending', 'tok', 'pending', 'manual') RETURNING id",
969 )
970 .bind(t)
971 .bind(format!("switch-unverified-{}.example", Uuid::new_v4()))
972 .fetch_one(&pool)
973 .await
974 .unwrap();
975
976 crate::saas::db::update_domain(
977 &pool,
978 id,
979 t,
980 None,
981 Some(&crate::saas::types::SslProvider::Acme),
982 )
983 .await
984 .expect("update")
985 .expect("domain should be found");
986
987 assert!(
988 !claims_include(&pool, id).await,
989 "an unverified domain must not become claimable by changing its provider"
990 );
991 }
992
993 #[tokio::test]
994 #[ignore = "requires PostgreSQL"]
995 async fn an_update_leaves_absent_fields_alone() {
996 let pool = pool().await;
997 let _exclusive = exclusive().await;
998 let t = tenant(&pool).await;
999 let (id, _) = domain(&pool, t, "pending", 3, Some(10)).await;
1000
1001 let updated = crate::saas::db::update_domain(
1004 &pool,
1005 id,
1006 t,
1007 Some(&crate::saas::types::VerificationMethod::Http),
1008 None,
1009 )
1010 .await
1011 .expect("update")
1012 .expect("domain should be found");
1013
1014 assert_eq!(format!("{:?}", updated.verification_method), "Http");
1015 assert_eq!(format!("{:?}", updated.ssl_provider), "Acme");
1016
1017 let attempts: i32 =
1018 sqlx::query_scalar("SELECT ssl_attempts FROM proxy_domains WHERE id = $1")
1019 .bind(id)
1020 .fetch_one(&pool)
1021 .await
1022 .unwrap();
1023 assert_eq!(attempts, 3, "an unrelated update reset the attempt count");
1024 }
1025
1026 #[tokio::test]
1027 #[ignore = "requires PostgreSQL"]
1028 async fn an_update_will_not_cross_a_tenant_boundary() {
1029 let pool = pool().await;
1030 let _exclusive = exclusive().await;
1031 let owner = tenant(&pool).await;
1032 let stranger = tenant(&pool).await;
1033 let (id, _) = domain(&pool, owner, "pending", 0, None).await;
1034
1035 let updated = crate::saas::db::update_domain(
1036 &pool,
1037 id,
1038 stranger,
1039 None,
1040 Some(&crate::saas::types::SslProvider::None),
1041 )
1042 .await
1043 .expect("update");
1044 assert!(updated.is_none(), "update crossed a tenant boundary");
1045 }
1046
1047 #[tokio::test]
1048 #[ignore = "requires PostgreSQL"]
1049 async fn provision_will_not_touch_another_tenants_domain() {
1050 let pool = pool().await;
1051 let _exclusive = exclusive().await;
1052 let owner = tenant(&pool).await;
1053 let stranger = tenant(&pool).await;
1054 let (id, _) = domain(&pool, owner, "failed", 10, Some(60)).await;
1055
1056 let requeued = crate::saas::db::queue_for_issuance(&pool, id, stranger)
1057 .await
1058 .expect("retry");
1059 assert!(requeued.is_none(), "provision crossed a tenant boundary");
1060
1061 let status: String =
1062 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
1063 .bind(id)
1064 .fetch_one(&pool)
1065 .await
1066 .unwrap();
1067 assert_eq!(status, "failed", "another tenant's domain was modified");
1068 }
1069
1070 #[tokio::test]
1071 #[ignore = "requires PostgreSQL"]
1072 async fn two_workers_do_not_claim_the_same_domain() {
1073 let pool = pool().await;
1074 let _exclusive = exclusive().await;
1075 let t = tenant(&pool).await;
1076 let (id, _) = domain(&pool, t, "pending", 0, None).await;
1077
1078 let first = claim_next(&pool).await.expect("claim");
1082 assert!(first.is_some());
1083
1084 let others = drain(&pool).await;
1085 assert!(!others.contains(&id), "the same domain was claimed twice");
1086 }
1087}