1use 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
48const RENEW_CAPABILITY_BEFORE: Duration = Duration::from_secs(60);
51
52const UNAVAILABLE_RETRIES: u32 = 6;
55const UNAVAILABLE_WAIT: Duration = Duration::from_millis(250);
56
57#[derive(Clone)]
59pub struct RemoteScandal {
60 client: ScandalClient,
61 exchange: ClientWithMiddleware,
62 capabilities: Option<Arc<Capabilities>>,
63}
64
65impl RemoteScandal {
66 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 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 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 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 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 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
588async 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
615struct Capabilities {
617 caller: TokenSource,
618 token_endpoint: Url,
619 api: String,
621 current: Mutex<Option<Narrowed>>,
623}
624
625struct Narrowed {
627 parent: Token,
628 capability: Token,
629}
630
631impl Capabilities {
632 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
670fn 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
682fn 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 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 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}