1use std::collections::BTreeSet;
9
10use base64::Engine;
11use headless_lms_utils::services::suotar::SuotarEndpoint;
12use itertools::Itertools;
13use serde::de::DeserializeOwned;
14use sqlx::PgPool;
15
16use crate::prelude::*;
17
18use super::default_world;
19use super::faults::{Effect, Fault, FaultMatch, ItemAddress, Stage, matches_item, matches_request};
20use super::logic;
21use super::store::{MockSuotarStore, Preamble};
22use super::wire::{self, ErasedResponseItem, RequestLevelError, SuotarItemStatus};
23use super::world::{MissedFault, RecordedCall, RecordedFaults, RecordedItem, WorkingSet};
24
25const RAW_BODY_LIMIT: usize = 8 * 1024;
26
27macro_rules! endpoint_handlers {
29 ($($handler:ident => $endpoint:ident,)*) => {
30 $(
31 pub async fn $handler(
32 app_conf: web::Data<ApplicationConfiguration>,
33 store: web::Data<MockSuotarStore>,
34 pool: web::Data<PgPool>,
35 req: HttpRequest,
36 body: web::Bytes,
37 ) -> ControllerResult<HttpResponse> {
38 endpoint(
39 SuotarEndpoint::$endpoint,
40 app_conf,
41 store,
42 pool,
43 req,
44 body,
45 )
46 .await
47 }
48 )*
49 };
50}
51
52endpoint_handlers! {
53 resolve_persons => ResolvePersons,
54 resolve_enrolments => ResolveEnrolments,
55 list_by_course => ListByCourse,
56 import_attainments => ImportAttainments,
57 verify_attainments => VerifyAttainments,
58 resolve_product_access_tokens => ProductAccessTokens,
59}
60
61async fn endpoint(
62 endpoint: SuotarEndpoint,
63 app_conf: web::Data<ApplicationConfiguration>,
64 store: web::Data<MockSuotarStore>,
65 pool: web::Data<PgPool>,
66 req: HttpRequest,
67 body: web::Bytes,
68) -> ControllerResult<HttpResponse> {
69 super::assert_enabled(&app_conf);
70 let token = skip_authorize();
71 let delivery = match run(endpoint, &store, &pool, &req, &body).await {
72 Ok(delivery) => delivery,
73 Err(error) => {
74 error!("mock Suotar failed to serve a request: {error:?}");
76 return token.authorized_ok(HttpResponse::InternalServerError().json(
77 RequestLevelError::with_message("internalError", error.to_string()),
78 ));
79 }
80 };
81 token.authorized_ok(deliver(delivery))
82}
83
84fn deliver(delivery: Delivery) -> HttpResponse {
85 match delivery {
86 Delivery::Json { status, body } => HttpResponse::build(
87 actix_web::http::StatusCode::from_u16(status)
88 .unwrap_or(actix_web::http::StatusCode::OK),
89 )
90 .content_type("application/json")
91 .body(body),
92 Delivery::ConnectionReset => {
93 let stream = futures::stream::once(async {
94 Err::<web::Bytes, actix_web::Error>(actix_web::error::ErrorInternalServerError(
95 "mock Suotar dropped the connection",
96 ))
97 });
98 HttpResponse::Ok().streaming(stream)
99 }
100 }
101}
102
103enum Delivery {
104 Json { status: u16, body: String },
105 ConnectionReset,
106}
107
108impl Delivery {
109 fn json<T: Serialize>(status: u16, value: &T) -> Self {
110 Self::Json {
111 status,
112 body: serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()),
113 }
114 }
115}
116
117struct Terminal {
119 delivery: Delivery,
120 status: u16,
121 request_level_code: Option<String>,
122 effect: String,
123}
124
125fn terminal(endpoint: SuotarEndpoint, effect: &Effect) -> Option<Terminal> {
127 let kind = effect.kind().to_string();
128 match effect {
129 Effect::ConnectionReset => Some(Terminal {
130 delivery: Delivery::ConnectionReset,
131 status: 200,
132 request_level_code: None,
133 effect: kind,
134 }),
135 Effect::RequestLevel {
136 status,
137 code,
138 message,
139 } => {
140 let error = match message {
141 Some(message) => RequestLevelError::with_message(code, message.clone()),
142 None => RequestLevelError::new(endpoint, code),
143 };
144 Some(Terminal {
145 delivery: Delivery::json(*status, &error),
146 status: *status,
147 request_level_code: Some(code.clone()),
148 effect: kind,
149 })
150 }
151 Effect::ItemLevel { .. } => None,
152 }
153}
154
155async fn run(
156 endpoint: SuotarEndpoint,
157 store: &MockSuotarStore,
158 pool: &PgPool,
159 req: &HttpRequest,
160 body: &[u8],
161) -> anyhow::Result<Delivery> {
162 let now = Utc::now();
163 let (generation, preamble) = resolve_world(store, pool).await?;
164 let mut runner = FaultRunner {
165 store,
166 generation: &generation,
167 faults: &preamble.faults,
168 preamble: &preamble,
169 log: RecordedFaults::default(),
170 };
171 let mut call = RecordedCall {
172 seq: store.next_call_seq(&generation).await?,
173 received_at: now,
174 endpoint,
175 correlation_id: req
176 .headers()
177 .get("X-Correlation-Id")
178 .and_then(|value| value.to_str().ok())
179 .map(str::to_string),
180 authorized: true,
181 http_status: 200,
182 request_level_code: None,
183 effect: None,
184 raw_body_truncated: truncate(body),
185 faults: RecordedFaults::default(),
186 items: Vec::new(),
187 };
188 let mut working = WorkingSet {
189 defaults: preamble.defaults.clone(),
190 ..Default::default()
191 };
192
193 if !authorized(req, &preamble) {
195 call.authorized = false;
196 return finish(
197 store,
198 &generation,
199 &working,
200 call,
201 runner.log,
202 Delivery::json(401, &RequestLevelError::new(endpoint, "unauthorized")),
203 401,
204 Some("unauthorized".to_string()),
205 None,
206 )
207 .await;
208 }
209
210 let parsed = match parse(endpoint, body, &preamble) {
211 Ok(parsed) => parsed,
212 Err((code, message)) => {
213 return finish(
214 store,
215 &generation,
216 &working,
217 call,
218 runner.log,
219 Delivery::json(400, &RequestLevelError::with_message(&code, message)),
220 400,
221 Some(code),
222 None,
223 )
224 .await;
225 }
226 };
227
228 let mut addresses = parsed.addresses();
229 load(store, &generation, &parsed, &mut working).await?;
230 parsed.enrich_addresses(&mut addresses, &working);
231
232 for stage in [Stage::Auth, Stage::RequestGate, Stage::Parse] {
235 if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await?
236 && let Some(terminal) = terminal(endpoint, &effect)
237 {
238 call.authorized = stage != Stage::Auth;
239 return finish(
240 store,
241 &generation,
242 &working,
243 call,
244 runner.log,
245 terminal.delivery,
246 terminal.status,
247 terminal.request_level_code,
248 Some(terminal.effect),
249 )
250 .await;
251 }
252 }
253
254 if let Some(effect) = runner
255 .request_stage(endpoint, Stage::Resolve, &addresses)
256 .await?
257 && let Some(terminal) = terminal(endpoint, &effect)
258 {
259 call.authorized = true;
260 return finish(
261 store,
262 &generation,
263 &working,
264 call,
265 runner.log,
266 terminal.delivery,
267 terminal.status,
268 terminal.request_level_code,
269 Some(terminal.effect),
270 )
271 .await;
272 }
273
274 let mut items = Vec::with_capacity(addresses.len());
275 for (index, address) in addresses.iter().enumerate() {
276 let fault = runner.item_stage(endpoint, Stage::Resolve, address).await?;
277 match fault {
278 Some(effect) => items.push(item_effect_response(endpoint, address, &effect, None)),
279 None => items.push(parsed.resolve(index, &mut working, now)),
280 }
281 }
282
283 let mut answered_by_fault: Option<Terminal> = None;
286 for stage in [Stage::AfterWrite, Stage::Respond] {
287 if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await? {
288 answered_by_fault = terminal(endpoint, &effect);
289 }
290 for (index, address) in addresses.iter().enumerate() {
291 if let Some(effect) = runner.item_stage(endpoint, stage, address).await?
292 && let Some(item) = items.get_mut(index)
293 {
294 *item = item_effect_response(endpoint, address, &effect, Some(item));
295 }
296 }
297 }
298
299 call.items = addresses
300 .iter()
301 .zip(items.iter())
302 .map(|(address, item)| RecordedItem {
303 request_item_id: address.request_item_id.clone(),
304 student_number: address.student_number.clone(),
305 course_code: address.course_code.clone(),
306 submitted_attainment_id: address.submitted_attainment_id.clone(),
307 product_id: address.product_id.clone(),
308 status: match item.status {
309 SuotarItemStatus::Ok => "ok".to_string(),
310 SuotarItemStatus::Error => "error".to_string(),
311 },
312 code: item.code.clone(),
313 })
314 .collect();
315
316 let (delivery, status, code, effect) = match answered_by_fault {
317 Some(terminal) => (
318 terminal.delivery,
319 terminal.status,
320 terminal.request_level_code,
321 Some(terminal.effect),
322 ),
323 None => (Delivery::json(200, &items), 200, None, None),
324 };
325 finish(
326 store,
327 &generation,
328 &working,
329 call,
330 runner.log,
331 delivery,
332 status,
333 code,
334 effect,
335 )
336 .await
337}
338
339#[allow(clippy::too_many_arguments)]
341async fn finish(
342 store: &MockSuotarStore,
343 generation: &str,
344 working: &WorkingSet,
345 mut call: RecordedCall,
346 log: RecordedFaults,
347 delivery: Delivery,
348 status: u16,
349 request_level_code: Option<String>,
350 effect: Option<String>,
351) -> anyhow::Result<Delivery> {
352 call.faults = log;
353 call.effect = effect;
354 call.http_status = status;
355 call.request_level_code = request_level_code;
356 let capacity = working.defaults.call_log_capacity.max(1);
357 store.commit(generation, working, &call, capacity).await?;
358 Ok(delivery)
359}
360
361async fn resolve_world(
362 store: &MockSuotarStore,
363 pool: &PgPool,
364) -> anyhow::Result<(String, Preamble)> {
365 if let Some(generation) = store.live_generation().await? {
366 let preamble = store.preamble(&generation).await?;
367 if preamble.defaults_present {
368 return Ok((generation, preamble));
369 }
370 }
371 let marker = default_world::db_generation_marker(pool).await;
372 let generation = store
373 .install_if_absent(&default_world::build(), marker.as_deref())
374 .await?;
375 let preamble = store.preamble(&generation).await?;
376 Ok((generation, preamble))
377}
378
379fn authorized(req: &HttpRequest, preamble: &Preamble) -> bool {
380 credential_accepted(
381 req.headers()
382 .get(actix_web::http::header::AUTHORIZATION)
383 .and_then(|value| value.to_str().ok()),
384 &preamble.defaults.accepted_token,
385 )
386}
387
388fn credential_accepted(header: Option<&str>, expected: &str) -> bool {
391 let Some(header) = header else {
392 return false;
393 };
394 let credential = header.split_whitespace().next_back().unwrap_or_default();
395 if credential == expected {
396 return true;
397 }
398 base64::engine::general_purpose::STANDARD
401 .decode(credential)
402 .ok()
403 .and_then(|bytes| String::from_utf8(bytes).ok())
404 .is_some_and(|decoded| {
405 decoded == expected
406 || decoded == format!("{expected}:")
407 || decoded.rsplit(':').next() == Some(expected)
408 })
409}
410
411fn truncate(body: &[u8]) -> String {
412 let text = String::from_utf8_lossy(body);
413 if text.len() <= RAW_BODY_LIMIT {
414 return text.into_owned();
415 }
416 let mut cut = RAW_BODY_LIMIT;
417 while cut > 0 && !text.is_char_boundary(cut) {
418 cut -= 1;
419 }
420 text[..cut].to_string()
421}
422
423struct FaultRunner<'a> {
424 store: &'a MockSuotarStore,
425 generation: &'a str,
426 faults: &'a [Fault],
427 preamble: &'a Preamble,
428 log: RecordedFaults,
429}
430
431impl FaultRunner<'_> {
432 async fn request_stage(
434 &mut self,
435 endpoint: SuotarEndpoint,
436 stage: Stage,
437 items: &[ItemAddress],
438 ) -> anyhow::Result<Option<Effect>> {
439 let mut winner = None;
440 for fault in self.faults {
441 if !fault.then.is_request_shaped() {
442 continue;
443 }
444 match matches_request(fault, endpoint, stage, items) {
445 FaultMatch::Missed(predicate) => {
446 self.record_miss(fault, endpoint, stage, predicate)
447 }
448 FaultMatch::Fires => {
449 if winner.is_some() {
450 self.log.shadowed.push(fault.id.clone());
451 continue;
452 }
453 if self.draw(fault).await? {
454 self.log.applied.push(fault.id.clone());
455 winner = Some(fault.then.clone());
456 }
457 }
458 }
459 }
460 Ok(winner)
461 }
462
463 async fn item_stage(
464 &mut self,
465 endpoint: SuotarEndpoint,
466 stage: Stage,
467 item: &ItemAddress,
468 ) -> anyhow::Result<Option<Effect>> {
469 for fault in self.faults {
470 if fault.then.is_request_shaped() {
471 continue;
472 }
473 match matches_item(fault, endpoint, stage, item) {
474 FaultMatch::Missed(predicate) => {
475 self.record_miss(fault, endpoint, stage, predicate)
476 }
477 FaultMatch::Fires => {
478 if self.draw(fault).await? {
479 self.log.applied.push(fault.id.clone());
480 return Ok(Some(fault.then.clone()));
481 }
482 }
483 }
484 }
485 Ok(None)
486 }
487
488 fn record_miss(
491 &mut self,
492 fault: &Fault,
493 endpoint: SuotarEndpoint,
494 stage: Stage,
495 predicate: &str,
496 ) {
497 if fault.endpoint() != Some(endpoint) || fault.stage() != Some(stage) {
498 return;
499 }
500 let miss = MissedFault {
501 fault_id: fault.id.clone(),
502 predicate: predicate.to_string(),
503 };
504 if !self.log.missed.contains(&miss) {
505 self.log.missed.push(miss);
506 }
507 }
508
509 async fn draw(&mut self, fault: &Fault) -> anyhow::Result<bool> {
512 let Some(budget) = fault.lifetime.budget() else {
513 return Ok(true);
514 };
515 if budget > 0
516 && self
517 .preamble
518 .remaining
519 .get(&fault.id)
520 .is_some_and(|left| *left <= 0)
521 {
522 return Ok(false);
523 }
524 let left = self.store.draw(self.generation, &fault.id, -1).await?;
525 if left < 0 {
526 self.store.draw(self.generation, &fault.id, 1).await?;
527 self.log.missed.push(MissedFault {
528 fault_id: fault.id.clone(),
529 predicate: "lifetime".to_string(),
530 });
531 return Ok(false);
532 }
533 Ok(true)
534 }
535}
536
537fn item_effect_response(
540 endpoint: SuotarEndpoint,
541 address: &ItemAddress,
542 effect: &Effect,
543 resolved: Option<&ErasedResponseItem>,
544) -> ErasedResponseItem {
545 let Effect::ItemLevel {
546 code,
547 message,
548 disclose_submitted_attainment_id,
549 } = effect
550 else {
551 return resolved.cloned().unwrap_or_else(|| {
552 wire::error_item(endpoint, &address.request_item_id, "internalError")
553 });
554 };
555 let mut item = match message {
556 Some(message) => {
557 wire::error_item_with_message(&address.request_item_id, code, message.clone())
558 }
559 None => wire::error_item(endpoint, &address.request_item_id, code),
560 };
561 if *disclose_submitted_attainment_id
562 && let Some(id) = resolved
563 .and_then(|resolved| resolved.result.as_ref())
564 .and_then(|result| result.get("submittedAttainmentId"))
565 .and_then(|value| value.as_str())
566 && let Some(error) = item.error.as_mut()
567 {
568 error.submitted_attainment_id = Some(id.to_string());
569 }
570 item
571}
572
573enum ParsedRequest {
574 ResolvePersons(Vec<wire::ResolvePersonRequestItem>),
575 ResolveEnrolments(Vec<wire::ResolveEnrolmentRequestItem>),
576 Import(Vec<wire::ImportAttainmentRequestItem>),
577 Verify(Vec<wire::VerifyAttainmentRequestItem>),
578 ProductAccessTokens(Vec<wire::ProductAccessTokenRequestItem>),
579 ListByCourse(Vec<wire::ListByCourseRequestItem>),
580}
581
582impl ParsedRequest {
583 fn addresses(&self) -> Vec<ItemAddress> {
584 match self {
585 Self::ResolvePersons(items) => items
586 .iter()
587 .map(|item| ItemAddress {
588 request_item_id: item.request_item_id.clone(),
589 student_number: Some(item.student_number.clone()),
590 ..Default::default()
591 })
592 .collect(),
593 Self::ResolveEnrolments(items) => items
594 .iter()
595 .map(|item| ItemAddress {
596 request_item_id: item.request_item_id.clone(),
597 student_number: Some(item.student_number.clone()),
598 course_code: Some(item.course_code.clone()),
599 ..Default::default()
600 })
601 .collect(),
602 Self::Import(items) => items
603 .iter()
604 .map(|item| ItemAddress {
605 request_item_id: item.request_item_id.clone(),
606 student_number: Some(item.student_number.clone()),
607 course_code: Some(item.course_code.clone()),
608 ..Default::default()
609 })
610 .collect(),
611 Self::Verify(items) => items
612 .iter()
613 .map(|item| ItemAddress {
614 request_item_id: item.request_item_id.clone(),
615 submitted_attainment_id: Some(item.submitted_attainment_id.clone()),
616 ..Default::default()
617 })
618 .collect(),
619 Self::ProductAccessTokens(items) => items
620 .iter()
621 .map(|item| ItemAddress {
622 request_item_id: item.request_item_id.clone(),
623 product_id: Some(item.open_university_product_id.clone()),
624 ..Default::default()
625 })
626 .collect(),
627 Self::ListByCourse(items) => items
628 .iter()
629 .map(|item| ItemAddress {
630 request_item_id: item.request_item_id.clone(),
631 course_code: Some(item.course_code.clone()),
632 ..Default::default()
633 })
634 .collect(),
635 }
636 }
637
638 fn enrich_addresses(&self, addresses: &mut [ItemAddress], working: &WorkingSet) {
641 if !matches!(self, Self::Verify(_)) {
642 return;
643 }
644 for address in addresses.iter_mut() {
645 if let Some(submission) = address
646 .submitted_attainment_id
647 .as_ref()
648 .and_then(|id| working.submissions.get(id))
649 {
650 address.student_number = Some(submission.student_number.clone());
651 address.course_code = Some(submission.course_code.clone());
652 }
653 }
654 }
655
656 fn resolve(
657 &self,
658 index: usize,
659 working: &mut WorkingSet,
660 now: DateTime<Utc>,
661 ) -> ErasedResponseItem {
662 match self {
663 Self::ResolvePersons(items) => {
664 wire::erase(logic::resolve_person_item(&items[index], working))
665 }
666 Self::ResolveEnrolments(items) => {
667 wire::erase(logic::resolve_enrolments_item(&items[index], working, now))
668 }
669 Self::Import(items) => wire::erase(logic::import_item(&items[index], working, now)),
670 Self::Verify(items) => wire::erase(logic::verify_item(&items[index], working, now)),
671 Self::ProductAccessTokens(items) => {
672 wire::erase(logic::product_access_token_item(&items[index], working))
673 }
674 Self::ListByCourse(items) => {
675 wire::erase(logic::list_by_course_item(&items[index], working))
676 }
677 }
678 }
679}
680
681fn unique_field<T>(items: &[T], field: impl Fn(&T) -> &str) -> Vec<String> {
683 items
684 .iter()
685 .map(|item| field(item).to_string())
686 .unique()
687 .collect()
688}
689
690async fn load(
691 store: &MockSuotarStore,
692 generation: &str,
693 parsed: &ParsedRequest,
694 working: &mut WorkingSet,
695) -> anyhow::Result<()> {
696 let defaults = working.defaults.clone();
697 let loaded = match parsed {
698 ParsedRequest::ResolvePersons(items) => {
699 let persons = store
700 .load_persons(generation, &unique_field(items, |i| &i.student_number))
701 .await?;
702 WorkingSet {
703 persons,
704 ..Default::default()
705 }
706 }
707 ParsedRequest::ResolveEnrolments(items) => {
708 store
709 .load_for_person_course(
710 generation,
711 &unique_field(items, |i| &i.student_number),
712 &unique_field(items, |i| &i.course_code),
713 )
714 .await?
715 }
716 ParsedRequest::Import(items) => {
717 store
718 .load_for_person_course(
719 generation,
720 &unique_field(items, |i| &i.student_number),
721 &unique_field(items, |i| &i.course_code),
722 )
723 .await?
724 }
725 ParsedRequest::Verify(items) => {
726 store
727 .load_for_verify(
728 generation,
729 &unique_field(items, |i| &i.submitted_attainment_id),
730 )
731 .await?
732 }
733 ParsedRequest::ProductAccessTokens(items) => {
734 let product_tokens = store
735 .load_product_tokens(
736 generation,
737 &unique_field(items, |i| &i.open_university_product_id),
738 )
739 .await?;
740 WorkingSet {
741 product_tokens,
742 ..Default::default()
743 }
744 }
745 ParsedRequest::ListByCourse(items) => {
746 store
747 .load_for_list_by_course(generation, &unique_field(items, |i| &i.course_code))
748 .await?
749 }
750 };
751 *working = WorkingSet { defaults, ..loaded };
752 Ok(())
753}
754
755type ParseFailure = (String, String);
757
758fn parse(
759 endpoint: SuotarEndpoint,
760 body: &[u8],
761 preamble: &Preamble,
762) -> Result<ParsedRequest, ParseFailure> {
763 let parsed = match endpoint {
764 SuotarEndpoint::ResolvePersons => ParsedRequest::ResolvePersons(parse_items(body)?),
765 SuotarEndpoint::ResolveEnrolments => ParsedRequest::ResolveEnrolments(parse_items(body)?),
766 SuotarEndpoint::ImportAttainments => ParsedRequest::Import(parse_items(body)?),
767 SuotarEndpoint::VerifyAttainments => ParsedRequest::Verify(parse_items(body)?),
768 SuotarEndpoint::ProductAccessTokens => {
769 ParsedRequest::ProductAccessTokens(parse_items(body)?)
770 }
771 SuotarEndpoint::ListByCourse => ParsedRequest::ListByCourse(parse_items(body)?),
772 };
773
774 let ids: Vec<String> = parsed
775 .addresses()
776 .into_iter()
777 .map(|address| address.request_item_id)
778 .collect();
779 let mut seen = BTreeSet::new();
780 for (index, id) in ids.iter().enumerate() {
781 if !seen.insert(id.clone()) {
782 return Err(malformed(format!(
783 "Item {index} repeats requestItemId `{id}`."
784 )));
785 }
786 }
787
788 if let ParsedRequest::Import(items) = &parsed {
789 let grade_code = preamble
793 .defaults
794 .static_grade_error_code
795 .clone()
796 .unwrap_or_else(|| "malformedRequest".to_string());
797 for (index, item) in items.iter().enumerate() {
798 if preamble.defaults.scale(&item.grade_scale_id).is_none() {
799 return Err((
800 grade_code,
801 format!(
802 "Item {index} has an unknown gradeScaleId `{}`.",
803 item.grade_scale_id
804 ),
805 ));
806 }
807 if !preamble.defaults.any_scale_has_grade(&item.grade_id) {
808 return Err((
809 grade_code,
810 format!(
811 "Item {index} has a gradeId `{}` that is in no known grade scale.",
812 item.grade_id
813 ),
814 ));
815 }
816 if item.attainment_language.chars().count() != 2 {
817 return Err(malformed(format!(
818 "Item {index} has an attainmentLanguage that is not a two-letter code."
819 )));
820 }
821 if item.credits < 0.0 || !item.credits.is_finite() {
822 return Err(malformed(format!(
823 "Item {index} has credits that are not a positive number."
824 )));
825 }
826 }
827 }
828
829 if preamble.defaults.realisation_id_required
830 && let ParsedRequest::ListByCourse(items) = &parsed
831 && let Some(index) = items
832 .iter()
833 .position(|item| item.course_unit_realisation_id.is_none())
834 {
835 return Err(malformed(format!(
836 "Item {index} is missing courseUnitRealisationId."
837 )));
838 }
839
840 Ok(parsed)
841}
842
843fn parse_items<T: DeserializeOwned>(body: &[u8]) -> Result<Vec<T>, ParseFailure> {
846 let values: Vec<serde_json::Value> = serde_json::from_slice(body)
847 .map_err(|error| malformed(format!("Request body is not a JSON array: {error}")))?;
848 if values.is_empty() {
849 return Err(malformed("Request body is an empty array.".to_string()));
850 }
851 values
852 .into_iter()
853 .enumerate()
854 .map(|(index, value)| {
855 serde_json::from_value(value)
856 .map_err(|error| malformed(format!("Item {index} is invalid: {error}")))
857 })
858 .collect()
859}
860
861fn malformed(message: String) -> ParseFailure {
862 ("malformedRequest".to_string(), message)
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 fn encoded(value: &str) -> String {
870 base64::engine::general_purpose::STANDARD.encode(value)
871 }
872
873 #[test]
874 fn every_shape_of_the_configured_credential_is_accepted() {
875 let expected = "mock-suotar-token";
876 for header in [
877 format!("Basic {expected}"),
878 format!("Bearer {expected}"),
879 format!("Basic {}", encoded(expected)),
880 format!("Basic {}", encoded(&format!("{expected}:"))),
881 format!("Basic {}", encoded(&format!("suotar:{expected}"))),
882 ] {
883 assert!(
884 credential_accepted(Some(&header), expected),
885 "rejected {header}"
886 );
887 }
888 assert!(!credential_accepted(None, expected));
889 assert!(!credential_accepted(Some("Basic wrong-token"), expected));
890 assert!(!credential_accepted(
891 Some(&format!("Basic {}", encoded("suotar:wrong-token"))),
892 expected
893 ));
894 }
895}