Skip to main content

acme_proxy_store/
upstream_order.rs

1//! The mapping between a local order and the order the `relay` signer
2//! backend opened for it at the upstream CA.
3//!
4//! Kept in the database alongside the rest of the data model, as
5//! `signer::local_ca`'s revocation ledger now is too: this state *is*
6//! per-order, so it belongs with the orders.
7//!
8//! ## Methods
9//!
10//! - `create`: record a newly opened upstream order, `processing`
11//! - `find_by_order_id`: the mapping for one local order
12//! - `mark_valid` / `mark_invalid`: terminal outcomes
13//! - `list_processing`: rows whose background task was lost to a restart
14
15use crate::sql::Row;
16use tracing::debug;
17use uuid::Uuid;
18
19use crate::db::Database;
20use crate::nonce::now_secs;
21use crate::status::{self, OrderStatus, UpstreamOrderStatus};
22use acme_proxy_core::identifier::Identifier;
23
24/// The most rows [`UpstreamOrder::list_processing`] returns in one call.
25///
26/// A restart after a long upstream outage can leave a very large number of
27/// orders `processing`; loading them all at once would spike memory before the
28/// relay semaphore ever got a say in how fast they are worked through.
29pub const MAX_PROCESSING_BATCH: usize = 500;
30
31/// One `upstream_orders` row: the order the `relay` backend opened at its
32/// upstream for a local order, keyed by the local order's id, and the URLs and
33/// error text the relay needs to resume or explain it.
34#[derive(Debug, Clone)]
35pub struct UpstreamOrder {
36    pub order_id: Uuid,
37    pub upstream_order_url: String,
38    pub upstream_finalize_url: Option<String>,
39    pub upstream_certificate_url: Option<String>,
40    /// The client's CSR, kept so a relay interrupted by a restart can still
41    /// finalize upstream. See the migration for why it cannot be recovered
42    /// any other way.
43    pub csr_der: Vec<u8>,
44    pub status: String,
45    pub error: Option<String>,
46    pub created_at: i64,
47    pub updated_at: i64,
48    /// The finalize request's context, stored by [`UpstreamOrder::set_client`]
49    /// so [`UpstreamOrder::client`] can hand it back to the audit row written
50    /// when the relay settles — long after that request returned. See the
51    /// migration for why it cannot come from anywhere else.
52    pub client_ip: Option<String>,
53    pub client_ptr: Option<String>,
54    pub user_agent: Option<String>,
55    pub request_id: Option<String>,
56}
57
58impl UpstreamOrder {
59    fn from_row(row: Row) -> Result<Self, sqlx::Error> {
60        Ok(UpstreamOrder {
61            order_id: row.try_get("order_id")?,
62            upstream_order_url: row.try_get("upstream_order_url")?,
63            upstream_finalize_url: row.try_get("upstream_finalize_url")?,
64            upstream_certificate_url: row.try_get("upstream_certificate_url")?,
65            csr_der: row.try_get("csr_der")?,
66            status: row.try_get("status")?,
67            error: row.try_get("error")?,
68            created_at: row.try_get("created_at")?,
69            updated_at: row.try_get("updated_at")?,
70            client_ip: row.try_get("client_ip")?,
71            client_ptr: row.try_get("client_ptr")?,
72            user_agent: row.try_get("user_agent")?,
73            request_id: row.try_get("request_id")?,
74        })
75    }
76
77    /// The stored finalize context, in the shape an audit row takes.
78    #[must_use]
79    pub fn client(&self) -> acme_proxy_core::audit::ClientContext {
80        acme_proxy_core::audit::ClientContext {
81            ip: self.client_ip.clone(),
82            ptr: self.client_ptr.clone(),
83            user_agent: self.user_agent.clone(),
84            request_id: self.request_id.clone(),
85        }
86    }
87
88    /// Records the finalize request's context on an already-created mapping row.
89    ///
90    /// A separate `UPDATE` rather than a parameter of [`UpstreamOrder::create`]
91    /// because that runs inside `SignerBackend::issue`, which is handed a CSR
92    /// and an order id and deliberately knows nothing about HTTP. The
93    /// `signer_issue` job calls this the moment the backend answers
94    /// `Processing`, with the context `finalize` parked on the job row.
95    ///
96    /// That leaves a window — between the backend's own `create` and this
97    /// `UPDATE` — in which a relay could theoretically settle and find no
98    /// context. It cannot happen in practice: settling takes at least one
99    /// round trip to the upstream CA, and this statement is a few microseconds
100    /// of local work on the same task. If it ever did, the audit row would
101    /// simply carry no address, which is the same degradation as a resumed
102    /// order and not a wrong answer.
103    pub async fn set_client(
104        order_id: &str,
105        client: &acme_proxy_core::audit::ClientContext,
106        database: &Database,
107    ) -> Result<(), sqlx::Error> {
108        let Some(order_id) = crate::id::parse(order_id) else {
109            return Ok(());
110        };
111        crate::sql::query(
112            "UPDATE upstream_orders \
113             SET client_ip = ?, client_ptr = ?, user_agent = ?, request_id = ? \
114             WHERE order_id = ?;",
115        )
116        .bind(&client.ip)
117        .bind(&client.ptr)
118        .bind(&client.user_agent)
119        .bind(&client.request_id)
120        .bind(order_id)
121        .execute(database)
122        .await?;
123        Ok(())
124    }
125
126    /// Records a newly opened upstream order as `processing`.
127    ///
128    /// Returns `Ok(None)` when a row for `order_id` already exists — the
129    /// primary key rejecting a duplicate is how two concurrent finalize
130    /// requests are stopped from opening two upstream orders for one local
131    /// order. The caller treats that as "a relay is already running".
132    pub async fn create(
133        order_id: &str,
134        upstream_order_url: &str,
135        upstream_finalize_url: Option<&str>,
136        csr_der: &[u8],
137        database: &Database,
138    ) -> Result<Option<UpstreamOrder>, sqlx::Error> {
139        let Some(order_id) = crate::id::parse(order_id) else {
140            return Ok(None);
141        };
142        let now = now_secs();
143        let record = UpstreamOrder {
144            order_id,
145            upstream_order_url: upstream_order_url.to_string(),
146            upstream_finalize_url: upstream_finalize_url.map(str::to_string),
147            upstream_certificate_url: None,
148            csr_der: csr_der.to_vec(),
149            status: "processing".to_string(),
150            error: None,
151            created_at: now,
152            updated_at: now,
153            client_ip: None,
154            client_ptr: None,
155            user_agent: None,
156            request_id: None,
157        };
158
159        debug!(event = "db_upstream_order_create_started", outcome = "progress", order_id = ?order_id);
160        let result = crate::sql::query(
161            "INSERT INTO upstream_orders \
162             (order_id, upstream_order_url, upstream_finalize_url, csr_der, status, \
163              created_at, updated_at) \
164             VALUES (?, ?, ?, ?, ?, ?, ?) \
165             ON CONFLICT DO NOTHING;",
166        )
167        .bind(record.order_id)
168        .bind(&record.upstream_order_url)
169        .bind(&record.upstream_finalize_url)
170        .bind(&record.csr_der)
171        .bind(&record.status)
172        .bind(record.created_at)
173        .bind(record.updated_at)
174        .execute(database)
175        .await?;
176
177        if result.rows_affected() == 0 {
178            return Ok(None);
179        }
180        Ok(Some(record))
181    }
182
183    /// The upstream order behind the local order `order_id`, if this server
184    /// relayed it.
185    pub async fn find_by_order_id(
186        order_id: &str,
187        database: &Database,
188    ) -> Result<Option<UpstreamOrder>, sqlx::Error> {
189        let Some(order_id) = crate::id::parse(order_id) else {
190            return Ok(None);
191        };
192        let row = crate::sql::query("SELECT * FROM upstream_orders WHERE order_id = ?;")
193            .bind(order_id)
194            .fetch_optional(database)
195            .await?;
196        row.map(UpstreamOrder::from_row).transpose()
197    }
198
199    /// Marks the relay finished, recording where the certificate came from.
200    pub async fn mark_valid(
201        order_id: &str,
202        certificate_url: Option<&str>,
203        database: &Database,
204    ) -> Result<(), sqlx::Error> {
205        let Some(order_id) = crate::id::parse(order_id) else {
206            return Ok(());
207        };
208        crate::sql::query(
209            "UPDATE upstream_orders \
210             SET status = 'valid', upstream_certificate_url = ?, updated_at = ? \
211             WHERE order_id = ?;",
212        )
213        .bind(certificate_url)
214        .bind(now_secs())
215        .bind(order_id)
216        .execute(database)
217        .await?;
218        Ok(())
219    }
220
221    /// Marks the relay failed, recording why for an operator to read later —
222    /// the client only ever sees the problem document on the order itself.
223    pub async fn mark_invalid(
224        order_id: &str,
225        error: &str,
226        database: &Database,
227    ) -> Result<(), sqlx::Error> {
228        let Some(order_id) = crate::id::parse(order_id) else {
229            return Ok(());
230        };
231        crate::sql::query(
232            "UPDATE upstream_orders SET status = 'invalid', error = ?, updated_at = ? \
233             WHERE order_id = ?;",
234        )
235        .bind(error)
236        .bind(now_secs())
237        .bind(order_id)
238        .execute(database)
239        .await?;
240        Ok(())
241    }
242
243    /// Rows still `processing`, oldest first — relays whose in-memory task
244    /// died with the process and need one respawned at startup.
245    ///
246    /// Restricted to the `profiles` the calling backend actually serves, via
247    /// the owning order's own `profile`: several endpoints may relay to
248    /// several different upstreams, and resuming another one's order would
249    /// drive it against the wrong CA. An empty `profiles` therefore matches
250    /// nothing, which is the safe reading of "this backend serves nothing".
251    pub async fn list_processing(
252        profiles: &[String],
253        database: &Database,
254    ) -> Result<Vec<UpstreamOrder>, sqlx::Error> {
255        if profiles.is_empty() {
256            return Ok(Vec::new());
257        }
258
259        // sqlx has no array binding for SQLite, so the IN list is built from
260        // one placeholder per profile — never from the values themselves.
261        let placeholders = std::iter::repeat_n("?", profiles.len())
262            .collect::<Vec<_>>()
263            .join(", ");
264        // Bounded so a pathological backlog is not loaded into memory whole;
265        // the oldest are taken first, and a later restart picks up the rest.
266        let limit = MAX_PROCESSING_BATCH;
267        let sql = format!(
268            "SELECT u.* FROM upstream_orders u \
269             JOIN orders o ON o.id = u.order_id \
270             WHERE u.status = 'processing' AND o.profile IN ({placeholders}) \
271             ORDER BY u.created_at ASC LIMIT {limit};"
272        );
273
274        // `AssertSqlSafe` because sqlx refuses a non-`'static` query string
275        // outright: the only runtime part of this one is the count of `?`
276        // placeholders, never a value.
277        let mut query = crate::sql::query(sqlx::AssertSqlSafe(sql));
278        for profile in profiles {
279            query = query.bind(profile);
280        }
281        let rows = query.fetch_all(database).await?;
282        rows.into_iter().map(UpstreamOrder::from_row).collect()
283    }
284}
285
286/// One `upstream_orders` row joined to its local order, for the operator
287/// surface (`acme-proxy upstream order list`, `GET /api/upstream-orders`).
288///
289/// **`csr_der` is deliberately not a field.** It is the end client's CSR, kept
290/// only so an interrupted relay can finalize; no operator view has a reason to
291/// render DER, and leaving it off the struct makes that structural rather than
292/// a rule a renderer has to remember. The relay flow keeps using
293/// [`UpstreamOrder`] (which carries it) via [`UpstreamOrder::find_by_order_id`].
294#[derive(Debug, Clone)]
295pub struct UpstreamOrderRow {
296    pub order_id: Uuid,
297    pub upstream_order_url: String,
298    pub upstream_finalize_url: Option<String>,
299    pub upstream_certificate_url: Option<String>,
300    pub status: String,
301    /// Text the upstream CA wrote — untrusted, HTML-escape it on any web
302    /// surface.
303    pub error: Option<String>,
304    pub created_at: i64,
305    pub updated_at: i64,
306    pub client_ip: Option<String>,
307    pub client_ptr: Option<String>,
308    /// The finalize request's `User-Agent` — caller-controlled, untrusted.
309    pub user_agent: Option<String>,
310    pub request_id: Option<String>,
311    // --- joined from `orders` ---
312    pub profile: String,
313    pub account_id: Uuid,
314    pub identifiers: Vec<Identifier>,
315    pub local_status: OrderStatus,
316    pub local_expires: i64,
317}
318
319impl UpstreamOrderRow {
320    /// Columns selected by [`UpstreamOrder::search`] and
321    /// [`UpstreamOrder::find_row_by_order_id`] — the same set or
322    /// [`Self::from_joined_row`] fails on whichever forgot one. `csr_der` is
323    /// deliberately absent.
324    const COLUMNS: &'static str = "u.order_id, u.upstream_order_url, u.upstream_finalize_url, \
325         u.upstream_certificate_url, u.status, u.error, u.created_at, u.updated_at, \
326         u.client_ip, u.client_ptr, u.user_agent, u.request_id, \
327         o.profile, o.account_id, o.identifiers, o.status AS local_status, \
328         o.expires AS local_expires";
329
330    fn from_joined_row(row: Row) -> Result<Self, sqlx::Error> {
331        let identifiers_json: String = row.try_get("identifiers")?;
332        let identifiers: Vec<Identifier> = serde_json::from_str(&identifiers_json)
333            .map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
334
335        Ok(UpstreamOrderRow {
336            order_id: row.try_get("order_id")?,
337            upstream_order_url: row.try_get("upstream_order_url")?,
338            upstream_finalize_url: row.try_get("upstream_finalize_url")?,
339            upstream_certificate_url: row.try_get("upstream_certificate_url")?,
340            status: row.try_get("status")?,
341            error: row.try_get("error")?,
342            created_at: row.try_get("created_at")?,
343            updated_at: row.try_get("updated_at")?,
344            client_ip: row.try_get("client_ip")?,
345            client_ptr: row.try_get("client_ptr")?,
346            user_agent: row.try_get("user_agent")?,
347            request_id: row.try_get("request_id")?,
348            profile: row.try_get("profile")?,
349            account_id: row.try_get("account_id")?,
350            identifiers,
351            local_status: status::from_column(row.try_get::<String>("local_status")?.as_str())?,
352            local_expires: row.try_get("local_expires")?,
353        })
354    }
355
356    /// The stored finalize context, in the shape an audit row takes — the
357    /// [`UpstreamOrder::client`] equivalent for the joined row.
358    #[must_use]
359    pub fn client(&self) -> acme_proxy_core::audit::ClientContext {
360        acme_proxy_core::audit::ClientContext {
361            ip: self.client_ip.clone(),
362            ptr: self.client_ptr.clone(),
363            user_agent: self.user_agent.clone(),
364            request_id: self.request_id.clone(),
365        }
366    }
367}
368
369/// The filters and page window [`UpstreamOrder::search`] applies. `status` is a
370/// [`UpstreamOrderStatus`] because both front ends refuse an unknown value by
371/// name; `profile` is a free `String`, matched over the joined `orders.profile`.
372#[derive(Debug, Clone, Default)]
373pub struct UpstreamOrderQuery {
374    pub profile: Option<String>,
375    pub status: Option<UpstreamOrderStatus>,
376    pub limit: i64,
377    pub offset: i64,
378}
379
380impl UpstreamOrderQuery {
381    /// The `WHERE` shared by the page query and the count, over the aliases
382    /// `u` (`upstream_orders`) and `o` (`orders`). Every value is `push_bind`.
383    fn push_predicates(&self, builder: &mut crate::sql::Builder) {
384        crate::query::push_equalities(
385            builder,
386            crate::query::WHERE,
387            &[
388                ("o.profile = ", self.profile.as_deref()),
389                ("u.status = ", self.status.map(UpstreamOrderStatus::as_str)),
390            ],
391        );
392    }
393}
394
395impl UpstreamOrder {
396    /// One page of upstream orders matching `query`, plus the total the same
397    /// predicates match unpaged — the operator surface's cross-profile listing.
398    ///
399    /// An `INNER JOIN` to `orders`: `order_id` is a foreign key with
400    /// `ON DELETE CASCADE`, so a row with no local order cannot exist, and the
401    /// join is what carries the identifiers and local status a mapping row does
402    /// not. **Not** [`Self::list_processing`], which is `processing`-only,
403    /// `LIMIT 500`, oldest-first and profile-list-scoped for restart recovery.
404    ///
405    /// The one index on this table is `(status)`; `o.profile` and the
406    /// `ORDER BY` scan. The table is bounded by the cascade from `orders`
407    /// (`order.retention_days`), so this is the `Order::find_expiring` tradeoff
408    /// again — a dedicated index is a future migration if a deployment says so.
409    pub async fn search(
410        query: &UpstreamOrderQuery,
411        database: &Database,
412    ) -> Result<(Vec<UpstreamOrderRow>, i64), sqlx::Error> {
413        debug!(
414            event = "db_upstream_order_search_started",
415            outcome = "progress",
416            profile = ?query.profile,
417            status = ?query.status,
418            limit = query.limit,
419            offset = query.offset,
420        );
421
422        let mut page = crate::sql::Builder::new(
423            database.dialect(),
424            format!(
425                "SELECT {} FROM upstream_orders u JOIN orders o ON o.id = u.order_id",
426                UpstreamOrderRow::COLUMNS
427            ),
428        );
429        query.push_predicates(&mut page);
430        page.push(" ORDER BY u.created_at DESC, u.order_id DESC LIMIT ");
431        page.push_bind(query.limit);
432        page.push(" OFFSET ");
433        page.push_bind(query.offset);
434
435        let rows = page.build().fetch_all(database).await?;
436        let items: Vec<UpstreamOrderRow> = rows
437            .into_iter()
438            .map(UpstreamOrderRow::from_joined_row)
439            .collect::<Result<_, _>>()?;
440
441        let mut count = crate::sql::Builder::new(
442            database.dialect(),
443            "SELECT COUNT(*) FROM upstream_orders u JOIN orders o ON o.id = u.order_id",
444        );
445        query.push_predicates(&mut count);
446        let total: i64 = count.build().fetch_one(database).await?.try_get::<i64>(0)?;
447
448        Ok((items, total))
449    }
450
451    /// The joined row for one local order, or `None` — the operator-detail
452    /// counterpart to [`Self::find_by_order_id`] (which carries `csr_der` and
453    /// serves the relay flow).
454    pub async fn find_row_by_order_id(
455        order_id: &str,
456        database: &Database,
457    ) -> Result<Option<UpstreamOrderRow>, sqlx::Error> {
458        let Some(order_id) = crate::id::parse(order_id) else {
459            return Ok(None);
460        };
461        let mut query = crate::sql::Builder::new(
462            database.dialect(),
463            format!(
464                "SELECT {} FROM upstream_orders u JOIN orders o ON o.id = u.order_id \
465             WHERE u.order_id = ",
466                UpstreamOrderRow::COLUMNS
467            ),
468        );
469        query.push_bind(order_id);
470        let row = query.build().fetch_optional(database).await?;
471        row.map(UpstreamOrderRow::from_joined_row).transpose()
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::account::Account;
479    use crate::order::Order;
480    use acme_proxy_core::audit::ClientContext;
481    use acme_proxy_core::identifier::Identifier;
482    use std::sync::Arc;
483
484    /// `upstream_orders.order_id` is a foreign key, so a real order has to
485    /// exist behind every row these tests create.
486    async fn order(database: &Database) -> Order {
487        let (account, _) = Account::find_or_create(
488            "default",
489            &acme_proxy_core::random::random_bytes::<16>(),
490            Vec::new(),
491            &ClientContext::default(),
492            database,
493        )
494        .await
495        .unwrap();
496        Order::create(
497            "default",
498            account.id,
499            vec![Identifier::dns("example.com")],
500            now_secs() + 3600,
501            None,
502            None,
503            database,
504        )
505        .await
506        .unwrap()
507    }
508
509    async fn database() -> Arc<Database> {
510        Arc::new(Database::connect_for_test().await.unwrap())
511    }
512
513    /// The relay settles minutes after the finalize request returned, from a
514    /// task with no request in scope — so the context it audits has to come
515    /// back off this row. A row that never had one hands back an empty context
516    /// rather than a half-filled one.
517    #[tokio::test]
518    async fn the_finalize_context_is_stored_and_handed_back_for_the_audit_row() {
519        let database = database().await;
520        let order = order(&database).await;
521
522        UpstreamOrder::create(
523            order.id.to_string().as_str(),
524            "https://up.example/order/1",
525            None,
526            b"csr",
527            &database,
528        )
529        .await
530        .unwrap()
531        .unwrap();
532
533        // Before `post_finalize` stores anything: empty, not partial.
534        let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &database)
535            .await
536            .unwrap()
537            .unwrap();
538        assert_eq!(mapping.client(), ClientContext::default());
539
540        let client = ClientContext {
541            ip: Some("203.0.113.7".to_string()),
542            ptr: Some("host.example.com".to_string()),
543            user_agent: Some("lego".to_string()),
544            request_id: Some("req-1".to_string()),
545        };
546        UpstreamOrder::set_client(order.id.to_string().as_str(), &client, &database)
547            .await
548            .unwrap();
549
550        let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &database)
551            .await
552            .unwrap()
553            .unwrap();
554        assert_eq!(mapping.client(), client);
555
556        // Setting it for an order with no mapping row is a no-op, not an error:
557        // `signer_issue` calls this straight after the backend answered, and a
558        // backend that answered `Processing` without creating a row would be a
559        // different bug that must not surface here as a 500.
560        UpstreamOrder::set_client("no-such-order", &client, &database)
561            .await
562            .unwrap();
563    }
564
565    #[tokio::test]
566    async fn create_then_find_round_trips() {
567        let db = database().await;
568        let order = order(&db).await;
569
570        let created = UpstreamOrder::create(
571            order.id.to_string().as_str(),
572            "https://up.example/order/1",
573            Some("https://up.example/order/1/finalize"),
574            b"csr-bytes",
575            &db,
576        )
577        .await
578        .unwrap()
579        .expect("a first insert must succeed");
580        assert_eq!(created.status, "processing");
581
582        let found = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
583            .await
584            .unwrap()
585            .expect("the row just written must be found");
586        assert_eq!(found.upstream_order_url, "https://up.example/order/1");
587        assert_eq!(
588            found.upstream_finalize_url.as_deref(),
589            Some("https://up.example/order/1/finalize")
590        );
591        assert!(found.upstream_certificate_url.is_none());
592    }
593
594    /// The concurrency guard: the primary key must refuse a second relay for
595    /// the same order rather than opening a duplicate upstream order.
596    #[tokio::test]
597    async fn a_second_create_for_one_order_is_refused() {
598        let db = database().await;
599        let order = order(&db).await;
600
601        assert!(
602            UpstreamOrder::create(
603                order.id.to_string().as_str(),
604                "https://up.example/order/1",
605                None,
606                b"csr",
607                &db
608            )
609            .await
610            .unwrap()
611            .is_some()
612        );
613        assert!(
614            UpstreamOrder::create(
615                order.id.to_string().as_str(),
616                "https://up.example/order/2",
617                None,
618                b"csr",
619                &db
620            )
621            .await
622            .unwrap()
623            .is_none(),
624            "a duplicate must be reported as already-in-flight, not inserted"
625        );
626
627        // And the original row is untouched by the refused attempt.
628        let found = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
629            .await
630            .unwrap()
631            .unwrap();
632        assert_eq!(found.upstream_order_url, "https://up.example/order/1");
633    }
634
635    #[tokio::test]
636    async fn mark_valid_records_the_certificate_url() {
637        let db = database().await;
638        let order = order(&db).await;
639        UpstreamOrder::create(
640            order.id.to_string().as_str(),
641            "https://up.example/order/1",
642            None,
643            b"csr",
644            &db,
645        )
646        .await
647        .unwrap();
648
649        UpstreamOrder::mark_valid(
650            order.id.to_string().as_str(),
651            Some("https://up.example/cert/1"),
652            &db,
653        )
654        .await
655        .unwrap();
656
657        let found = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
658            .await
659            .unwrap()
660            .unwrap();
661        assert_eq!(found.status, "valid");
662        assert_eq!(
663            found.upstream_certificate_url.as_deref(),
664            Some("https://up.example/cert/1")
665        );
666    }
667
668    #[tokio::test]
669    async fn mark_invalid_records_the_reason() {
670        let db = database().await;
671        let order = order(&db).await;
672        UpstreamOrder::create(
673            order.id.to_string().as_str(),
674            "https://up.example/order/1",
675            None,
676            b"csr",
677            &db,
678        )
679        .await
680        .unwrap();
681
682        UpstreamOrder::mark_invalid(order.id.to_string().as_str(), "upstream said no", &db)
683            .await
684            .unwrap();
685
686        let found = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
687            .await
688            .unwrap()
689            .unwrap();
690        assert_eq!(found.status, "invalid");
691        assert_eq!(found.error.as_deref(), Some("upstream said no"));
692    }
693
694    /// Only `processing` rows come back — a finished relay must not be
695    /// respawned at the next startup.
696    #[tokio::test]
697    async fn list_processing_skips_settled_rows() {
698        let db = database().await;
699        let still_running = order(&db).await;
700        let finished = order(&db).await;
701
702        UpstreamOrder::create(
703            still_running.id.to_string().as_str(),
704            "https://up.example/a",
705            None,
706            b"csr",
707            &db,
708        )
709        .await
710        .unwrap();
711        UpstreamOrder::create(
712            finished.id.to_string().as_str(),
713            "https://up.example/b",
714            None,
715            b"csr",
716            &db,
717        )
718        .await
719        .unwrap();
720        UpstreamOrder::mark_valid(finished.id.to_string().as_str(), None, &db)
721            .await
722            .unwrap();
723
724        let processing = UpstreamOrder::list_processing(&["default".to_string()], &db)
725            .await
726            .unwrap();
727        assert_eq!(processing.len(), 1);
728        assert_eq!(processing[0].order_id, still_running.id);
729    }
730
731    #[tokio::test]
732    async fn find_by_order_id_is_none_for_an_unknown_order() {
733        let db = database().await;
734        assert!(
735            UpstreamOrder::find_by_order_id("nope", &db)
736                .await
737                .unwrap()
738                .is_none()
739        );
740    }
741
742    // --- the operator surface: `UpstreamOrderQuery` / `search` ---------------
743
744    use crate::status::{OrderStatus, UpstreamOrderStatus};
745
746    async fn order_on(profile: &str, names: &[&str], database: &Database) -> Order {
747        let (account, _) = Account::find_or_create(
748            profile,
749            &acme_proxy_core::random::random_bytes::<16>(),
750            Vec::new(),
751            &ClientContext::default(),
752            database,
753        )
754        .await
755        .unwrap();
756        Order::create(
757            profile,
758            account.id,
759            names.iter().map(|n| Identifier::dns(*n)).collect(),
760            now_secs() + 3600,
761            None,
762            None,
763            database,
764        )
765        .await
766        .unwrap()
767    }
768
769    #[tokio::test]
770    async fn search_joins_the_local_order_for_identifiers_and_status() {
771        let db = database().await;
772        let ord = order_on("default", &["a.example.com", "b.example.com"], &db).await;
773        UpstreamOrder::create(
774            ord.id.to_string().as_str(),
775            "https://up.example/order/1",
776            Some("https://up.example/order/1/finalize"),
777            b"secret-csr-bytes",
778            &db,
779        )
780        .await
781        .unwrap();
782
783        let (rows, total) = UpstreamOrder::search(
784            &UpstreamOrderQuery {
785                limit: 50,
786                ..UpstreamOrderQuery::default()
787            },
788            &db,
789        )
790        .await
791        .unwrap();
792        assert_eq!(total, 1);
793        let row = &rows[0];
794        assert_eq!(row.order_id, ord.id);
795        assert_eq!(row.profile, "default");
796        assert_eq!(row.local_status, OrderStatus::Pending);
797        let names: Vec<&str> = row.identifiers.iter().map(|i| i.value.as_str()).collect();
798        assert_eq!(names, vec!["a.example.com", "b.example.com"]);
799        // The struct has no `csr_der` field, and the rendered JSON must never
800        // carry the bytes — see `render_upstream_order_json` tests.
801    }
802
803    #[tokio::test]
804    async fn search_filters_profile_and_status_together_and_pages() {
805        let db = database().await;
806        let a = order_on("default", &["a.example.com"], &db).await;
807        let b = order_on("default", &["b.example.com"], &db).await;
808        let c = order_on("other", &["c.example.com"], &db).await;
809        for ord in [&a, &b, &c] {
810            UpstreamOrder::create(
811                ord.id.to_string().as_str(),
812                "https://up.example/o",
813                None,
814                b"csr",
815                &db,
816            )
817            .await
818            .unwrap();
819        }
820        UpstreamOrder::mark_invalid(b.id.to_string().as_str(), "upstream said no", &db)
821            .await
822            .unwrap();
823
824        // Profile narrows page and total together.
825        let (rows, total) = UpstreamOrder::search(
826            &UpstreamOrderQuery {
827                profile: Some("default".to_string()),
828                limit: 50,
829                ..UpstreamOrderQuery::default()
830            },
831            &db,
832        )
833        .await
834        .unwrap();
835        assert_eq!(total, 2);
836        assert_eq!(rows.len(), 2);
837
838        // Profile + status together.
839        let (rows, total) = UpstreamOrder::search(
840            &UpstreamOrderQuery {
841                profile: Some("default".to_string()),
842                status: Some(UpstreamOrderStatus::Invalid),
843                limit: 50,
844                offset: 0,
845            },
846            &db,
847        )
848        .await
849        .unwrap();
850        assert_eq!(total, 1);
851        assert_eq!(rows[0].order_id, b.id);
852        assert_eq!(rows[0].error.as_deref(), Some("upstream said no"));
853
854        // A hostile profile value is bound, not interpolated.
855        let (rows, total) = UpstreamOrder::search(
856            &UpstreamOrderQuery {
857                profile: Some("' OR 1=1 --".to_string()),
858                limit: 50,
859                ..UpstreamOrderQuery::default()
860            },
861            &db,
862        )
863        .await
864        .unwrap();
865        assert_eq!(total, 0);
866        assert!(rows.is_empty());
867
868        // Single-row pages over the three rows see each once; total stays 3.
869        let mut seen = std::collections::BTreeSet::new();
870        for offset in 0..3 {
871            let (rows, total) = UpstreamOrder::search(
872                &UpstreamOrderQuery {
873                    limit: 1,
874                    offset,
875                    ..UpstreamOrderQuery::default()
876                },
877                &db,
878            )
879            .await
880            .unwrap();
881            assert_eq!(total, 3);
882            assert!(seen.insert(rows[0].order_id));
883        }
884        assert_eq!(seen.len(), 3);
885    }
886
887    #[tokio::test]
888    async fn find_row_by_order_id_is_none_for_junk_and_for_an_unrelayed_order() {
889        let db = database().await;
890        assert!(
891            UpstreamOrder::find_row_by_order_id("nope", &db)
892                .await
893                .unwrap()
894                .is_none()
895        );
896        let ord = order_on("default", &["a.example.com"], &db).await;
897        // The order exists but no relay was opened for it.
898        assert!(
899            UpstreamOrder::find_row_by_order_id(ord.id.to_string().as_str(), &db)
900                .await
901                .unwrap()
902                .is_none()
903        );
904    }
905}