Skip to main content

headless_lms_models/
credit_registration_events.rs

1//! Append-only audit trail for the credit registration ledger.
2//!
3//! No retention sweep touches this table, so every Suotar payload must go through
4//! [`scrub_suotar_body`] at the write site — redacting on read would leave the raw values on disk.
5use std::sync::LazyLock;
6
7use regex::{Captures, Regex};
8use serde_json::{Value, json};
9use utoipa::ToSchema;
10
11use crate::credit_registrations::{CreditRegistrationErrorCode, CreditRegistrationState};
12use crate::prelude::*;
13use crate::suotar_api_calls::SuotarEndpoint;
14
15/// Replaces a redacted value; the key is kept so the payload shape survives.
16pub const REDACTED: &str = "[redacted]";
17
18/// Keys whose values identify a person or authenticate a request. Matched case-insensitively at any
19/// depth.
20const REDACTED_KEYS: &[&str] = &[
21    "studentnumber",
22    "firstnames",
23    "lastname",
24    "fullname",
25    "primaryemail",
26    "secondaryemail",
27    "email",
28    "emailedto",
29    "accesstoken",
30    "personid",
31    "sisupersonid",
32];
33
34/// Keys whose values the value scan must leave alone: they carry ids shaped like student numbers —
35/// `cr-{uuid}` request item ids, Sisu ids such as `hy-CUR-135176012` — that the scan would mangle.
36///
37/// Container keys do not belong here: an exemption stops at the objects below it.
38const NEVER_SCANNED_KEYS: &[&str] = &[
39    "requestitemid",
40    "code",
41    "coursecode",
42    "gradescaleid",
43    "gradeid",
44    "credits",
45    "attainmentdate",
46    "attainmentlanguage",
47    "submittedattainmentid",
48    "attainmentid",
49    "sisuattainmentid",
50    "courseunitrealisationid",
51    "openuniversityproductid",
52];
53
54static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
55    Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}").expect("hardcoded regex")
56});
57
58/// Student-number-shaped digit runs. The id shapes come first because the `regex` crate has no
59/// lookaround: leftmost-first alternation is the only way to say "digits not part of an id". The
60/// word boundaries keep the digit branch off longer numbers such as millisecond timestamps.
61///
62/// Known cost of the `prefixed` branch: a student number hyphen-joined to a word,
63/// `person-012345678`, survives. A bare digit run, the shape Suotar's messages use, still goes.
64static STUDENT_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
65    Regex::new(
66        r"(?P<uuid>\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b)|(?P<prefixed>\b[A-Za-z][A-Za-z0-9-]*-[0-9]{6,12}\b)|(?P<digits>\b[0-9]{6,12}\b)",
67    )
68    .expect("hardcoded regex")
69});
70
71/// Best-effort removal of personal data from a Suotar request or response body. Not a guarantee and
72/// not an exhaustive PII filter.
73///
74/// Two mechanisms, because either alone leaks: key matching removes the structured person fields,
75/// and the free-text scan is the backstop for the input Suotar's error messages quote back. A key's
76/// treatment covers its own scalar value and the scalars of an array under it, but objects are
77/// always classified again key by key, so an exemption never spreads over a subtree.
78///
79/// The scan only removes email addresses and student-number-shaped digit runs. A name in free text
80/// is kept: no pattern separates it from error prose, and the study registry holds it anyway.
81pub fn scrub_suotar_body(value: &Value) -> Value {
82    scrub(value)
83}
84
85enum KeyPolicy {
86    /// Replaced by [`REDACTED`], subtree and all.
87    FullyRedact,
88    /// Passed through verbatim, except for objects, which are classified by their own keys.
89    NeverScan,
90    /// The default.
91    ScanFreeText,
92}
93
94fn key_policy(key: &str) -> KeyPolicy {
95    let normalized = normalize_key(key);
96    if REDACTED_KEYS.contains(&normalized.as_str()) {
97        KeyPolicy::FullyRedact
98    } else if NEVER_SCANNED_KEYS.contains(&normalized.as_str()) {
99        KeyPolicy::NeverScan
100    } else {
101        KeyPolicy::ScanFreeText
102    }
103}
104
105fn scrub(value: &Value) -> Value {
106    match value {
107        Value::Object(map) => Value::Object(
108            map.iter()
109                .map(|(key, child)| {
110                    let scrubbed = match key_policy(key) {
111                        KeyPolicy::FullyRedact => json!(REDACTED),
112                        KeyPolicy::NeverScan => keep_scalars(child),
113                        KeyPolicy::ScanFreeText => scrub(child),
114                    };
115                    (key.clone(), scrubbed)
116                })
117                .collect(),
118        ),
119        Value::Array(items) => Value::Array(items.iter().map(scrub).collect()),
120        Value::String(text) => Value::String(scrub_free_text(text)),
121        other => other.clone(),
122    }
123}
124
125/// Keeps bare ids and lists of ids, but hands objects back to [`scrub`] so an exemption cannot
126/// smuggle a nested error message past the value scan.
127fn keep_scalars(value: &Value) -> Value {
128    match value {
129        Value::Object(_) => scrub(value),
130        Value::Array(items) => Value::Array(items.iter().map(keep_scalars).collect()),
131        other => other.clone(),
132    }
133}
134
135fn normalize_key(key: &str) -> String {
136    key.chars()
137        .filter(|c| c.is_ascii_alphanumeric())
138        .flat_map(|c| c.to_lowercase())
139        .collect()
140}
141
142fn scrub_free_text(text: &str) -> String {
143    let without_emails = EMAIL_RE.replace_all(text, REDACTED);
144    STUDENT_NUMBER_RE
145        .replace_all(&without_emails, |captures: &Captures| {
146            // The id branches exist to beat the digit branch to the match; put them back unchanged.
147            if captures.name("digits").is_some() {
148                REDACTED.to_string()
149            } else {
150                captures[0].to_string()
151            }
152        })
153        .into_owned()
154}
155
156/// Scrubs a bare error message with the same rules as a JSON body.
157pub fn scrub_text(text: &str) -> String {
158    scrub_free_text(text)
159}
160
161/// Both sides of a Suotar exchange, scrubbed on construction so there is no way to build an
162/// unscrubbed `details`. The request is kept because the ledger row no longer reflects it after a
163/// retry.
164pub fn suotar_exchange_details(request: Option<&Value>, response: Option<&Value>) -> Value {
165    let mut details = serde_json::Map::new();
166    if let Some(request) = request {
167        details.insert("request".to_string(), scrub_suotar_body(request));
168    }
169    if let Some(response) = response {
170        details.insert("response".to_string(), scrub_suotar_body(response));
171    }
172    Value::Object(details)
173}
174
175#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
176#[sqlx(
177    type_name = "credit_registration_event_kind",
178    rename_all = "snake_case"
179)]
180#[serde(rename_all = "snake_case")]
181pub enum CreditRegistrationEventKind {
182    Created,
183    StateChanged,
184    SuotarResponse,
185    RetryScheduled,
186    AdminAction,
187    StudentAction,
188    Cancelled,
189}
190
191#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
192pub struct CreditRegistrationEvent {
193    pub id: Uuid,
194    pub created_at: DateTime<Utc>,
195    pub updated_at: DateTime<Utc>,
196    pub deleted_at: Option<DateTime<Utc>>,
197    pub credit_registration_id: Uuid,
198    pub kind: CreditRegistrationEventKind,
199    pub from_state: Option<CreditRegistrationState>,
200    pub to_state: Option<CreditRegistrationState>,
201    pub error_code: Option<CreditRegistrationErrorCode>,
202    pub message: Option<String>,
203    pub suotar_api_call_id: Option<Uuid>,
204    pub actor_user_id: Option<Uuid>,
205    pub details: Option<Value>,
206}
207
208#[derive(Debug, Clone, PartialEq)]
209pub struct NewCreditRegistrationEvent {
210    pub credit_registration_id: Uuid,
211    pub kind: CreditRegistrationEventKind,
212    pub from_state: Option<CreditRegistrationState>,
213    pub to_state: Option<CreditRegistrationState>,
214    pub error_code: Option<CreditRegistrationErrorCode>,
215    pub message: Option<String>,
216    pub suotar_api_call_id: Option<Uuid>,
217    pub actor_user_id: Option<Uuid>,
218    /// Build with [`suotar_exchange_details`] so it is scrubbed.
219    pub details: Option<Value>,
220}
221
222impl NewCreditRegistrationEvent {
223    pub fn new(credit_registration_id: Uuid, kind: CreditRegistrationEventKind) -> Self {
224        Self {
225            credit_registration_id,
226            kind,
227            from_state: None,
228            to_state: None,
229            error_code: None,
230            message: None,
231            suotar_api_call_id: None,
232            actor_user_id: None,
233            details: None,
234        }
235    }
236}
237
238/// Callers that also change `state` must go through `credit_registrations::transition` instead,
239/// which writes both in one transaction.
240pub async fn insert(
241    conn: &mut PgConnection,
242    new: &NewCreditRegistrationEvent,
243) -> ModelResult<Uuid> {
244    let res = sqlx::query!(
245        r#"
246INSERT INTO credit_registration_events (
247    credit_registration_id,
248    kind,
249    from_state,
250    to_state,
251    error_code,
252    message,
253    suotar_api_call_id,
254    actor_user_id,
255    details
256  )
257VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
258RETURNING id
259        "#,
260        new.credit_registration_id,
261        new.kind as CreditRegistrationEventKind,
262        new.from_state as Option<CreditRegistrationState>,
263        new.to_state as Option<CreditRegistrationState>,
264        new.error_code as Option<CreditRegistrationErrorCode>,
265        new.message,
266        new.suotar_api_call_id,
267        new.actor_user_id,
268        new.details,
269    )
270    .fetch_one(conn)
271    .await?;
272    Ok(res.id)
273}
274
275/// Inserts the same event on many rows in one round trip.
276/// Appends one event per element, in one statement. The column list is [`insert`]'s, so a batch
277/// writes the same rows a loop would.
278pub async fn insert_batch(
279    conn: &mut PgConnection,
280    events: &[NewCreditRegistrationEvent],
281) -> ModelResult<()> {
282    if events.is_empty() {
283        return Ok(());
284    }
285    let ids: Vec<Uuid> = events.iter().map(|e| e.credit_registration_id).collect();
286    let kinds: Vec<CreditRegistrationEventKind> = events.iter().map(|e| e.kind).collect();
287    let from_states: Vec<Option<CreditRegistrationState>> =
288        events.iter().map(|e| e.from_state).collect();
289    let to_states: Vec<Option<CreditRegistrationState>> =
290        events.iter().map(|e| e.to_state).collect();
291    let error_codes: Vec<Option<CreditRegistrationErrorCode>> =
292        events.iter().map(|e| e.error_code).collect();
293    let messages: Vec<Option<String>> = events.iter().map(|e| e.message.clone()).collect();
294    let call_ids: Vec<Option<Uuid>> = events.iter().map(|e| e.suotar_api_call_id).collect();
295    let actors: Vec<Option<Uuid>> = events.iter().map(|e| e.actor_user_id).collect();
296    let details: Vec<Option<Value>> = events.iter().map(|e| e.details.clone()).collect();
297    sqlx::query!(
298        r#"
299INSERT INTO credit_registration_events (
300    credit_registration_id,
301    kind,
302    from_state,
303    to_state,
304    error_code,
305    message,
306    suotar_api_call_id,
307    actor_user_id,
308    details
309  )
310SELECT *
311FROM UNNEST(
312    $1::uuid [],
313    $2::credit_registration_event_kind [],
314    $3::credit_registration_state [],
315    $4::credit_registration_state [],
316    $5::credit_registration_error_code [],
317    $6::text [],
318    $7::uuid [],
319    $8::uuid [],
320    $9::jsonb []
321  )
322        "#,
323        &ids,
324        &kinds as &[CreditRegistrationEventKind],
325        &from_states as &[Option<CreditRegistrationState>],
326        &to_states as &[Option<CreditRegistrationState>],
327        &error_codes as &[Option<CreditRegistrationErrorCode>],
328        &messages as &[Option<String>],
329        &call_ids as &[Option<Uuid>],
330        &actors as &[Option<Uuid>],
331        &details as &[Option<Value>],
332    )
333    .execute(conn)
334    .await?;
335    Ok(())
336}
337
338pub async fn insert_many(
339    conn: &mut PgConnection,
340    credit_registration_ids: &[Uuid],
341    kind: CreditRegistrationEventKind,
342    actor_user_id: Option<Uuid>,
343    message: Option<&str>,
344) -> ModelResult<()> {
345    if credit_registration_ids.is_empty() {
346        return Ok(());
347    }
348    sqlx::query!(
349        r#"
350INSERT INTO credit_registration_events (credit_registration_id, kind, actor_user_id, message)
351SELECT id, $2, $3, $4
352FROM UNNEST($1::uuid []) AS id
353        "#,
354        credit_registration_ids,
355        kind as CreditRegistrationEventKind,
356        actor_user_id,
357        message,
358    )
359    .execute(conn)
360    .await?;
361    Ok(())
362}
363
364/// The per-item timeline, newest first.
365pub async fn get_by_registration_id(
366    conn: &mut PgConnection,
367    credit_registration_id: Uuid,
368) -> ModelResult<Vec<CreditRegistrationEvent>> {
369    let res = sqlx::query_as!(
370        CreditRegistrationEvent,
371        r#"
372SELECT *
373FROM credit_registration_events
374WHERE credit_registration_id = $1
375  AND deleted_at IS NULL
376ORDER BY created_at DESC
377        "#,
378        credit_registration_id
379    )
380    .fetch_all(conn)
381    .await?;
382    Ok(res)
383}
384
385/// The per-item timeline entries one study registry call produced, oldest first: what the answer
386/// did to each row it covered.
387pub async fn get_by_suotar_api_call_id(
388    conn: &mut PgConnection,
389    suotar_api_call_id: Uuid,
390) -> ModelResult<Vec<CreditRegistrationEvent>> {
391    let res = sqlx::query_as!(
392        CreditRegistrationEvent,
393        r#"
394SELECT *
395FROM credit_registration_events
396WHERE suotar_api_call_id = $1
397  AND deleted_at IS NULL
398ORDER BY created_at
399        "#,
400        suotar_api_call_id
401    )
402    .fetch_all(conn)
403    .await?;
404    Ok(res)
405}
406
407/// The attainment the study registry pointed at when it turned a submission down as no improvement.
408///
409/// Read back off the event the answer was recorded on rather than stored on the ledger row: the row
410/// holds what we sent, and this is what the registry already had.
411#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
412pub struct NotImprovedAttainment {
413    pub grade_id: Option<String>,
414    /// Names the scale `grade_id` is on, without which "1" reads as a one out of five when it means
415    /// a pass.
416    pub grade_scale_id: Option<String>,
417}
418
419/// The registry's verdict for a row it declined as no improvement. `None` for every other row, and
420/// for one whose answer named no attainment.
421pub async fn get_not_improved_attainment(
422    conn: &mut PgConnection,
423    credit_registration_id: Uuid,
424) -> ModelResult<Option<NotImprovedAttainment>> {
425    let found = sqlx::query_scalar!(
426        r#"
427SELECT details #> '{response,result,previousAttainment}' AS "attainment!"
428FROM credit_registration_events
429WHERE credit_registration_id = $1
430  AND to_state = 'not_improved'
431  AND details #> '{response,result,previousAttainment}' IS NOT NULL
432  AND deleted_at IS NULL
433ORDER BY created_at DESC
434LIMIT 1
435        "#,
436        credit_registration_id
437    )
438    .fetch_optional(conn)
439    .await?;
440    Ok(found.map(|attainment| NotImprovedAttainment {
441        grade_id: string_field(&attainment, "gradeId"),
442        grade_scale_id: string_field(&attainment, "gradeScaleId"),
443    }))
444}
445
446fn string_field(value: &Value, key: &str) -> Option<String> {
447    Some(value.get(key)?.as_str()?.to_string())
448}
449
450/// How the study registry answered the items it was asked about in a window.
451#[derive(Debug, Clone, PartialEq, Default)]
452pub struct SuotarItemOutcomeTotals {
453    pub item_count: i64,
454    /// Items blamed on Sisu being down or slow, which is the only proxy we have for its uptime.
455    pub sisu_unavailable_count: i64,
456    pub last_sisu_unavailable_at: Option<DateTime<Utc>>,
457}
458
459/// Per-item outcomes in `[since, now)`, counted over events rather than rows: an item that failed
460/// really failed, whatever its row has since become.
461pub async fn count_item_outcomes_since(
462    conn: &mut PgConnection,
463    since: DateTime<Utc>,
464) -> ModelResult<SuotarItemOutcomeTotals> {
465    let res = sqlx::query_as!(
466        SuotarItemOutcomeTotals,
467        r#"
468SELECT COUNT(*) AS "item_count!",
469  COUNT(*) FILTER (
470    WHERE error_code IN ('sisu_temporarily_unavailable', 'sisu_timeout')
471  ) AS "sisu_unavailable_count!",
472  MAX(created_at) FILTER (
473    WHERE error_code IN ('sisu_temporarily_unavailable', 'sisu_timeout')
474  ) AS "last_sisu_unavailable_at"
475FROM credit_registration_events
476WHERE kind = 'suotar_response'
477  AND created_at >= $1
478  AND deleted_at IS NULL
479        "#,
480        since,
481    )
482    .fetch_one(conn)
483    .await?;
484    Ok(res)
485}
486
487/// One error code's standing over a window and the window before it.
488#[derive(Debug, Clone, PartialEq)]
489pub struct ErrorCodeWindowCounts {
490    pub error_code: CreditRegistrationErrorCode,
491    pub current_count: i64,
492    /// The equally long window immediately before, which is what a spike is measured against.
493    pub previous_count: i64,
494    pub user_count: i64,
495    pub course_count: i64,
496    pub first_seen_at: Option<DateTime<Utc>>,
497    pub last_seen_at: Option<DateTime<Utc>>,
498    /// The endpoints the code arrived on, empty for one recorded without a call of ours.
499    pub endpoints: Vec<SuotarEndpoint>,
500}
501
502/// Error events per code over `[now - 2 * window, now)`, split at `now - window`.
503///
504/// Counts events, so errors on attempts since superseded are included: an `invalid_credits` on
505/// attempt 1 is the configuration bug, whether or not attempt 2 succeeded.
506pub async fn get_error_code_counts_for_window(
507    conn: &mut PgConnection,
508    window_secs: i64,
509) -> ModelResult<Vec<ErrorCodeWindowCounts>> {
510    let res = sqlx::query_as!(
511        ErrorCodeWindowCounts,
512        r#"
513SELECT e.error_code AS "error_code!: CreditRegistrationErrorCode",
514  COUNT(*) FILTER (
515    WHERE e.created_at > now() - MAKE_INTERVAL(secs => $1::double precision)
516  ) AS "current_count!",
517  COUNT(*) FILTER (
518    WHERE e.created_at <= now() - MAKE_INTERVAL(secs => $1::double precision)
519  ) AS "previous_count!",
520  COUNT(DISTINCT cr.user_id) AS "user_count!",
521  COUNT(DISTINCT cr.course_id) AS "course_count!",
522  MIN(e.created_at) AS "first_seen_at",
523  MAX(e.created_at) AS "last_seen_at",
524  COALESCE(
525    ARRAY_AGG(DISTINCT call.endpoint) FILTER (
526      WHERE call.endpoint IS NOT NULL
527    ),
528    '{}'
529  ) AS "endpoints!: Vec<SuotarEndpoint>"
530FROM credit_registration_events e
531  JOIN credit_registrations cr ON cr.id = e.credit_registration_id
532  LEFT JOIN suotar_api_calls call ON call.id = e.suotar_api_call_id
533  AND call.deleted_at IS NULL
534WHERE e.error_code IS NOT NULL
535  AND e.created_at > now() - MAKE_INTERVAL(secs => $1::double precision * 2)
536  AND e.deleted_at IS NULL
537  AND cr.deleted_at IS NULL
538GROUP BY e.error_code
539ORDER BY 2 DESC
540        "#,
541        window_secs as f64,
542    )
543    .fetch_all(conn)
544    .await?;
545    Ok(res)
546}
547
548/// Registrations whose answers named more than one submitted attainment id, which is the shape a
549/// double submission would leave behind.
550///
551/// Per registration, not per completion: a grade improvement is a second registration row and a
552/// second attainment on purpose.
553pub async fn get_ids_with_several_submitted_attainments(
554    conn: &mut PgConnection,
555    limit: i64,
556) -> ModelResult<Vec<Uuid>> {
557    let res = sqlx::query_scalar!(
558        r#"
559SELECT credit_registration_id
560FROM credit_registration_events
561WHERE details #>> '{response,submittedAttainmentId}' IS NOT NULL
562  AND deleted_at IS NULL
563GROUP BY credit_registration_id
564HAVING COUNT(
565    DISTINCT details #>> '{response,submittedAttainmentId}'
566  ) > 1
567LIMIT $1
568        "#,
569        limit,
570    )
571    .fetch_all(conn)
572    .await?;
573    Ok(res)
574}
575
576pub async fn get_recent_by_kind(
577    conn: &mut PgConnection,
578    kind: CreditRegistrationEventKind,
579    limit: i64,
580) -> ModelResult<Vec<CreditRegistrationEvent>> {
581    let res = sqlx::query_as!(
582        CreditRegistrationEvent,
583        r#"
584SELECT *
585FROM credit_registration_events
586WHERE kind = $1
587  AND deleted_at IS NULL
588ORDER BY created_at DESC
589LIMIT $2
590        "#,
591        kind as CreditRegistrationEventKind,
592        limit,
593    )
594    .fetch_all(conn)
595    .await?;
596    Ok(res)
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn redacts_by_key_name_but_keeps_the_key() {
605        let scrubbed = scrub_suotar_body(&json!({
606            "studentNumber": "012345678",
607            "firstNames": "Aada Maria",
608            "lastName": "Virtanen",
609            "primaryEmail": "aada@helsinki.fi",
610            "accessToken": "abc123",
611            "personId": "hy-hlo-1",
612        }));
613        assert_eq!(
614            scrubbed,
615            json!({
616                "studentNumber": REDACTED,
617                "firstNames": REDACTED,
618                "lastName": REDACTED,
619                "primaryEmail": REDACTED,
620                "accessToken": REDACTED,
621                "personId": REDACTED,
622            })
623        );
624    }
625
626    #[test]
627    fn keeps_the_fields_debugging_needs() {
628        // These ids carry student-number-shaped digit runs the value scan must not touch.
629        let body = json!({
630            "requestItemId": "cr-2a4b0d6e-0000-4000-8000-000000000001",
631            "code": "sent",
632            "status": "ok",
633            "courseCode": "AYTKT21018",
634            "enrolmentId": "hy-CUR-135176012",
635            "gradeScaleId": "sis-0-5",
636            "gradeId": "4",
637            "credits": 5.0,
638            "attainmentDate": "2026-07-30",
639            "attainmentLanguage": "fi",
640            "submittedAttainmentId": "hy-att-1",
641        });
642        assert_eq!(scrub_suotar_body(&body), body);
643    }
644
645    #[test]
646    fn redacts_recursively_through_objects_and_arrays() {
647        let scrubbed = scrub_suotar_body(&json!({
648            "items": [
649                { "person": { "studentNumber": "012345678" }, "code": "ok" },
650                { "person": { "studentNumber": "012345679" }, "code": "ok" },
651            ]
652        }));
653        assert_eq!(
654            scrubbed,
655            json!({
656                "items": [
657                    { "person": { "studentNumber": REDACTED }, "code": "ok" },
658                    { "person": { "studentNumber": REDACTED }, "code": "ok" },
659                ]
660            })
661        );
662    }
663
664    #[test]
665    fn matches_keys_case_insensitively_and_across_naming_styles() {
666        let scrubbed = scrub_suotar_body(&json!({
667            "STUDENT_NUMBER": "012345678",
668            "Student-Number": "012345678",
669            "emailedTo": "aada@helsinki.fi",
670        }));
671        assert_eq!(
672            scrubbed,
673            json!({
674                "STUDENT_NUMBER": REDACTED,
675                "Student-Number": REDACTED,
676                "emailedTo": REDACTED,
677            })
678        );
679    }
680
681    #[test]
682    fn value_scan_catches_identifiers_quoted_in_free_text() {
683        // Suotar error messages quote the input, so key-based redaction alone would leak here.
684        let scrubbed = scrub_suotar_body(&json!({
685            "message": "Person 012345678 (aada@helsinki.fi) has no accepted enrolment",
686        }));
687        assert_eq!(
688            scrubbed,
689            json!({
690                "message": format!("Person {REDACTED} ({REDACTED}) has no accepted enrolment"),
691            })
692        );
693    }
694
695    #[test]
696    fn a_never_scanned_key_covers_the_ids_in_a_list_under_it() {
697        let body = json!({ "courseUnitRealisationId": ["hy-CUR-135176012", "hy-CUR-135176013"] });
698        assert_eq!(scrub_suotar_body(&body), body);
699
700        // An object in that list is classified by its own keys, so the exemption stops there.
701        let mixed = json!({
702            "code": ["hy-CUR-135176012", { "message": "Person 012345678 not found" }],
703        });
704        assert_eq!(
705            scrub_suotar_body(&mixed),
706            json!({
707                "code": [
708                    "hy-CUR-135176012",
709                    { "message": format!("Person {REDACTED} not found") },
710                ],
711            })
712        );
713    }
714
715    #[test]
716    fn a_redacted_key_takes_its_whole_value_with_it() {
717        // Over-redacting a person field costs debuggability; under-redacting is a permanent leak.
718        let scrubbed = scrub_suotar_body(&json!({
719            "firstNames": ["Aada", "Maria"],
720            "personId": { "value": "hy-hlo-1" },
721        }));
722        assert_eq!(
723            scrubbed,
724            json!({ "firstNames": REDACTED, "personId": REDACTED })
725        );
726    }
727
728    #[test]
729    fn value_scan_reaches_inside_a_never_scanned_key() {
730        // Suotar hangs per-item errors off an exempt key; the exemption covers only its own scalar.
731        let scrubbed = scrub_suotar_body(&json!({
732            "items": [{
733                "status": {
734                    "code": "personNotFound",
735                    "message": "Person 012345678 (aada@helsinki.fi) not found",
736                },
737            }],
738        }));
739        assert_eq!(
740            scrubbed,
741            json!({
742                "items": [{
743                    "status": {
744                        "code": "personNotFound",
745                        "message": format!("Person {REDACTED} ({REDACTED}) not found"),
746                    },
747                }],
748            })
749        );
750    }
751
752    #[test]
753    fn value_scan_keeps_request_item_ids_quoted_in_free_text_whole() {
754        // The last UUID group is 12 digits, so a naive digit-run scan would eat it.
755        let body = json!({ "message": "item cr-2a4b0d6e-0000-4000-8000-000000000001 rejected" });
756        assert_eq!(scrub_suotar_body(&body), body);
757
758        // An all-digit first group must not tip the alternation into the digit branch.
759        let numeric = json!({ "message": "item 12345678-1234-4321-8765-123456789012 rejected" });
760        assert_eq!(scrub_suotar_body(&numeric), numeric);
761
762        // A Sisu id is kept whole for the same reason, even though its tail is digits only.
763        let sisu = json!({ "message": "enrolment hy-CUR-135176012 rejected" });
764        assert_eq!(scrub_suotar_body(&sisu), sisu);
765
766        // Accepted cost: a student number hyphen-joined to a word reads as a prefixed id and
767        // survives. A bare run still goes, which is the shape Suotar sends.
768        let adjacent = json!({ "message": "person-012345678 and 012345678 not found" });
769        assert_eq!(
770            scrub_suotar_body(&adjacent),
771            json!({ "message": format!("person-012345678 and {REDACTED} not found") })
772        );
773    }
774
775    #[test]
776    fn free_text_names_are_deliberately_kept() {
777        // No pattern separates a name from error prose; only the known person keys are redacted.
778        let scrubbed = scrub_suotar_body(&json!({
779            "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
780            "fullName": "Aada Maria Virtanen",
781        }));
782        assert_eq!(
783            scrubbed,
784            json!({
785                "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
786                "fullName": REDACTED,
787            })
788        );
789    }
790
791    #[test]
792    fn value_scan_leaves_short_and_long_digit_runs_alone() {
793        let body = json!({ "message": "code 404 after 1234567890123 ms" });
794        assert_eq!(scrub_suotar_body(&body), body);
795    }
796
797    #[test]
798    fn scrubbing_is_idempotent() {
799        let body = json!({
800            "studentNumber": "012345678",
801            "message": "Person 012345678 not found",
802        });
803        let once = scrub_suotar_body(&body);
804        assert_eq!(scrub_suotar_body(&once), once);
805    }
806
807    #[test]
808    fn exchange_details_scrub_both_sides_and_omit_missing_ones() {
809        let details = suotar_exchange_details(
810            Some(&json!({ "studentNumber": "012345678" })),
811            Some(&json!({ "code": "sent", "fullName": "Aada Virtanen" })),
812        );
813        assert_eq!(
814            details,
815            json!({
816                "request": { "studentNumber": REDACTED },
817                "response": { "code": "sent", "fullName": REDACTED },
818            })
819        );
820
821        let request_only = suotar_exchange_details(Some(&json!({ "code": "x" })), None);
822        assert_eq!(request_only, json!({ "request": { "code": "x" } }));
823    }
824}