Skip to main content

spiral/
remote_scandal.rs

1//! Remote scandal: the scandal contract's generated HTTP client, with Arrow IPC for rows.
2
3use std::fmt::Display;
4use std::future::Future;
5use std::hash::{BuildHasher as _, Hasher as _, RandomState};
6use std::ops::Range;
7use std::sync::Arc;
8use std::time::Duration;
9
10use arrow_array::RecordBatch;
11use arrow_schema::{Schema, SchemaRef};
12use async_trait::async_trait;
13use futures::{StreamExt as _, TryStreamExt as _, stream};
14use reqwest::Url;
15use reqwest_middleware::ClientWithMiddleware;
16use spiral_contract::auth::AuthHeader;
17use spiral_contract::call::CallError;
18use spiral_contract::safelong::SafeUnsignedLong;
19use spiral_contract::stream::ByteStream as ContractStream;
20use tokio::sync::Mutex;
21use uuid::Uuid;
22
23use crate::api::scandal::ScandalClient;
24use crate::auth::{Token, TokenSource, exchange_token, token_endpoint};
25use crate::http::default_http_client;
26use crate::ipc::{ArrowIpcReader, BodyError};
27use crate::scandal::api::{
28    AbortBlobUploadRequest, AlignedFragment, AppendRowsRequest, ArrowSchema, AttachColumnsRequest,
29    AttachedColumns, BindProjectionRequest, ByteRange, ChangePlan, CompleteBlobUploadRequest,
30    DeleteRowsRequest, EvaluateRequest, FinalizeWriteRequest, PackBlobsRequest, PatchRowsRequest,
31    PlanChangesRequest, PlanScanRequest, ReadBlobRequest, ReadLegacyTableRequest, ReadScanRequest,
32    ScandalApi, SearchVectorsRequest, StartBlobUploadRequest, StatBlobRequest, TakeRowsRequest,
33    UploadBlobPartRequest, WriteAlignedRequest, WriteBlobRequest,
34};
35use crate::scandal::blob::{Blob, BlobPart, BlobStat, BlobUpload};
36use crate::scandal::error::ScandalError;
37use crate::scandal::expression::NamedExpression;
38use crate::scandal::functions::FunctionInfo;
39use crate::scandal::types::{
40    FragmentReceipt, PreparedWriteReceipt, Scan, ScanPlan, Split, SplitTake,
41};
42use crate::scandal::wire::{self, CAPABILITY_AUDIENCE};
43use crate::scandal::{
44    ByteStream, Evaluation, RecordBatchStream, Scandal, SplitRead, StorageBinding, TableBinding,
45    WriteBase,
46};
47
48/// A capability is renewed once it has less than this left, so it is still valid when scandal
49/// admits the request despite transit and clock skew.
50const RENEW_CAPABILITY_BEFORE: Duration = Duration::from_secs(60);
51
52/// Times a request that no replica admitted is sent again. Each wait is random up to a bound
53/// that starts at [`UNAVAILABLE_WAIT`] and doubles, so refused clients do not return together.
54const UNAVAILABLE_RETRIES: u32 = 6;
55const UNAVAILABLE_WAIT: Duration = Duration::from_millis(250);
56
57/// Scandal served by a remote `scandal` endpoint.
58#[derive(Clone)]
59pub struct RemoteScandal {
60    client: ScandalClient,
61    exchange: ClientWithMiddleware,
62    capabilities: Option<Arc<Capabilities>>,
63}
64
65impl RemoteScandal {
66    /// Call the scandal service at `base_url`.
67    pub fn new(base_url: Url) -> Result<Self, ScandalError> {
68        let http = default_http_client().map_err(local_error)?;
69        let client = ScandalClient::new(base_url.as_str()).map_err(call_error)?;
70        Ok(Self {
71            client,
72            exchange: http.clone(),
73            capabilities: None,
74        }
75        .with_http(http))
76    }
77
78    /// Use the shared client's connection pool and transport configuration. Redirects must
79    /// be disabled to protect bearer credentials.
80    pub fn with_http(mut self, http: impl Into<ClientWithMiddleware>) -> Self {
81        let http = http.into();
82        self.exchange = http.clone();
83        self.client = self.client.with_http(http);
84        self
85    }
86
87    /// Call scandal as `caller`: every request carries a scandal capability, the caller's access
88    /// token narrowed by the authorization server at `auth_url` to scandal and the stack at
89    /// `api_url`, and renewed before it expires or when the caller's token changes. Scandal
90    /// admits and attributes each request by it, and binds the `spiral://` addresses a read
91    /// meets as the caller at the stack.
92    pub fn with_caller(
93        mut self,
94        caller: TokenSource,
95        auth_url: &Url,
96        api_url: &Url,
97    ) -> Result<Self, ScandalError> {
98        self.capabilities = Some(Arc::new(Capabilities {
99            caller,
100            token_endpoint: token_endpoint(auth_url).map_err(local_error)?,
101            api: api_url.as_str().trim_end_matches('/').to_owned(),
102            current: Mutex::new(None),
103        }));
104        Ok(self)
105    }
106
107    /// The Authorization header of the next request: the caller's capability, or none for a
108    /// scandal that admits every request.
109    async fn auth(&self) -> Result<AuthHeader, ScandalError> {
110        match &self.capabilities {
111            Some(capabilities) => {
112                let capability = capabilities.current(&self.exchange).await?;
113                Ok(AuthHeader::new(format!(
114                    "Bearer {}",
115                    capability.expose_secret()
116                )))
117            }
118            None => Ok(AuthHeader::new("")),
119        }
120    }
121
122    /// Send a request until a replica admits it. A replica that refuses one as `Unavailable`
123    /// started no work, so a request whose body can be sent again is safe to resend.
124    async fn admitted<T, Request>(
125        &self,
126        mut request: impl FnMut() -> Request,
127    ) -> Result<T, ScandalError>
128    where
129        Request: Future<Output = Result<T, ScandalError>>,
130    {
131        let mut bound = UNAVAILABLE_WAIT;
132        for _ in 0..UNAVAILABLE_RETRIES {
133            match request().await {
134                Err(ScandalError::Unavailable { .. }) => {
135                    tokio::time::sleep(bound.mul_f64(jitter())).await;
136                    bound *= 2;
137                }
138                result => return result,
139            }
140        }
141        request().await
142    }
143}
144
145#[async_trait]
146impl Scandal for RemoteScandal {
147    async fn read_legacy_table(
148        &self,
149        request: &ReadLegacyTableRequest,
150    ) -> Result<ByteStream, ScandalError> {
151        let body = self
152            .admitted(|| async {
153                self.client
154                    .read_legacy_table(self.auth().await?, request.clone())
155                    .await
156                    .map_err(call_error)
157            })
158            .await?;
159        Ok(body.map_err(|error| local_error(&*error)).boxed())
160    }
161
162    async fn list_functions(&self) -> Result<Vec<FunctionInfo>, ScandalError> {
163        let catalog = self
164            .admitted(|| async {
165                self.client
166                    .list_functions(self.auth().await?)
167                    .await
168                    .map_err(call_error)
169            })
170            .await?;
171        Ok(catalog.functions)
172    }
173
174    async fn plan_scan(
175        &self,
176        scan: &Scan,
177        storage: &StorageBinding,
178    ) -> Result<ScanPlan, ScandalError> {
179        let request = PlanScanRequest {
180            scan: scan.clone(),
181            storage: Some(storage.clone()),
182        };
183        self.admitted(|| async {
184            self.client
185                .plan_scan(self.auth().await?, request.clone())
186                .await
187                .map_err(call_error)
188        })
189        .await
190    }
191
192    async fn read_scan(
193        &self,
194        scan: &Scan,
195        split: &Split,
196        storage: &StorageBinding,
197    ) -> Result<SplitRead, ScandalError> {
198        let request = ReadScanRequest {
199            scan: scan.clone(),
200            split: split.clone(),
201            storage: Some(storage.clone()),
202        };
203        let body = self
204            .admitted(|| async {
205                self.client
206                    .read_scan(self.auth().await?, request.clone())
207                    .await
208                    .map_err(call_error)
209            })
210            .await?;
211        split_read(body).await
212    }
213
214    async fn take_rows(
215        &self,
216        scan: &Scan,
217        takes: &[SplitTake],
218        storage: &StorageBinding,
219    ) -> Result<SplitRead, ScandalError> {
220        let request = TakeRowsRequest {
221            scan: scan.clone(),
222            takes: takes.to_vec(),
223            storage: Some(storage.clone()),
224        };
225        let body = self
226            .admitted(|| async {
227                self.client
228                    .take_rows(self.auth().await?, request.clone())
229                    .await
230                    .map_err(call_error)
231            })
232            .await?;
233        split_read(body).await
234    }
235
236    async fn read_blob(
237        &self,
238        blob: &Blob,
239        range: Option<Range<u64>>,
240        storage: &StorageBinding,
241    ) -> Result<ByteStream, ScandalError> {
242        let request = ReadBlobRequest {
243            blob: blob.clone(),
244            range: range.map(|range| ByteRange {
245                start: range.start.into(),
246                end: range.end.into(),
247            }),
248            storage: Some(storage.clone()),
249        };
250        let body = self
251            .admitted(|| async {
252                self.client
253                    .read_blob(self.auth().await?, request.clone())
254                    .await
255                    .map_err(call_error)
256            })
257            .await?;
258        Ok(body.map_err(|error| local_error(&*error)).boxed())
259    }
260
261    async fn stat_blob(
262        &self,
263        blob: &Blob,
264        storage: &StorageBinding,
265    ) -> Result<BlobStat, ScandalError> {
266        let request = StatBlobRequest {
267            blob: blob.clone(),
268            storage: Some(storage.clone()),
269        };
270        self.admitted(|| async {
271            self.client
272                .stat_blob(self.auth().await?, request.clone())
273                .await
274                .map_err(call_error)
275        })
276        .await
277    }
278
279    async fn write_blob(
280        &self,
281        table: &TableBinding,
282        media_type: &str,
283        max_bytes: u64,
284        input: ByteStream,
285    ) -> Result<Blob, ScandalError> {
286        let request = WriteBlobRequest {
287            table: table.clone(),
288            media_type: media_type.to_owned(),
289            max_bytes: SafeUnsignedLong(max_bytes),
290        };
291        self.client
292            .write_blob(self.auth().await?, request, ContractStream::new(input))
293            .await
294            .map_err(call_error)
295    }
296
297    async fn pack_blobs(
298        &self,
299        table: &TableBinding,
300        attempt: Option<Uuid>,
301        schema: SchemaRef,
302        input: RecordBatchStream,
303    ) -> Result<Vec<RecordBatch>, ScandalError> {
304        let request = PackBlobsRequest {
305            table: table.clone(),
306            attempt,
307        };
308        let rows = ContractStream::new(wire::encode_input(schema, input));
309        let body = self
310            .client
311            .pack_blobs(self.auth().await?, request, rows)
312            .await
313            .map_err(call_error)?;
314        let (_, batches) = arrow(body).await?;
315        batches.try_collect().await
316    }
317
318    async fn start_blob_upload(
319        &self,
320        table: &TableBinding,
321        attempt: Uuid,
322        media_type: &str,
323        part_bytes: Option<u64>,
324    ) -> Result<BlobUpload, ScandalError> {
325        let request = StartBlobUploadRequest {
326            table: table.clone(),
327            attempt,
328            media_type: media_type.to_owned(),
329            part_bytes: part_bytes.map(SafeUnsignedLong),
330        };
331        self.admitted(|| async {
332            self.client
333                .start_blob_upload(self.auth().await?, request.clone())
334                .await
335                .map_err(call_error)
336        })
337        .await
338    }
339
340    async fn upload_blob_part(
341        &self,
342        upload: &BlobUpload,
343        part: u32,
344        input: ByteStream,
345        storage: &StorageBinding,
346    ) -> Result<BlobPart, ScandalError> {
347        let request = UploadBlobPartRequest {
348            upload: upload.clone(),
349            storage: Some(storage.clone()),
350            part,
351        };
352        // A part is bounded, so it is held to be sent again when a full replica refuses it.
353        let chunks = input.try_collect::<Vec<_>>().await?;
354        self.admitted(|| async {
355            let body = stream::iter(chunks.clone().into_iter().map(Ok::<_, ScandalError>));
356            self.client
357                .upload_blob_part(
358                    self.auth().await?,
359                    request.clone(),
360                    ContractStream::new(body),
361                )
362                .await
363                .map_err(call_error)
364        })
365        .await
366    }
367
368    async fn complete_blob_upload(
369        &self,
370        upload: &BlobUpload,
371        parts: &[BlobPart],
372        storage: &StorageBinding,
373    ) -> Result<Blob, ScandalError> {
374        let request = CompleteBlobUploadRequest {
375            upload: upload.clone(),
376            storage: Some(storage.clone()),
377            parts: parts.to_vec(),
378        };
379        self.admitted(|| async {
380            self.client
381                .complete_blob_upload(self.auth().await?, request.clone())
382                .await
383                .map_err(call_error)
384        })
385        .await
386    }
387
388    async fn abort_blob_upload(
389        &self,
390        upload: &BlobUpload,
391        storage: &StorageBinding,
392    ) -> Result<(), ScandalError> {
393        let request = AbortBlobUploadRequest {
394            upload: upload.clone(),
395            storage: Some(storage.clone()),
396        };
397        self.admitted(|| async {
398            self.client
399                .abort_blob_upload(self.auth().await?, request.clone())
400                .await
401                .map_err(call_error)
402        })
403        .await
404    }
405
406    async fn append_rows(
407        &self,
408        table: &TableBinding,
409        base: Option<Uuid>,
410        attempt: Option<Uuid>,
411        schema: SchemaRef,
412        input: RecordBatchStream,
413    ) -> Result<Vec<FragmentReceipt>, ScandalError> {
414        self.append_partition(
415            &AppendRowsRequest {
416                table: table.clone(),
417                base,
418                attempt,
419                properties: None,
420                schema_changes: None,
421            },
422            schema,
423            input,
424        )
425        .await
426    }
427
428    async fn append_partition(
429        &self,
430        request: &AppendRowsRequest,
431        schema: SchemaRef,
432        input: RecordBatchStream,
433    ) -> Result<Vec<FragmentReceipt>, ScandalError> {
434        let rows = ContractStream::new(wire::encode_input(schema, input));
435        let appended = self
436            .client
437            .append_rows(self.auth().await?, request.clone(), rows)
438            .await
439            .map_err(call_error)?;
440        Ok(appended.receipts)
441    }
442
443    async fn patch_rows(
444        &self,
445        request: &PatchRowsRequest,
446        schema: SchemaRef,
447        input: RecordBatchStream,
448    ) -> Result<Vec<FragmentReceipt>, ScandalError> {
449        let rows = ContractStream::new(wire::encode_input(schema, input));
450        let written = self
451            .client
452            .patch_rows(self.auth().await?, request.clone(), rows)
453            .await
454            .map_err(call_error)?;
455        Ok(written.receipts)
456    }
457
458    async fn delete_rows(
459        &self,
460        request: &DeleteRowsRequest,
461        schema: SchemaRef,
462        input: RecordBatchStream,
463    ) -> Result<Vec<FragmentReceipt>, ScandalError> {
464        let rows = ContractStream::new(wire::encode_input(schema, input));
465        let written = self
466            .client
467            .delete_rows(self.auth().await?, request.clone(), rows)
468            .await
469            .map_err(call_error)?;
470        Ok(written.receipts)
471    }
472
473    async fn attach_columns(
474        &self,
475        request: &AttachColumnsRequest,
476        schema: SchemaRef,
477        input: RecordBatchStream,
478    ) -> Result<AttachedColumns, ScandalError> {
479        let rows = ContractStream::new(wire::encode_input(schema, input));
480        self.client
481            .attach_columns(self.auth().await?, request.clone(), rows)
482            .await
483            .map_err(call_error)
484    }
485
486    async fn write_aligned(
487        &self,
488        request: &WriteAlignedRequest,
489        schema: SchemaRef,
490        input: RecordBatchStream,
491    ) -> Result<AlignedFragment, ScandalError> {
492        let rows = ContractStream::new(wire::encode_input(schema, input));
493        self.client
494            .write_aligned(self.auth().await?, request.clone(), rows)
495            .await
496            .map_err(call_error)
497    }
498
499    async fn plan_changes(&self, request: &PlanChangesRequest) -> Result<ChangePlan, ScandalError> {
500        self.admitted(|| async {
501            self.client
502                .plan_changes(self.auth().await?, request.clone())
503                .await
504                .map_err(call_error)
505        })
506        .await
507    }
508
509    async fn finalize_write(
510        &self,
511        table: &TableBinding,
512        base: &WriteBase,
513        receipts: &[FragmentReceipt],
514    ) -> Result<PreparedWriteReceipt, ScandalError> {
515        let request = FinalizeWriteRequest {
516            table: table.clone(),
517            base: base.clone(),
518            receipts: receipts.to_vec(),
519        };
520        self.admitted(|| async {
521            self.client
522                .finalize_write(self.auth().await?, request.clone())
523                .await
524                .map_err(call_error)
525        })
526        .await
527    }
528
529    async fn bind_projection(
530        &self,
531        input: &Schema,
532        projection: &[NamedExpression],
533    ) -> Result<SchemaRef, ScandalError> {
534        let request = BindProjectionRequest {
535            input: ArrowSchema::from_schema(input)?,
536            projection: projection.to_vec(),
537        };
538        let schema = self
539            .admitted(|| async {
540                self.client
541                    .bind_projection(self.auth().await?, request.clone())
542                    .await
543                    .map_err(call_error)
544            })
545            .await?;
546        Ok(SchemaRef::new(schema.to_schema()?))
547    }
548
549    async fn search_vectors(
550        &self,
551        request: &SearchVectorsRequest,
552    ) -> Result<Evaluation, ScandalError> {
553        request.validate()?;
554        let body = self
555            .admitted(|| async {
556                self.client
557                    .search_vectors(self.auth().await?, request.clone())
558                    .await
559                    .map_err(call_error)
560            })
561            .await?;
562        let (schema, batches) = arrow(body).await?;
563        Ok(Evaluation { schema, batches })
564    }
565
566    async fn evaluate(
567        &self,
568        projection: &[NamedExpression],
569        input_schema: SchemaRef,
570        input: RecordBatchStream,
571        tables: &[TableBinding],
572    ) -> Result<Evaluation, ScandalError> {
573        let request = EvaluateRequest {
574            projection: projection.to_vec(),
575            tables: tables.to_vec(),
576        };
577        let rows = ContractStream::new(wire::encode_input(input_schema, input));
578        let body = self
579            .client
580            .evaluate(self.auth().await?, request, rows)
581            .await
582            .map_err(call_error)?;
583        let (schema, batches) = arrow(body).await?;
584        Ok(Evaluation { schema, batches })
585    }
586}
587
588/// Decode an Arrow IPC response without buffering it.
589/// A read's response body: its rows, each batch's progress separated from them.
590async fn split_read(body: ContractStream) -> Result<SplitRead, ScandalError> {
591    let (schema, batches) = arrow(body).await?;
592    Ok(SplitRead {
593        schema: wire::read_scan_rows(&schema)?,
594        batches: batches
595            .map(|batch| batch.and_then(wire::detach_progress))
596            .boxed(),
597    })
598}
599
600async fn arrow(body: ContractStream) -> Result<(SchemaRef, RecordBatchStream), ScandalError> {
601    let source = body.map_err(|error| error as BodyError).boxed();
602    let reader = ArrowIpcReader::new(source).await.map_err(local_error)?;
603    let schema = wire::execution_rows(&reader.schema())?;
604    let batches = stream::unfold(reader, |mut reader| async move {
605        reader.next_batch().await.map(|batch| {
606            (
607                batch.map_err(local_error).and_then(wire::detach_execution),
608                reader,
609            )
610        })
611    });
612    Ok((schema, wire::execution_batches(batches.boxed())))
613}
614
615/// The scandal capabilities of one caller (plan 122 D5).
616struct Capabilities {
617    caller: TokenSource,
618    token_endpoint: Url,
619    /// The caller's stack API, which each capability also names.
620    api: String,
621    /// The last capability, with the caller's token it narrows.
622    current: Mutex<Option<Narrowed>>,
623}
624
625/// A capability and the caller's token it was narrowed from.
626struct Narrowed {
627    parent: Token,
628    capability: Token,
629}
630
631impl Capabilities {
632    /// A capability narrowed from the caller's current token with time left, exchanged anew
633    /// when the last one nears expiry or the caller's token has changed.
634    async fn current(&self, http: &ClientWithMiddleware) -> Result<Token, ScandalError> {
635        let unauthorized = |reason: String| ScandalError::Unauthenticated {
636            message: format!("no scandal capability: {reason}"),
637        };
638        let token = self
639            .caller
640            .token()
641            .await
642            .map_err(|error| unauthorized(error.to_string()))?
643            .ok_or_else(|| unauthorized("the caller has no credentials".to_owned()))?;
644        let mut current = self.current.lock().await;
645        if let Some(narrowed) = current.as_ref()
646            && narrowed.parent.expose_secret() == token.expose_secret()
647            && narrowed
648                .capability
649                .remaining_lifetime()
650                .is_ok_and(|remaining| remaining > RENEW_CAPABILITY_BEFORE)
651        {
652            return Ok(narrowed.capability.clone());
653        }
654        let capability = exchange_token(
655            http,
656            &self.token_endpoint,
657            &token,
658            &[CAPABILITY_AUDIENCE, &self.api],
659        )
660        .await
661        .map_err(|error| unauthorized(error.to_string()))?;
662        *current = Some(Narrowed {
663            parent: token,
664            capability: capability.clone(),
665        });
666        Ok(capability)
667    }
668}
669
670/// A random fraction in `0..1`, from the standard library's per-process random hash keys.
671fn jitter() -> f64 {
672    let random = RandomState::new().build_hasher().finish();
673    (random >> 11) as f64 / (1u64 << 53) as f64
674}
675
676fn local_error(error: impl Display) -> ScandalError {
677    ScandalError::Internal {
678        message: error.to_string(),
679    }
680}
681
682/// The scandal error a call failed with. A declared error keeps its kind; an undeclared
683/// rejection, such as a request the router refused, keeps its envelope's class; a failure to
684/// reach scandal is internal, so a read retries it.
685fn call_error(error: CallError<ScandalError>) -> ScandalError {
686    match error {
687        CallError::Service(failure) => match failure.into_declared() {
688            Ok(error) => error,
689            Err(failure) => {
690                let envelope = failure.envelope();
691                let message = envelope.to_string();
692                match envelope.error_code.as_str() {
693                    "UNAUTHENTICATED" => ScandalError::Unauthenticated { message },
694                    "INVALID_ARGUMENT" => ScandalError::InvalidArgument { message },
695                    _ => ScandalError::from_wire(envelope),
696                }
697            }
698        },
699        error => local_error(error),
700    }
701}
702
703#[cfg(test)]
704pub(crate) mod tests {
705    use std::sync::atomic::{AtomicUsize, Ordering};
706    use std::time::{SystemTime, UNIX_EPOCH};
707
708    use base64::Engine as _;
709    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
710    use serde_json::json;
711    use wiremock::matchers::{body_string_contains, header, method, path};
712    use wiremock::{Mock, MockServer, ResponseTemplate};
713
714    use super::*;
715    use crate::auth::TokenSource;
716
717    /// An unsigned JWT for `sub` that expires after `ttl`. Only scandal verifies capabilities;
718    /// the SDK reads their expiry.
719    pub(crate) fn unsigned_jwt(sub: &str, ttl: Duration) -> String {
720        let exp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap() + ttl;
721        let encode = |value: serde_json::Value| URL_SAFE_NO_PAD.encode(value.to_string());
722        format!(
723            "{}.{}.signature",
724            encode(json!({ "alg": "RS256", "typ": "JWT" })),
725            encode(json!({ "sub": sub, "exp": exp.as_secs() })),
726        )
727    }
728
729    /// Mount an exchange of `caller`'s token for `capability`, and a catalog that admits only
730    /// `capability`.
731    pub(crate) async fn issue_capability(
732        server: &MockServer,
733        caller: &str,
734        capability: &str,
735        exchanges: u64,
736        requests: u64,
737    ) {
738        Mock::given(method("POST"))
739            .and(path("/oauth/token"))
740            .and(body_string_contains(format!("subject_token={caller}&")))
741            .and(body_string_contains(
742                "resource=https%3A%2F%2Fscandal.spiraldb.dev&resource=",
743            ))
744            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
745                "access_token": capability,
746                "token_type": "Bearer",
747                "expires_in": 900,
748            })))
749            .expect(exchanges)
750            .mount(server)
751            .await;
752        Mock::given(method("GET"))
753            .and(path("/functions"))
754            .and(header("authorization", format!("Bearer {capability}")))
755            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "functions": [] })))
756            .expect(requests)
757            .mount(server)
758            .await;
759    }
760
761    async fn scandal_for(server: &MockServer, caller: &str) -> RemoteScandal {
762        let url: Url = server.uri().parse().unwrap();
763        RemoteScandal::new(url.clone())
764            .unwrap()
765            .with_caller(TokenSource::fixed(caller.into()), &url, &url)
766            .unwrap()
767    }
768
769    #[tokio::test]
770    async fn requests_carry_a_capability_exchanged_from_the_callers_token() {
771        let server = MockServer::start().await;
772        let capability = unsigned_jwt("user_test", Duration::from_secs(900));
773        issue_capability(&server, "stack-token", &capability, 1, 2).await;
774
775        let scandal = scandal_for(&server, "stack-token").await;
776        for _ in 0..2 {
777            scandal.list_functions().await.unwrap();
778        }
779    }
780
781    #[tokio::test]
782    async fn renews_a_capability_before_it_expires() {
783        let server = MockServer::start().await;
784        let capability = unsigned_jwt("user_test", RENEW_CAPABILITY_BEFORE / 2);
785        issue_capability(&server, "stack-token", &capability, 2, 2).await;
786
787        let scandal = scandal_for(&server, "stack-token").await;
788        for _ in 0..2 {
789            scandal.list_functions().await.unwrap();
790        }
791    }
792
793    #[tokio::test]
794    async fn narrows_the_callers_new_token_when_it_changes() {
795        let server = MockServer::start().await;
796        let first = unsigned_jwt("user_test", Duration::from_secs(900));
797        let second = unsigned_jwt("user_other", Duration::from_secs(900));
798        issue_capability(&server, "first-token", &first, 1, 1).await;
799        issue_capability(&server, "second-token", &second, 1, 1).await;
800
801        let calls = Arc::new(AtomicUsize::new(0));
802        let caller = TokenSource::new(move || {
803            let call = calls.fetch_add(1, Ordering::SeqCst);
804            async move {
805                Ok(Some(Token::from(if call == 0 {
806                    "first-token"
807                } else {
808                    "second-token"
809                })))
810            }
811        });
812        let url: Url = server.uri().parse().unwrap();
813        let scandal = RemoteScandal::new(url.clone())
814            .unwrap()
815            .with_caller(caller, &url, &url)
816            .unwrap();
817        for _ in 0..2 {
818            scandal.list_functions().await.unwrap();
819        }
820    }
821
822    #[tokio::test]
823    async fn a_refused_exchange_fails_before_scandal_is_called() {
824        let server = MockServer::start().await;
825        Mock::given(method("POST"))
826            .and(path("/oauth/token"))
827            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
828                "error": "invalid_request",
829                "error_description": "the subject token is not for https://scandal.spiraldb.dev",
830            })))
831            .expect(1)
832            .mount(&server)
833            .await;
834        Mock::given(method("GET"))
835            .and(path("/functions"))
836            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "functions": [] })))
837            .expect(0)
838            .mount(&server)
839            .await;
840
841        let error = scandal_for(&server, "spfs-capability")
842            .await
843            .list_functions()
844            .await
845            .unwrap_err();
846        assert!(
847            matches!(error, ScandalError::Unauthenticated { .. }),
848            "{error:?}"
849        );
850    }
851}