Skip to main content

headless_lms_server/controllers/mock_suotar/
api.rs

1//! The moocfi endpoints, one stage boundary at a time.
2//!
3//! Order per request, as Suotar's middleware runs it: the JSON body parse (size, then syntax), the
4//! credential, the envelope and item validation, the item-keyed load, the `auth`/`requestGate`/
5//! `parse` faults, `resolve` request-level then per item, then `afterWrite` and `respond`. The
6//! write-back commits before the response — or its deliberate absence — leaves the process, which is
7//! what makes a timeout that landed distinguishable from one that did not.
8
9use std::collections::{BTreeSet, HashMap};
10
11use futures::StreamExt;
12use itertools::Itertools;
13use serde::de::DeserializeOwned;
14use sqlx::PgPool;
15
16use crate::prelude::*;
17
18use crate::mock_suotar::default_world;
19use crate::mock_suotar::faults::{
20    Effect, Fault, FaultMatch, ItemAddress, Stage, matches_item, matches_request,
21};
22use crate::mock_suotar::logic::{self, ImportResolution};
23use crate::mock_suotar::store::{MockSuotarStore, Preamble};
24use crate::mock_suotar::wire::{
25    self, Endpoint, ItemStatus, NOT_AN_ARRAY, RequestLevelError, ResponseItem, SubmittedAttainment,
26};
27use crate::mock_suotar::world::{
28    MissedFault, MockSubmission, RecordedCall, RecordedFaults, RecordedItem, SendState, WorkingSet,
29    WorldWrite,
30};
31
32const RAW_BODY_LIMIT: usize = 8 * 1024;
33
34/// Express's `5mb`.
35const MAX_BODY_BYTES: usize = 5 * 1024 * 1024;
36
37const IMPORT_STRING_FIELDS: [&str; 7] = [
38    "studentNumber",
39    "courseCode",
40    "enrolmentId",
41    "attainmentDate",
42    "attainmentLanguage",
43    "gradeScaleId",
44    "gradeId",
45];
46
47/// Actix binds one handler per route, so the endpoint each route serves is all these differ by.
48macro_rules! endpoint_handlers {
49    ($($handler:ident => $endpoint:ident,)*) => {
50        $(
51            pub async fn $handler(
52                app_conf: web::Data<ApplicationConfiguration>,
53                store: web::Data<MockSuotarStore>,
54                pool: web::Data<PgPool>,
55                req: HttpRequest,
56                payload: web::Payload,
57            ) -> ControllerResult<HttpResponse> {
58                endpoint(Endpoint::$endpoint, app_conf, store, pool, req, payload).await
59            }
60        )*
61    };
62}
63
64endpoint_handlers! {
65    resolve_persons => ResolvePersons,
66    resolve_enrolments => ResolveEnrolments,
67    list_by_course => ListByCourse,
68    import_attainments => ImportAttainments,
69    verify_attainments => VerifyAttainments,
70    validate_course_codes => ValidateCourseCodes,
71}
72
73async fn endpoint(
74    endpoint: Endpoint,
75    app_conf: web::Data<ApplicationConfiguration>,
76    store: web::Data<MockSuotarStore>,
77    pool: web::Data<PgPool>,
78    req: HttpRequest,
79    payload: web::Payload,
80) -> ControllerResult<HttpResponse> {
81    super::assert_enabled(&app_conf);
82    let token = skip_authorize();
83    let body = read_body(payload).await;
84    let delivery = match run(endpoint, &store, &pool, &req, &body).await {
85        Ok(delivery) => delivery,
86        Err(error) => {
87            // A store failure is loud: silent degradation is wrong for something tests assert against.
88            error!("mock Suotar failed to serve a request: {error:?}");
89            Delivery::json(500, &RequestLevelError::new("internalError"))
90        }
91    };
92    token.authorized_ok(deliver(delivery))
93}
94
95/// Anything under the prefix that no route serves, including a wrong method. A valid key falls
96/// through to Suotar's internal routes, whose refusal is a bare string rather than the envelope.
97pub async fn fall_through(
98    app_conf: web::Data<ApplicationConfiguration>,
99    store: web::Data<MockSuotarStore>,
100    pool: web::Data<PgPool>,
101    req: HttpRequest,
102    payload: web::Payload,
103) -> ControllerResult<HttpResponse> {
104    super::assert_enabled(&app_conf);
105    let token = skip_authorize();
106    let body = read_body(payload).await;
107    let delivery = match resolve_world(&store, &pool).await {
108        Ok((_, preamble)) => {
109            match pre_route_rejection(&parse_body(&req, &body), authorized(&req, &preamble)) {
110                Some((status, error)) => Delivery::json(status, &error),
111                None => Delivery::json(401, &serde_json::json!({ "error": "Unauthorized access" })),
112            }
113        }
114        Err(error) => {
115            error!("mock Suotar failed to serve a request: {error:?}");
116            Delivery::json(500, &RequestLevelError::new("internalError"))
117        }
118    };
119    token.authorized_ok(deliver(delivery))
120}
121
122fn deliver(delivery: Delivery) -> HttpResponse {
123    let status_of = |status: u16| {
124        actix_web::http::StatusCode::from_u16(status).unwrap_or(actix_web::http::StatusCode::OK)
125    };
126    match delivery {
127        Delivery::Json { status, body } => HttpResponse::build(status_of(status))
128            .content_type("application/json")
129            .body(body),
130        Delivery::Raw {
131            status,
132            body,
133            content_type,
134        } => HttpResponse::build(status_of(status))
135            .content_type(content_type)
136            .body(body),
137        Delivery::ConnectionReset => {
138            let stream = futures::stream::once(async {
139                Err::<web::Bytes, actix_web::Error>(actix_web::error::ErrorInternalServerError(
140                    "mock Suotar dropped the connection",
141                ))
142            });
143            HttpResponse::Ok().streaming(stream)
144        }
145    }
146}
147
148enum Delivery {
149    Json {
150        status: u16,
151        body: String,
152    },
153    Raw {
154        status: u16,
155        body: String,
156        content_type: String,
157    },
158    ConnectionReset,
159}
160
161impl Delivery {
162    fn json<T: Serialize>(status: u16, value: &T) -> Self {
163        Self::Json {
164            status,
165            body: serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()),
166        }
167    }
168}
169
170enum Body {
171    Read(Vec<u8>),
172    TooLarge,
173    Broken,
174}
175
176/// Stops reading past the limit, so an oversized body costs no more than the limit.
177async fn read_body(mut payload: web::Payload) -> Body {
178    let mut bytes = Vec::new();
179    while let Some(chunk) = payload.next().await {
180        let Ok(chunk) = chunk else {
181            return Body::Broken;
182        };
183        if bytes.len() + chunk.len() > MAX_BODY_BYTES {
184            return Body::TooLarge;
185        }
186        bytes.extend_from_slice(&chunk);
187    }
188    Body::Read(bytes)
189}
190
191/// What Express's JSON parser made of the body before any route ran.
192enum ParsedBody {
193    Json(serde_json::Value),
194    /// Not declared as JSON, so never parsed: the envelope check then finds no array.
195    NotJson,
196    TooLarge,
197    Invalid,
198}
199
200fn parse_body(req: &HttpRequest, body: &Body) -> ParsedBody {
201    let is_json = req
202        .headers()
203        .get(actix_web::http::header::CONTENT_TYPE)
204        .and_then(|value| value.to_str().ok())
205        .and_then(|value| value.split(';').next())
206        .is_some_and(|essence| essence.trim().eq_ignore_ascii_case("application/json"));
207    if !is_json {
208        return ParsedBody::NotJson;
209    }
210    match body {
211        Body::TooLarge => ParsedBody::TooLarge,
212        Body::Broken => ParsedBody::Invalid,
213        Body::Read(bytes) if bytes.is_empty() => ParsedBody::Json(serde_json::json!({})),
214        // Strict mode: only an object or an array is a JSON body.
215        Body::Read(bytes) => match serde_json::from_slice::<serde_json::Value>(bytes) {
216            Ok(value) if value.is_object() || value.is_array() => ParsedBody::Json(value),
217            _ => ParsedBody::Invalid,
218        },
219    }
220}
221
222/// The body parser runs before the credential check, so its failures win over a missing key.
223fn pre_route_rejection(
224    parsed: &ParsedBody,
225    is_authorized: bool,
226) -> Option<(u16, RequestLevelError)> {
227    match parsed {
228        ParsedBody::TooLarge => Some((413, RequestLevelError::new("requestTooLarge"))),
229        ParsedBody::Invalid => Some((
230            400,
231            RequestLevelError::with_message(
232                "malformedRequest",
233                "Request body is not valid JSON.".to_string(),
234            ),
235        )),
236        _ if !is_authorized => Some((401, RequestLevelError::new("unauthorized"))),
237        _ => None,
238    }
239}
240
241/// What a request is answered with, and what its call-log entry records about the answer.
242struct Answer {
243    delivery: Delivery,
244    status: u16,
245    request_level_code: Option<String>,
246    /// The fault effect that shaped the whole answer, if one did.
247    effect: Option<String>,
248}
249
250impl Answer {
251    fn json<T: Serialize>(status: u16, body: &T) -> Self {
252        Self {
253            delivery: Delivery::json(status, body),
254            status,
255            request_level_code: None,
256            effect: None,
257        }
258    }
259
260    fn request_level(status: u16, error: &RequestLevelError) -> Self {
261        Self {
262            request_level_code: Some(error.error.code.clone()),
263            ..Self::json(status, error)
264        }
265    }
266}
267
268/// `None` for an item-shaped effect, which shapes one item rather than the whole answer.
269fn terminal(effect: &Effect) -> Option<Answer> {
270    let answer = match effect {
271        Effect::ConnectionReset => Answer {
272            delivery: Delivery::ConnectionReset,
273            status: 200,
274            request_level_code: None,
275            effect: None,
276        },
277        Effect::RequestLevel {
278            status,
279            code,
280            message,
281        } => Answer::request_level(
282            *status,
283            &match message {
284                Some(message) => RequestLevelError::with_message(code, message.clone()),
285                None => RequestLevelError::new(code),
286            },
287        ),
288        Effect::RawBody {
289            status,
290            body,
291            content_type,
292        } => Answer {
293            delivery: Delivery::Raw {
294                status: *status,
295                body: body.clone(),
296                content_type: content_type
297                    .clone()
298                    .unwrap_or_else(|| "application/json".to_string()),
299            },
300            status: *status,
301            request_level_code: None,
302            effect: None,
303        },
304        Effect::ItemLevel { .. } | Effect::DropItem => return None,
305    };
306    Some(Answer {
307        effect: Some(effect.kind().to_string()),
308        ..answer
309    })
310}
311
312async fn run(
313    endpoint: Endpoint,
314    store: &MockSuotarStore,
315    pool: &PgPool,
316    req: &HttpRequest,
317    body: &Body,
318) -> anyhow::Result<Delivery> {
319    let now = Utc::now();
320    let (generation, preamble) = resolve_world(store, pool).await?;
321    let mut runner = FaultRunner {
322        store,
323        generation: &generation,
324        faults: &preamble.faults,
325        preamble: &preamble,
326        log: RecordedFaults::default(),
327    };
328    let mut call = RecordedCall {
329        seq: store.next_call_seq(&generation).await?,
330        received_at: now,
331        endpoint,
332        correlation_id: req
333            .headers()
334            .get("X-Correlation-Id")
335            .and_then(|value| value.to_str().ok())
336            .map(str::to_string),
337        authorized: authorized(req, &preamble),
338        http_status: 200,
339        request_level_code: None,
340        effect: None,
341        raw_body_truncated: match body {
342            Body::Read(bytes) => truncate(bytes),
343            Body::TooLarge => "(over the size limit)".to_string(),
344            Body::Broken => "(unreadable)".to_string(),
345        },
346        faults: RecordedFaults::default(),
347        items: Vec::new(),
348    };
349    let mut working = WorkingSet {
350        defaults: preamble.defaults.clone(),
351        ..Default::default()
352    };
353
354    let answer = answer(
355        endpoint,
356        req,
357        body,
358        now,
359        &mut runner,
360        &mut call,
361        &mut working,
362    )
363    .await?;
364    call.faults = runner.log;
365    call.effect = answer.effect;
366    call.http_status = answer.status;
367    call.request_level_code = answer.request_level_code;
368    let capacity = working.defaults.call_log_capacity.max(1);
369    store.commit(&generation, &working, &call, capacity).await?;
370    Ok(answer.delivery)
371}
372
373/// Resolves one request against the working set, which the caller then commits along with `call`.
374async fn answer(
375    endpoint: Endpoint,
376    req: &HttpRequest,
377    body: &Body,
378    now: DateTime<Utc>,
379    runner: &mut FaultRunner<'_>,
380    call: &mut RecordedCall,
381    working: &mut WorkingSet,
382) -> anyhow::Result<Answer> {
383    let parsed_body = parse_body(req, body);
384    if let Some((status, error)) = pre_route_rejection(&parsed_body, call.authorized) {
385        return Ok(Answer::request_level(status, &error));
386    }
387    let json = match parsed_body {
388        ParsedBody::Json(value) => Some(value),
389        _ => None,
390    };
391
392    let parsed = match parse_envelope(endpoint, json) {
393        Ok(Some(parsed)) => parsed,
394        Ok(None) => return Ok(Answer::json(200, &Vec::<ResponseItem>::new())),
395        Err(message) => {
396            return Ok(Answer::request_level(
397                400,
398                &RequestLevelError::with_message("malformedRequest", message),
399            ));
400        }
401    };
402
403    let mut addresses = parsed.addresses();
404    load(runner.store, runner.generation, &parsed, working).await?;
405    parsed.enrich_addresses(&mut addresses, working);
406    // Overwritten once the items are answered; a request refused before that is otherwise a call no
407    // student or course code filter finds.
408    call.items = addresses
409        .iter()
410        .map(|address| RecordedItem {
411            request_item_id: address.request_item_id.clone(),
412            student_number: address.student_number.clone(),
413            course_code: address.course_code.clone(),
414            submitted_attainment_id: address.submitted_attainment_id.clone(),
415            status: "unanswered".to_string(),
416            code: String::new(),
417        })
418        .collect();
419
420    // `auth`, `requestGate` and `parse` run before the body in a real Suotar and after it here,
421    // because narrowing a fault to one spec's rows needs the parse. Nothing is written yet at any of
422    // these stages.
423    for stage in [
424        Stage::Auth,
425        Stage::RequestGate,
426        Stage::Parse,
427        Stage::Resolve,
428    ] {
429        if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await?
430            && let Some(terminal) = terminal(&effect)
431        {
432            call.authorized = stage != Stage::Auth;
433            return Ok(terminal);
434        }
435    }
436
437    let (mut items, written) = match &parsed {
438        ParsedRequest::Import(requests) => {
439            match resolve_import_batch(requests, &addresses, now, runner, working).await? {
440                Ok(resolved) => resolved,
441                Err(rejection) => return Ok(rejection),
442            }
443        }
444        ParsedRequest::PerItem(request) => {
445            let mut items = Vec::with_capacity(addresses.len());
446            for (index, address) in addresses.iter().enumerate() {
447                let fault = runner.item_stage(endpoint, Stage::Resolve, address).await?;
448                items.push(match fault {
449                    Some(effect) => item_effect_response(endpoint, address, &effect),
450                    None => request.resolve(index, working, now),
451                });
452            }
453            (items, vec![None; addresses.len()])
454        }
455    };
456
457    // A request-shaped effect here replaces the answer the items formed; the log keeps the items
458    // either way, which is what makes a landed-but-unanswered import visible.
459    let mut answered_by_fault: Option<Answer> = None;
460    let mut dropped = vec![false; addresses.len()];
461    for stage in [Stage::AfterWrite, Stage::Respond] {
462        if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await? {
463            answered_by_fault = terminal(&effect);
464        }
465        for (index, address) in addresses.iter().enumerate() {
466            let Some(effect) = runner.item_stage(endpoint, stage, address).await? else {
467                continue;
468            };
469            if effect == Effect::DropItem {
470                dropped[index] = true;
471                continue;
472            }
473            let Some(item) = items.get_mut(index) else {
474                continue;
475            };
476            let response = item_effect_response(endpoint, address, &effect);
477            *item = match (&effect, &written[index]) {
478                (Effect::ItemLevel { code, .. }, Some(submission_id)) if code == "sisuTimeout" => {
479                    leave_unconfirmed(working, submission_id);
480                    response.with_result(SubmittedAttainment::new(submission_id))
481                }
482                _ => response,
483            };
484        }
485    }
486
487    call.items = addresses
488        .iter()
489        .zip(items.iter())
490        .zip(&dropped)
491        .map(|((address, item), is_dropped)| RecordedItem {
492            request_item_id: address.request_item_id.clone(),
493            student_number: address.student_number.clone(),
494            course_code: address.course_code.clone(),
495            submitted_attainment_id: address.submitted_attainment_id.clone(),
496            status: match (is_dropped, item.status) {
497                (true, _) => "dropped".to_string(),
498                (false, ItemStatus::Ok) => "ok".to_string(),
499                (false, ItemStatus::Error) => "error".to_string(),
500            },
501            code: item.code.clone(),
502        })
503        .collect();
504
505    Ok(answered_by_fault.unwrap_or_else(|| {
506        let answered: Vec<&ResponseItem> = items
507            .iter()
508            .zip(&dropped)
509            .filter(|(_, is_dropped)| !**is_dropped)
510            .map(|(item, _)| item)
511            .collect();
512        Answer::json(200, &answered)
513    }))
514}
515
516/// Resolves an import batch and writes what survived its checks, returning each item's answer and
517/// the submission it wrote. `Err` is the request-level answer that replaces the whole batch.
518async fn resolve_import_batch(
519    requests: &[wire::ImportAttainmentRequestItem],
520    addresses: &[ItemAddress],
521    now: DateTime<Utc>,
522    runner: &mut FaultRunner<'_>,
523    working: &mut WorkingSet,
524) -> anyhow::Result<Result<(Vec<ResponseItem>, Vec<Option<String>>), Answer>> {
525    let endpoint = Endpoint::ImportAttainments;
526    let mut slots: Vec<Option<ResponseItem>> = vec![None; addresses.len()];
527    let mut to_write: Vec<(usize, MockSubmission)> = Vec::new();
528    // Completion key to the submission written for it; a repeat of an item answered outright is
529    // resolved on its own and reaches the same answer.
530    let mut written_in_batch: HashMap<String, String> = HashMap::new();
531    for (index, (address, request)) in addresses.iter().zip(requests).enumerate() {
532        if let Some(effect) = runner.item_stage(endpoint, Stage::Resolve, address).await? {
533            slots[index] = Some(item_effect_response(endpoint, address, &effect));
534            continue;
535        }
536        let completion = completion_key(request);
537        if let Some(first) = written_in_batch.get(&completion) {
538            slots[index] = Some(
539                ResponseItem::error(endpoint, &request.request_item_id, "duplicateRequestItem")
540                    .with_result(SubmittedAttainment::new(first)),
541            );
542            continue;
543        }
544        match logic::resolve_import_item(request, working, now) {
545            ImportResolution::Answered(item) => slots[index] = Some(item),
546            ImportResolution::Write(submission) => {
547                written_in_batch.insert(completion, submission.submitted_attainment_id.clone());
548                to_write.push((index, *submission));
549            }
550        }
551    }
552    let submissions: Vec<MockSubmission> = to_write.iter().map(|(_, s)| s.clone()).collect();
553    if logic::acceptor_lookup_fails(working, &submissions) {
554        return Ok(Err(Answer::request_level(
555            503,
556            &RequestLevelError::new("serviceTemporarilyUnavailable"),
557        )));
558    }
559    let mut written: Vec<Option<String>> = vec![None; addresses.len()];
560    for (index, submission) in to_write {
561        written[index] = Some(submission.submitted_attainment_id.clone());
562        slots[index] = Some(logic::write_and_send(working, submission));
563    }
564    let items = addresses
565        .iter()
566        .zip(slots)
567        .map(|(address, slot)| {
568            slot.unwrap_or_else(|| {
569                ResponseItem::error(endpoint, &address.request_item_id, "internalError")
570            })
571        })
572        .collect();
573    Ok(Ok((items, written)))
574}
575
576/// A send that never answered: the submission exists but Sisu's acceptance was never recorded.
577fn leave_unconfirmed(working: &mut WorkingSet, submission_id: &str) {
578    if let Some(submission) = working.submissions.get_mut(submission_id) {
579        submission.send_state = SendState::Attempted;
580        working
581            .writes
582            .push(WorldWrite::UpsertSubmission(submission_id.to_string()));
583    }
584}
585
586/// Suotar's intra-batch duplicate key. The enrolment and the language are deliberately not in it.
587fn completion_key(item: &wire::ImportAttainmentRequestItem) -> String {
588    format!(
589        "{}|{}|{}|{}|{}|{}",
590        item.student_number,
591        item.course_code,
592        item.grade_scale_id,
593        item.grade_id,
594        item.credits,
595        item.attainment_date()
596    )
597}
598
599async fn resolve_world(
600    store: &MockSuotarStore,
601    pool: &PgPool,
602) -> anyhow::Result<(String, Preamble)> {
603    if let Some(generation) = store.live_generation().await? {
604        let preamble = store.preamble(&generation).await?;
605        if preamble.defaults_present {
606            return Ok((generation, preamble));
607        }
608    }
609    let marker = default_world::db_generation_marker(pool).await;
610    let generation = store
611        .install_if_absent(&default_world::build(), marker.as_deref())
612        .await?;
613    let preamble = store.preamble(&generation).await?;
614    Ok((generation, preamble))
615}
616
617fn authorized(req: &HttpRequest, preamble: &Preamble) -> bool {
618    credential_accepted(
619        req.headers()
620            .get(actix_web::http::header::AUTHORIZATION)
621            .and_then(|value| value.to_str().ok()),
622        &preamble.defaults.accepted_token,
623    )
624}
625
626/// Exactly `Bearer <token>`: case-sensitive, one space, no other scheme.
627fn credential_accepted(header: Option<&str>, expected: &str) -> bool {
628    header
629        .and_then(|header| header.strip_prefix("Bearer "))
630        .is_some_and(|credential| !expected.is_empty() && credential == expected)
631}
632
633fn truncate(body: &[u8]) -> String {
634    let text = String::from_utf8_lossy(body);
635    if text.len() <= RAW_BODY_LIMIT {
636        return text.into_owned();
637    }
638    let mut cut = RAW_BODY_LIMIT;
639    while cut > 0 && !text.is_char_boundary(cut) {
640        cut -= 1;
641    }
642    text[..cut].to_string()
643}
644
645struct FaultRunner<'a> {
646    store: &'a MockSuotarStore,
647    generation: &'a str,
648    faults: &'a [Fault],
649    preamble: &'a Preamble,
650    log: RecordedFaults,
651}
652
653impl FaultRunner<'_> {
654    /// First match in arm order wins; later matches are recorded as shadowed rather than applied.
655    async fn request_stage(
656        &mut self,
657        endpoint: Endpoint,
658        stage: Stage,
659        items: &[ItemAddress],
660    ) -> anyhow::Result<Option<Effect>> {
661        let mut winner = None;
662        for fault in self.faults {
663            if !fault.then.is_request_shaped() {
664                continue;
665            }
666            match matches_request(fault, endpoint, stage, items) {
667                FaultMatch::Missed(predicate) => {
668                    self.record_miss(fault, endpoint, stage, predicate)
669                }
670                FaultMatch::Fires => {
671                    if winner.is_some() {
672                        self.log.shadowed.push(fault.id.clone());
673                        continue;
674                    }
675                    if self.draw(fault).await? {
676                        self.log.applied.push(fault.id.clone());
677                        winner = Some(fault.then.clone());
678                    }
679                }
680            }
681        }
682        Ok(winner)
683    }
684
685    async fn item_stage(
686        &mut self,
687        endpoint: Endpoint,
688        stage: Stage,
689        item: &ItemAddress,
690    ) -> anyhow::Result<Option<Effect>> {
691        for fault in self.faults {
692            if fault.then.is_request_shaped() {
693                continue;
694            }
695            match matches_item(fault, endpoint, stage, item) {
696                FaultMatch::Missed(predicate) => {
697                    self.record_miss(fault, endpoint, stage, predicate)
698                }
699                FaultMatch::Fires => {
700                    if self.draw(fault).await? {
701                        self.log.applied.push(fault.id.clone());
702                        return Ok(Some(fault.then.clone()));
703                    }
704                }
705            }
706        }
707        Ok(None)
708    }
709
710    /// Only a fault that reached this endpoint and stage and then missed on one further predicate is
711    /// worth reporting.
712    fn record_miss(&mut self, fault: &Fault, endpoint: Endpoint, stage: Stage, predicate: &str) {
713        if fault.endpoint() != Some(endpoint) || fault.stage() != Some(stage) {
714            return;
715        }
716        let miss = MissedFault {
717            fault_id: fault.id.clone(),
718            predicate: predicate.to_string(),
719        };
720        if !self.log.missed.contains(&miss) {
721            self.log.missed.push(miss);
722        }
723    }
724
725    /// The decision is the value the draw returns: reading a counter and deciding on the read is how
726    /// two concurrent requests both spend the last of one budget.
727    async fn draw(&mut self, fault: &Fault) -> anyhow::Result<bool> {
728        let Some(budget) = fault.lifetime.budget() else {
729            return Ok(true);
730        };
731        if budget > 0
732            && self
733                .preamble
734                .remaining
735                .get(&fault.id)
736                .is_some_and(|left| *left <= 0)
737        {
738            return Ok(false);
739        }
740        let left = self.store.draw(self.generation, &fault.id, -1).await?;
741        if left < 0 {
742            self.store.draw(self.generation, &fault.id, 1).await?;
743            self.log.missed.push(MissedFault {
744                fault_id: fault.id.clone(),
745                predicate: "lifetime".to_string(),
746            });
747            return Ok(false);
748        }
749        Ok(true)
750    }
751}
752
753fn item_effect_response(
754    endpoint: Endpoint,
755    address: &ItemAddress,
756    effect: &Effect,
757) -> ResponseItem {
758    match effect {
759        Effect::ItemLevel {
760            code,
761            message: Some(message),
762        } => ResponseItem::error_with_message(&address.request_item_id, code, message.clone()),
763        Effect::ItemLevel {
764            code,
765            message: None,
766        } => ResponseItem::error(endpoint, &address.request_item_id, code),
767        _ => ResponseItem::error(endpoint, &address.request_item_id, "internalError"),
768    }
769}
770
771/// Import resolves as a batch; every other endpoint answers item by item.
772enum ParsedRequest {
773    Import(Vec<wire::ImportAttainmentRequestItem>),
774    PerItem(PerItemRequest),
775}
776
777enum PerItemRequest {
778    ResolvePersons(Vec<wire::ResolvePersonRequestItem>),
779    ResolveEnrolments(Vec<wire::ResolveEnrolmentRequestItem>),
780    Verify(Vec<wire::VerifyAttainmentRequestItem>),
781    ListByCourse(Vec<wire::CourseCodeRequestItem>),
782    ValidateCourseCodes(Vec<wire::CourseCodeRequestItem>),
783}
784
785impl ParsedRequest {
786    fn addresses(&self) -> Vec<ItemAddress> {
787        match self {
788            Self::Import(items) => items
789                .iter()
790                .map(|item| ItemAddress {
791                    request_item_id: item.request_item_id.clone(),
792                    student_number: Some(item.student_number.clone()),
793                    course_code: Some(item.course_code.clone()),
794                    ..Default::default()
795                })
796                .collect(),
797            Self::PerItem(request) => request.addresses(),
798        }
799    }
800
801    /// Verify's body carries only a submitted attainment id; the person behind it is what a spec
802    /// addresses a fault with.
803    fn enrich_addresses(&self, addresses: &mut [ItemAddress], working: &WorkingSet) {
804        if let Self::PerItem(PerItemRequest::Verify(_)) = self {
805            enrich_verify_addresses(addresses, working);
806        }
807    }
808}
809
810impl PerItemRequest {
811    fn addresses(&self) -> Vec<ItemAddress> {
812        let course_code_addresses = |items: &[wire::CourseCodeRequestItem]| {
813            items
814                .iter()
815                .map(|item| ItemAddress {
816                    request_item_id: item.request_item_id.clone(),
817                    course_code: Some(item.course_code.clone()),
818                    ..Default::default()
819                })
820                .collect()
821        };
822        match self {
823            Self::ResolvePersons(items) => items
824                .iter()
825                .map(|item| ItemAddress {
826                    request_item_id: item.request_item_id.clone(),
827                    student_number: Some(item.student_number.clone()),
828                    ..Default::default()
829                })
830                .collect(),
831            Self::ResolveEnrolments(items) => items
832                .iter()
833                .map(|item| ItemAddress {
834                    request_item_id: item.request_item_id.clone(),
835                    student_number: Some(item.student_number.clone()),
836                    course_code: Some(item.course_code.clone()),
837                    ..Default::default()
838                })
839                .collect(),
840            Self::Verify(items) => items
841                .iter()
842                .map(|item| ItemAddress {
843                    request_item_id: item.request_item_id.clone(),
844                    submitted_attainment_id: Some(item.submitted_attainment_id.clone()),
845                    ..Default::default()
846                })
847                .collect(),
848            Self::ListByCourse(items) | Self::ValidateCourseCodes(items) => {
849                course_code_addresses(items)
850            }
851        }
852    }
853
854    fn resolve(&self, index: usize, working: &WorkingSet, now: DateTime<Utc>) -> ResponseItem {
855        match self {
856            Self::ResolvePersons(items) => logic::resolve_person_item(&items[index], working),
857            Self::ResolveEnrolments(items) => {
858                logic::resolve_enrolments_item(&items[index], working)
859            }
860            Self::Verify(items) => logic::verify_item(&items[index], working, now),
861            Self::ListByCourse(items) => logic::list_by_course_item(&items[index], working, now),
862            Self::ValidateCourseCodes(items) => {
863                logic::validate_course_code_item(&items[index], working)
864            }
865        }
866    }
867}
868
869fn enrich_verify_addresses(addresses: &mut [ItemAddress], working: &WorkingSet) {
870    for address in addresses.iter_mut() {
871        let Some(id) = address.submitted_attainment_id.as_ref() else {
872            continue;
873        };
874        let owner = working
875            .submissions
876            .get(id)
877            .map(|s| (s.student_number.clone(), s.course_code.clone()))
878            .or_else(|| {
879                working
880                    .attainments
881                    .get(id)
882                    .map(|a| (a.student_number.clone(), a.course_code.clone()))
883            });
884        if let Some((student_number, course_code)) = owner {
885            address.student_number = Some(student_number);
886            address.course_code = Some(course_code);
887        }
888    }
889}
890
891/// Collects the distinct values of one field across a request's items, in first-seen order.
892fn unique_field<T>(items: &[T], field: impl Fn(&T) -> &str) -> Vec<String> {
893    items
894        .iter()
895        .map(|item| field(item).to_string())
896        .unique()
897        .collect()
898}
899
900async fn load(
901    store: &MockSuotarStore,
902    generation: &str,
903    parsed: &ParsedRequest,
904    working: &mut WorkingSet,
905) -> anyhow::Result<()> {
906    let defaults = working.defaults.clone();
907    let loaded = match parsed {
908        ParsedRequest::PerItem(PerItemRequest::ResolvePersons(items)) => WorkingSet {
909            persons: store
910                .load_persons(generation, &unique_field(items, |i| &i.student_number))
911                .await?,
912            ..Default::default()
913        },
914        ParsedRequest::PerItem(PerItemRequest::ResolveEnrolments(items)) => {
915            store
916                .load_for_person_course(
917                    generation,
918                    &unique_field(items, |i| &i.student_number),
919                    &unique_field(items, |i| &i.course_code),
920                )
921                .await?
922        }
923        ParsedRequest::Import(items) => {
924            store
925                .load_for_person_course(
926                    generation,
927                    &unique_field(items, |i| &i.student_number),
928                    &unique_field(items, |i| &i.course_code),
929                )
930                .await?
931        }
932        ParsedRequest::PerItem(PerItemRequest::Verify(items)) => {
933            store
934                .load_for_verify(
935                    generation,
936                    &unique_field(items, |i| &i.submitted_attainment_id),
937                )
938                .await?
939        }
940        ParsedRequest::PerItem(PerItemRequest::ListByCourse(items)) => {
941            store
942                .load_for_list_by_course(generation, &unique_field(items, |i| &i.course_code))
943                .await?
944        }
945        ParsedRequest::PerItem(PerItemRequest::ValidateCourseCodes(items)) => WorkingSet {
946            course_units: store
947                .load_course_units(generation, &unique_field(items, |i| &i.course_code))
948                .await?,
949            ..Default::default()
950        },
951    };
952    *working = WorkingSet { defaults, ..loaded };
953    Ok(())
954}
955
956/// Suotar's envelope checks in its order; `Ok(None)` is the empty batch, answered with `[]`.
957fn parse_envelope(
958    endpoint: Endpoint,
959    body: Option<serde_json::Value>,
960) -> Result<Option<ParsedRequest>, String> {
961    let Some(serde_json::Value::Array(items)) = body else {
962        return Err(NOT_AN_ARRAY.to_string());
963    };
964    if items.is_empty() {
965        return Ok(None);
966    }
967    let max = endpoint.max_batch_size();
968    if items.len() > max {
969        return Err(format!("A batch may contain at most {max} request items."));
970    }
971    let mut ids = Vec::with_capacity(items.len());
972    for (index, item) in items.iter().enumerate() {
973        match non_empty_string(item, "requestItemId") {
974            Some(id) => ids.push(id.to_string()),
975            None => {
976                return Err(format!(
977                    "Request item at index {index} has no string requestItemId."
978                ));
979            }
980        }
981    }
982    if ids.iter().collect::<BTreeSet<_>>().len() != ids.len() {
983        return Err("Every requestItemId in a batch must be unique.".to_string());
984    }
985    for (item, id) in items.iter().zip(&ids) {
986        if let Some(message) = item_problem(endpoint, item) {
987            return Err(format!("Request item {id}: {message}"));
988        }
989    }
990
991    Ok(Some(match endpoint {
992        Endpoint::ImportAttainments => ParsedRequest::Import(typed(items)?),
993        Endpoint::ResolvePersons => {
994            ParsedRequest::PerItem(PerItemRequest::ResolvePersons(typed(items)?))
995        }
996        Endpoint::ResolveEnrolments => {
997            ParsedRequest::PerItem(PerItemRequest::ResolveEnrolments(typed(items)?))
998        }
999        Endpoint::VerifyAttainments => {
1000            ParsedRequest::PerItem(PerItemRequest::Verify(typed(items)?))
1001        }
1002        Endpoint::ListByCourse => {
1003            ParsedRequest::PerItem(PerItemRequest::ListByCourse(typed(items)?))
1004        }
1005        Endpoint::ValidateCourseCodes => {
1006            ParsedRequest::PerItem(PerItemRequest::ValidateCourseCodes(typed(items)?))
1007        }
1008    }))
1009}
1010
1011/// The endpoint's own item check, worded and ordered as Suotar's.
1012fn item_problem(endpoint: Endpoint, item: &serde_json::Value) -> Option<String> {
1013    let first_blank = |fields: &[&str]| {
1014        fields
1015            .iter()
1016            .find(|field| non_empty_string(item, field).is_none())
1017            .map(|field| format!("{field} must be a non-empty string."))
1018    };
1019    match endpoint {
1020        Endpoint::ResolvePersons => first_blank(&["studentNumber"]),
1021        Endpoint::ResolveEnrolments => first_blank(&["studentNumber", "courseCode"]),
1022        Endpoint::VerifyAttainments => first_blank(&["submittedAttainmentId"]),
1023        Endpoint::ValidateCourseCodes => first_blank(&["courseCode"]),
1024        Endpoint::ListByCourse => first_blank(&["courseCode"]).or_else(|| {
1025            item.get("courseUnitRealisationId").is_some().then(|| {
1026                "courseUnitRealisationId is not accepted; every person comes with the realisation they are enrolled on."
1027                    .to_string()
1028            })
1029        }),
1030        Endpoint::ImportAttainments => first_blank(&IMPORT_STRING_FIELDS)
1031            .or_else(|| {
1032                (!item.get("credits").is_some_and(serde_json::Value::is_number))
1033                    .then(|| "credits must be a number.".to_string())
1034            })
1035            .or_else(|| {
1036                (!non_empty_string(item, "attainmentDate").is_some_and(is_strict_date))
1037                    .then(|| "attainmentDate must be a date in YYYY-MM-DD format.".to_string())
1038            }),
1039    }
1040}
1041
1042fn non_empty_string<'a>(item: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1043    item.get(field)
1044        .and_then(serde_json::Value::as_str)
1045        .filter(|value| !value.is_empty())
1046}
1047
1048/// Moment's strict `YYYY-MM-DD`: that exact shape, and a day the calendar has.
1049fn is_strict_date(value: &str) -> bool {
1050    let shape_matches = value.len() == 10
1051        && value.char_indices().all(|(index, c)| match index {
1052            4 | 7 => c == '-',
1053            _ => c.is_ascii_digit(),
1054        });
1055    shape_matches && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
1056}
1057
1058/// Cannot fail on an item that passed `item_problem`; the message is for the day the two drift.
1059fn typed<T: DeserializeOwned>(items: Vec<serde_json::Value>) -> Result<Vec<T>, String> {
1060    items
1061        .into_iter()
1062        .enumerate()
1063        .map(|(index, item)| {
1064            serde_json::from_value(item)
1065                .map_err(|error| format!("Request item at index {index} is unreadable: {error}"))
1066        })
1067        .collect()
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    #[test]
1075    fn only_the_exact_bearer_form_of_the_credential_is_accepted() {
1076        let expected = "mock-suotar-token";
1077        assert!(credential_accepted(
1078            Some(&format!("Bearer {expected}")),
1079            expected
1080        ));
1081        for header in [
1082            format!("Basic {expected}"),
1083            format!("bearer {expected}"),
1084            format!("Bearer  {expected}"),
1085            expected.to_string(),
1086            "Bearer wrong-token".to_string(),
1087        ] {
1088            assert!(
1089                !credential_accepted(Some(&header), expected),
1090                "accepted {header}"
1091            );
1092        }
1093        assert!(!credential_accepted(None, expected));
1094    }
1095}