Skip to main content

headless_lms_server/programs/seed/seed_courses/
seed_credit_registration.rs

1//! Fixtures for the credit-registration (Suotar) system tests.
2//!
3//! Student numbers are `90000SSPP`: `SS` the spec index listed below, `PP` the person within that
4//! spec, the leading `9` keeping them clear of real UH numbers. Digits only, 6–12 of them, per
5//! `verified_student_numbers.student_number_format`.
6//!
7//! Account linking and worker ticks are both global — one spec's tick advances every eligible row in
8//! the shared database — so two specs sharing a student number would see each other's registration
9//! attempts. Each spec under `system-tests/src/tests/credit-registration/` owns one `SS`, and the
10//! mock Suotar's persons must reuse the same numbers: 01 `suotar-happy-path`,
11//! 02 `suotar-account-linking`, 03 `suotar-enrolment-problems`, 04 `suotar-import-outcomes`,
12//! 05 `suotar-verify-outcomes`, 06 `suotar-sisu-outage`, 07 `suotar-consent`,
13//! 08 `suotar-teacher-views`, 09 `suotar-admin-dashboard`, 10 `suotar-old-flow-coexistence`,
14//! 11 `suotar-backfill-and-late-consent`, 12 `suotar-grade-improvement`,
15//! 13 `suotar-student-emails`, 14 `suotar-fast-track-linking`, 15 `suotar-in-course-banner`,
16//! 16 `suotar-student-profile`.
17//!
18//! Names and emails are unlikely strings (`Zzyzx …`) because a spec asserts their absence from the
19//! scrubbed Suotar API log.
20//!
21//! No module here has `enable_credit_registration_via_suotar` on, and nothing may turn it on: the
22//! backfill spec flips it from the UI, which is one-way and run-wide.
23
24use anyhow::Result;
25use chrono::{Duration, Utc};
26use headless_lms_models::{
27    PKeyPolicy, course_credit_registration_consents, course_instance_enrollments,
28    course_module_completions::{self, NewCourseModuleCompletionSeed},
29    credit_registration_admin_actions::{
30        self, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
31        NewCreditRegistrationAdminAction,
32    },
33    credit_registrations::{
34        self, CreditRegistrationState, NewCreditRegistration, PayloadSnapshot, Transition,
35    },
36    student_number_verification_tokens::{self, SeedStudentNumberVerificationToken},
37    study_registry_registrars::get_or_create_default_registrar,
38    user_details::{self, EmailVerificationMethod},
39    user_passwords::{hash_password, upsert_user_password},
40    users,
41    verified_student_numbers::{self, NewVerifiedStudentNumber, StudentNumberVerificationMethod},
42};
43use secrecy::SecretString;
44use sqlx::PgConnection;
45use tracing::info;
46use uuid::Uuid;
47
48use crate::programs::seed::builder::{
49    chapter::ChapterBuilder,
50    context::SeedContext,
51    course::{CourseBuilder, CourseInstanceConfig},
52    module::{CompletionBuilder, CompletionRegisteredBuilder, ModuleBuilder},
53    page::PageBuilder,
54};
55use crate::programs::seed::seed_courses::CommonCourseData;
56use crate::programs::seed::seed_helpers::paragraph;
57
58/// The course the Suotar specs live on.
59pub const SUOTAR_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0001_4a5e_9e6e_c0de00000001);
60/// A course left on the legacy open-university pull flow, for the coexistence specs.
61pub const OLD_FLOW_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0002_4a5e_9e6e_c0de00000002);
62/// Owned outright by `suotar-backfill-and-late-consent.spec.ts`, which flips the Suotar flag on.
63pub const BACKFILL_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0003_4a5e_9e6e_c0de00000003);
64
65/// Linking tokens for `suotar-account-linking.spec.ts`, seeded rather than mailed.
66///
67/// Each is a UUID repeated four times because `student_number_verification_token_length` requires at
68/// least 128 characters.
69pub const LINKING_TOKEN_VALID: &str = concat!(
70    "11111111-1111-1111-1111-111111111111",
71    "11111111-1111-1111-1111-111111111111",
72    "11111111-1111-1111-1111-111111111111",
73    "11111111-1111-1111-1111-111111111111",
74);
75pub const LINKING_TOKEN_EXPIRED: &str = concat!(
76    "22222222-2222-2222-2222-222222222222",
77    "22222222-2222-2222-2222-222222222222",
78    "22222222-2222-2222-2222-222222222222",
79    "22222222-2222-2222-2222-222222222222",
80);
81pub const LINKING_TOKEN_ALREADY_USED: &str = concat!(
82    "33333333-3333-3333-3333-333333333333",
83    "33333333-3333-3333-3333-333333333333",
84    "33333333-3333-3333-3333-333333333333",
85    "33333333-3333-3333-3333-333333333333",
86);
87
88/// A seeded student, with the deterministic id a spec navigates by.
89struct SeededStudent {
90    user_id: Uuid,
91    email: String,
92}
93
94pub async fn seed_credit_registration(common_course_data: CommonCourseData) -> Result<Uuid> {
95    let CommonCourseData {
96        db_pool,
97        organization_id: org,
98        teacher_user_id,
99        ..
100    } = common_course_data;
101
102    let mut conn = db_pool.acquire().await?;
103    let cx = SeedContext {
104        teacher: teacher_user_id,
105        org,
106        base_course_ns: SUOTAR_COURSE_ID,
107    };
108
109    info!("inserting credit registration courses");
110
111    let suotar_instance_id = cx.v5(b"instance:suotar");
112    let (suotar_course, suotar_instance, _) = CourseBuilder::new(
113        "Credit registration via Suotar",
114        "credit-registration-via-suotar",
115    )
116    .desc("Fixture course for the credit registration system tests. The Suotar flag is off.")
117    .course_id(SUOTAR_COURSE_ID)
118    .instance(instance_config(suotar_instance_id))
119    .module(
120        ModuleBuilder::new()
121            .order(0)
122            .ects(5.0)
123            .uh_course_code("CRS-101".to_string())
124            .chapter(
125                // The in-course re-enrol banner spec needs a chapter page it can actually read.
126                ChapterBuilder::new(1, "Registering credits")
127                    .opens(Utc::now())
128                    .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page"))
129                    .page(
130                        PageBuilder::new("/chapter-1/page-1", "How registration works").block(
131                            paragraph(
132                                "Completing this module registers credits into Sisu.",
133                                cx.v5(b"page:1:1:block"),
134                            ),
135                        ),
136                    ),
137            ),
138    )
139    .module(
140        ModuleBuilder::new()
141            .order(1)
142            .name("Second module")
143            .ects(3.0)
144            .uh_course_code("CRS-102".to_string()),
145    )
146    .seed(&mut conn, &cx)
147    .await?;
148
149    let old_flow_cx = SeedContext {
150        teacher: teacher_user_id,
151        org,
152        base_course_ns: OLD_FLOW_COURSE_ID,
153    };
154    CourseBuilder::new(
155        "Credit registration old flow",
156        "credit-registration-old-flow",
157    )
158    .desc("Fixture course left on the legacy open university registration flow.")
159    .course_id(OLD_FLOW_COURSE_ID)
160    .instance(instance_config(old_flow_cx.v5(b"instance:old-flow")))
161    .module(
162        ModuleBuilder::new()
163            .order(0)
164            .ects(5.0)
165            .uh_course_code("CRS-OLD-101".to_string())
166            .register_to_open_university(true),
167    )
168    .seed(&mut conn, &old_flow_cx)
169    .await?;
170
171    seed_backfill_course(&mut conn, org, teacher_user_id).await?;
172
173    info!("inserting credit registration students");
174
175    let consented_linked = insert_student(
176        &mut conn,
177        cx.v5(b"user:consented-linked"),
178        "credit-registration-consented-linked@example.com",
179        "Zzyzx",
180        "Happypath",
181    )
182    .await?;
183    let consented_unlinked = insert_student(
184        &mut conn,
185        cx.v5(b"user:consented-unlinked"),
186        "credit-registration-consented-unlinked@example.com",
187        "Zzyzx",
188        "Linkpending",
189    )
190    .await?;
191    let not_consented = insert_student(
192        &mut conn,
193        cx.v5(b"user:not-consented"),
194        "credit-registration-not-consented@example.com",
195        "Zzyzx",
196        "Noconsent",
197    )
198    .await?;
199    let verified_email = insert_student(
200        &mut conn,
201        cx.v5(b"user:verified-email"),
202        "credit-registration-verified-email@example.com",
203        "Zzyzx",
204        "Fasttrack",
205    )
206    .await?;
207    let unverified_twin = insert_student(
208        &mut conn,
209        cx.v5(b"user:unverified-twin"),
210        "credit-registration-unverified-twin@example.com",
211        "Zzyzx",
212        "Nearmiss",
213    )
214    .await?;
215    let superseded_student = insert_student(
216        &mut conn,
217        cx.v5(b"user:superseded-attempts"),
218        "credit-registration-superseded@example.com",
219        "Zzyzx",
220        "Regraded",
221    )
222    .await?;
223
224    for student in [
225        &consented_linked,
226        &consented_unlinked,
227        &not_consented,
228        &verified_email,
229        &unverified_twin,
230        &superseded_student,
231    ] {
232        course_instance_enrollments::insert(
233            &mut conn,
234            student.user_id,
235            suotar_course.id,
236            suotar_instance.id,
237        )
238        .await?;
239    }
240
241    // `not_consented` gets no row at all: a missing row is what makes the course-start dialog
242    // appear, while `consent_given = false` means asked and declined.
243    for student in [&consented_linked, &consented_unlinked, &verified_email] {
244        course_credit_registration_consents::upsert(
245            &mut conn,
246            student.user_id,
247            suotar_course.id,
248            true,
249        )
250        .await?;
251    }
252    course_credit_registration_consents::upsert(
253        &mut conn,
254        superseded_student.user_id,
255        suotar_course.id,
256        true,
257    )
258    .await?;
259
260    verified_student_numbers::insert(
261        &mut conn,
262        PKeyPolicy::Fixed(cx.v5(b"verified-student-number:consented-linked")),
263        &NewVerifiedStudentNumber {
264            user_id: consented_linked.user_id,
265            student_number: "900000101".to_string(),
266            sisu_person_id: "hy-hlo-900000101".to_string(),
267            first_names: Some("Zzyzx".to_string()),
268            last_name: Some("Happypath".to_string()),
269            verified_via: StudentNumberVerificationMethod::EmailedLink,
270            verified_via_email: Some("zzyzx.happypath@helsinki.example".to_string()),
271            verified_via_email_match_field: None,
272            account_email_verified_at: None,
273            linked_by_user_id: None,
274            link_reason: None,
275            verified_from_course_id: Some(suotar_course.id),
276        },
277    )
278    .await?;
279    verified_student_numbers::insert(
280        &mut conn,
281        PKeyPolicy::Fixed(cx.v5(b"verified-student-number:superseded")),
282        &NewVerifiedStudentNumber {
283            user_id: superseded_student.user_id,
284            student_number: "900000901".to_string(),
285            sisu_person_id: "hy-hlo-900000901".to_string(),
286            first_names: Some("Zzyzx".to_string()),
287            last_name: Some("Regraded".to_string()),
288            verified_via: StudentNumberVerificationMethod::EmailedLink,
289            verified_via_email: Some("zzyzx.regraded@helsinki.example".to_string()),
290            verified_via_email_match_field: None,
291            account_email_verified_at: None,
292            linked_by_user_id: None,
293            link_reason: None,
294            verified_from_course_id: Some(suotar_course.id),
295        },
296    )
297    .await?;
298
299    // `verified_email` and `unverified_twin` differ only in this flag, and the mock Suotar person
300    // for each must hold that account's own address as its primary email for the match to fire.
301    // Without a verified address, an email match is an impersonation primitive.
302    user_details::set_email_verified(
303        &mut conn,
304        verified_email.user_id,
305        EmailVerificationMethod::EmailedCode,
306        Utc::now() - Duration::days(30),
307    )
308    .await?;
309
310    info!("inserting credit registration linking tokens");
311    seed_linking_tokens(&mut conn, &cx, suotar_course.id, unverified_twin.user_id).await?;
312
313    info!("inserting credit registration ledger history");
314    seed_superseded_attempt_pair(
315        &mut conn,
316        &cx,
317        &superseded_student,
318        suotar_course.id,
319        suotar_instance.id,
320    )
321    .await?;
322
323    info!("inserting credit registration admin actions");
324    seed_admin_actions(&mut conn, &cx, suotar_course.id, teacher_user_id).await?;
325
326    Ok(SUOTAR_COURSE_ID)
327}
328
329fn instance_config(instance_id: Uuid) -> CourseInstanceConfig {
330    CourseInstanceConfig {
331        name: None,
332        description: None,
333        support_email: None,
334        teacher_in_charge_name: "admin".to_string(),
335        teacher_in_charge_email: "admin@example.com".to_string(),
336        opening_time: None,
337        closing_time: None,
338        instance_id: Some(instance_id),
339    }
340}
341
342/// Four passed completions, one already registered by the legacy pull flow so the backfill spec can
343/// assert it is skipped rather than re-pushed.
344async fn seed_backfill_course(
345    conn: &mut PgConnection,
346    org: Uuid,
347    teacher_user_id: Uuid,
348) -> Result<()> {
349    let cx = SeedContext {
350        teacher: teacher_user_id,
351        org,
352        base_course_ns: BACKFILL_COURSE_ID,
353    };
354    let registrar_id = get_or_create_default_registrar(conn).await?;
355
356    let mut module = ModuleBuilder::new()
357        .order(0)
358        .ects(5.0)
359        .uh_course_code("CRS-BACKFILL-101".to_string())
360        .default_registrar(registrar_id);
361
362    for index in 1..=4 {
363        let student = insert_student(
364            conn,
365            cx.v5(format!("user:backfill:{index}").as_bytes()),
366            &format!("credit-registration-backfill-{index}@example.com"),
367            "Zzyzx",
368            &format!("Backfill{index}"),
369        )
370        .await?;
371        let mut completion = CompletionBuilder::new(student.user_id)
372            .email(student.email.clone())
373            .grade(3)
374            .passed(true);
375        if index == 1 {
376            completion = completion.registered(
377                CompletionRegisteredBuilder::new().real_student_number(format!("90000110{index}")),
378            );
379        }
380        module = module.completion(completion);
381    }
382
383    let (course, instance, _) = CourseBuilder::new(
384        "Credit registration backfill",
385        "credit-registration-backfill",
386    )
387    .desc("Fixture course with pre-existing passed completions, for the backfill-on-opt-in spec.")
388    .course_id(BACKFILL_COURSE_ID)
389    .instance(instance_config(cx.v5(b"instance:backfill")))
390    .module(module)
391    .seed(conn, &cx)
392    .await?;
393
394    for index in 1..=4 {
395        let user_id = cx.v5(format!("user:backfill:{index}").as_bytes());
396        course_instance_enrollments::insert(conn, user_id, course.id, instance.id).await?;
397        course_credit_registration_consents::upsert(conn, user_id, course.id, true).await?;
398    }
399    Ok(())
400}
401
402async fn insert_student(
403    conn: &mut PgConnection,
404    user_id: Uuid,
405    email: &str,
406    first_name: &str,
407    last_name: &str,
408) -> Result<SeededStudent> {
409    let user_id = users::insert(
410        conn,
411        PKeyPolicy::Fixed(user_id),
412        email,
413        Some(first_name),
414        Some(last_name),
415    )
416    .await?;
417    user_details::update_user_country(conn, user_id, "fi").await?;
418    // The local part of the address is the password, so these students can log in through the
419    // stored-password fallback without an entry in `authenticate_test_user`.
420    let password = email
421        .split('@')
422        .next()
423        .expect("split always yields one element");
424    let hash = hash_password(&SecretString::new(password.to_string().into()))
425        .map_err(|e| anyhow::anyhow!("failed to hash a seeded password: {e}"))?;
426    upsert_user_password(conn, user_id, &hash).await?;
427    Ok(SeededStudent {
428        user_id,
429        email: email.to_string(),
430    })
431}
432
433/// `emailed_to` matches no seeded account on purpose: tokens are unbound, and bind to whoever opens
434/// the link while logged in.
435async fn seed_linking_tokens(
436    conn: &mut PgConnection,
437    cx: &SeedContext,
438    course_id: Uuid,
439    claimed_by_user_id: Uuid,
440) -> Result<()> {
441    let now = Utc::now();
442    student_number_verification_tokens::insert_seed_row(
443        conn,
444        PKeyPolicy::Fixed(cx.v5(b"linking-token:valid")),
445        &SeedStudentNumberVerificationToken {
446            token: LINKING_TOKEN_VALID.to_string(),
447            student_number: "900000201".to_string(),
448            sisu_person_id: "hy-hlo-900000201".to_string(),
449            first_names: Some("Zzyzx".to_string()),
450            last_name: Some("Linkvalid".to_string()),
451            emailed_to: "zzyzx.linkvalid@helsinki.example".to_string(),
452            course_id: Some(course_id),
453            expires_at: now + Duration::days(14),
454            used_at: None,
455            claimed_by_user_id: None,
456        },
457    )
458    .await?;
459    student_number_verification_tokens::insert_seed_row(
460        conn,
461        PKeyPolicy::Fixed(cx.v5(b"linking-token:expired")),
462        &SeedStudentNumberVerificationToken {
463            token: LINKING_TOKEN_EXPIRED.to_string(),
464            student_number: "900000202".to_string(),
465            sisu_person_id: "hy-hlo-900000202".to_string(),
466            first_names: Some("Zzyzx".to_string()),
467            last_name: Some("Linkexpired".to_string()),
468            emailed_to: "zzyzx.linkexpired@helsinki.example".to_string(),
469            course_id: Some(course_id),
470            expires_at: now - Duration::days(1),
471            used_at: None,
472            claimed_by_user_id: None,
473        },
474    )
475    .await?;
476    student_number_verification_tokens::insert_seed_row(
477        conn,
478        PKeyPolicy::Fixed(cx.v5(b"linking-token:already-used")),
479        &SeedStudentNumberVerificationToken {
480            token: LINKING_TOKEN_ALREADY_USED.to_string(),
481            student_number: "900000203".to_string(),
482            sisu_person_id: "hy-hlo-900000203".to_string(),
483            first_names: Some("Zzyzx".to_string()),
484            last_name: Some("Linkused".to_string()),
485            emailed_to: "zzyzx.linkused@helsinki.example".to_string(),
486            course_id: Some(course_id),
487            expires_at: now + Duration::days(14),
488            used_at: Some(now - Duration::hours(1)),
489            claimed_by_user_id: Some(claimed_by_user_id),
490        },
491    )
492    .await?;
493    Ok(())
494}
495
496/// A registered grade-3 attempt superseded by a grade-4 one, so the admin-detail and
497/// grade-improvement specs get an attempt chain without driving a regrade first.
498async fn seed_superseded_attempt_pair(
499    conn: &mut PgConnection,
500    cx: &SeedContext,
501    student: &SeededStudent,
502    course_id: Uuid,
503    course_instance_id: Uuid,
504) -> Result<()> {
505    let course_module_id =
506        headless_lms_models::course_modules::get_default_by_course_id(conn, course_id)
507            .await?
508            .id;
509    let completion_id = course_module_completions::insert_seed_row(
510        conn,
511        &NewCourseModuleCompletionSeed {
512            course_id,
513            course_module_id,
514            user_id: student.user_id,
515            completion_date: Some(Utc::now() - Duration::days(20)),
516            completion_language: Some("en-US".to_string()),
517            eligible_for_ects: Some(true),
518            email: Some(student.email.clone()),
519            grade: Some(4),
520            passed: Some(true),
521            prerequisite_modules_completed: Some(true),
522            needs_to_be_reviewed: Some(false),
523        },
524    )
525    .await?;
526
527    let attempt_1 = insert_registered_attempt(
528        conn,
529        cx.v5(b"credit-registration:superseded:attempt-1"),
530        completion_id,
531        student.user_id,
532        course_id,
533        course_module_id,
534        course_instance_id,
535        1,
536        "3",
537    )
538    .await?;
539    // `uq_credit_registrations_completion` allows only one attempt per completion with a NULL
540    // `superseded_by_id`, and the FK cannot point at a row that does not exist yet: park attempt 1
541    // on itself, insert the successor, then repoint.
542    credit_registrations::mark_superseded(conn, attempt_1, attempt_1).await?;
543    let attempt_2 = insert_registered_attempt(
544        conn,
545        cx.v5(b"credit-registration:superseded:attempt-2"),
546        completion_id,
547        student.user_id,
548        course_id,
549        course_module_id,
550        course_instance_id,
551        2,
552        "4",
553    )
554    .await?;
555    credit_registrations::mark_superseded(conn, attempt_1, attempt_2).await?;
556    Ok(())
557}
558
559#[allow(clippy::too_many_arguments)]
560async fn insert_registered_attempt(
561    conn: &mut PgConnection,
562    id: Uuid,
563    course_module_completion_id: Uuid,
564    user_id: Uuid,
565    course_id: Uuid,
566    course_module_id: Uuid,
567    course_instance_id: Uuid,
568    attempt_number: i32,
569    grade_id: &str,
570) -> Result<Uuid> {
571    let id = credit_registrations::insert(
572        conn,
573        PKeyPolicy::Fixed(id),
574        &NewCreditRegistration {
575            course_module_completion_id,
576            user_id,
577            course_id,
578            course_module_id,
579            course_instance_id,
580            attempt_number,
581        },
582        Some("Seeded fixture"),
583    )
584    .await?;
585    credit_registrations::set_payload_snapshot(
586        conn,
587        id,
588        &PayloadSnapshot {
589            student_number: "900000901".to_string(),
590            sisu_person_id: "hy-hlo-900000901".to_string(),
591            uh_course_code: "CRS-101".to_string(),
592            selected_enrolment_id: Some("otm-900000901-degree".to_string()),
593            selected_enrolment_kind: Some("degree".to_string()),
594            selected_enrolment_realisation_id: Some("hy-opt-cur-900000901".to_string()),
595            attainment_date: (Utc::now() - Duration::days(20)).date_naive(),
596            attainment_language: "en".to_string(),
597            grade_scale_id: "sis-0-5".to_string(),
598            grade_id: grade_id.to_string(),
599            credits: 5.0,
600        },
601    )
602    .await?;
603    credit_registrations::transition(
604        conn,
605        id,
606        &Transition::to(CreditRegistrationState::Registered),
607    )
608    .await?;
609    Ok(id)
610}
611
612/// One `global_admin` and one `course_teacher` row, so the Audit tab has content without depending
613/// on another spec having clicked something.
614async fn seed_admin_actions(
615    conn: &mut PgConnection,
616    cx: &SeedContext,
617    course_id: Uuid,
618    teacher_user_id: Uuid,
619) -> Result<()> {
620    let admin_user_id = headless_lms_models::users::get_by_email(conn, "admin@example.com")
621        .await?
622        .id;
623    credit_registration_admin_actions::record(
624        conn,
625        &NewCreditRegistrationAdminAction {
626            action: CreditRegistrationAdminAction::TransitionItem,
627            target_kind: CreditRegistrationAdminActionTarget::CreditRegistration,
628            target_id: Some(cx.v5(b"credit-registration:superseded:attempt-1")),
629            target_phase: None,
630            actor_user_id: admin_user_id,
631            actor_role: "global_admin".to_string(),
632            actor_course_id: None,
633            reason: Some("Seeded fixture: checked Sisu by hand and requeued".to_string()),
634            before_state: Some(CreditRegistrationState::SubmissionUncertain),
635            after_state: Some(CreditRegistrationState::Registered),
636            details: None,
637            affected_row_count: Some(1),
638        },
639    )
640    .await?;
641    credit_registration_admin_actions::record(
642        conn,
643        &NewCreditRegistrationAdminAction {
644            action: CreditRegistrationAdminAction::ResendLinkEmail,
645            target_kind: CreditRegistrationAdminActionTarget::StudentNumberVerificationToken,
646            target_id: Some(cx.v5(b"linking-token:valid")),
647            target_phase: None,
648            actor_user_id: teacher_user_id,
649            actor_role: "course_teacher".to_string(),
650            actor_course_id: Some(course_id),
651            reason: Some("Seeded fixture: student reported the mail never arrived".to_string()),
652            before_state: None,
653            after_state: None,
654            details: None,
655            affected_row_count: Some(1),
656        },
657    )
658    .await?;
659    Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    /// `student_number_verification_token_length` requires at least 128 characters; a violation
667    /// would otherwise surface only as a seed crash.
668    #[test]
669    fn seeded_linking_tokens_are_long_enough() {
670        for token in [
671            LINKING_TOKEN_VALID,
672            LINKING_TOKEN_EXPIRED,
673            LINKING_TOKEN_ALREADY_USED,
674        ] {
675            assert!(token.len() >= 128, "token too short: {}", token.len());
676        }
677    }
678}