Skip to main content

headless_lms_server/controllers/mock_suotar/
control.rs

1//! The mock Suotar control surface: test-only routes that are not part of Suotar's API.
2//!
3//! A tick runs one iteration of one phase synchronously: the real loops are long-running
4//! `tokio::time::interval`s in their own Deployments, which a Playwright spec cannot wait out inside
5//! its 100 s timeout.
6//!
7//! Reachable only when `test_mode && test_suotar`, so no token. Not exported to utoipa or
8//! `bindings.ts`; Playwright uses a hand-written client (`system-tests/src/utils/suotarControl.ts`).
9
10use crate::domain::credit_registration_phases::{
11    CreditRegistrationPhase, PhaseTick, run_phase_once,
12};
13use crate::prelude::*;
14use sqlx::PgPool;
15
16#[derive(Debug, Deserialize)]
17pub struct RunTickQuery {
18    /// Optional so a missing phase answers our typed error listing the valid names, not actix's 400.
19    pub phase: Option<String>,
20}
21
22/// The outcome of dispatching one phase.
23#[derive(Debug, Serialize, Deserialize, PartialEq)]
24#[serde(rename_all = "camelCase", tag = "status")]
25pub enum PhaseTickResult {
26    Ran {
27        phase: String,
28        items_processed: i32,
29        items_failed: i32,
30        /// Set when the iteration ran but failed; already scrubbed by the phase.
31        error: Option<String>,
32    },
33    /// No implementation is registered for this phase in `run_phase_once` yet.
34    PhaseNotImplemented { phase: String },
35    /// The `?phase=` value is not one of the twelve canonical names.
36    UnknownPhase {
37        phase: Option<String>,
38        known_phases: Vec<String>,
39    },
40}
41
42impl PhaseTickResult {
43    fn of(phase: CreditRegistrationPhase, tick: PhaseTick) -> Self {
44        match tick {
45            PhaseTick::Ran(outcome) => Self::Ran {
46                phase: phase.as_str().to_string(),
47                items_processed: outcome.items_processed,
48                items_failed: outcome.items_failed,
49                error: outcome.error,
50            },
51            PhaseTick::NotImplemented => Self::PhaseNotImplemented {
52                phase: phase.as_str().to_string(),
53            },
54        }
55    }
56}
57
58/// What `run-registrar-tick` returns: one entry per phase in the sequence, in the order they ran.
59#[derive(Debug, Serialize, Deserialize, PartialEq)]
60#[serde(rename_all = "camelCase")]
61pub struct RegistrarTickResult {
62    pub phases: Vec<PhaseTickResult>,
63}
64
65/**
66POST `/api/v0/mock-suotar/control/run-tick?phase={phase}`
67
68Runs one iteration of one pipeline phase. 200 when the phase ran, 501 `phaseNotImplemented` when no
69implementation is registered for it, 400 `unknownPhase` for a name that is not a phase.
70*/
71async fn run_tick(
72    app_conf: web::Data<ApplicationConfiguration>,
73    pool: web::Data<PgPool>,
74    query: web::Query<RunTickQuery>,
75) -> ControllerResult<HttpResponse> {
76    super::assert_enabled(&app_conf);
77    let token = skip_authorize();
78
79    let Some(phase) = query
80        .phase
81        .as_deref()
82        .and_then(CreditRegistrationPhase::from_phase_name)
83    else {
84        return token.authorized_ok(HttpResponse::BadRequest().json(
85            PhaseTickResult::UnknownPhase {
86                phase: query.phase.clone(),
87                known_phases: known_phase_names(),
88            },
89        ));
90    };
91
92    let result = PhaseTickResult::of(phase, run_phase_once(&pool, phase).await?);
93    token.authorized_ok(match &result {
94        PhaseTickResult::Ran { .. } => HttpResponse::Ok().json(&result),
95        _ => HttpResponse::NotImplemented().json(&result),
96    })
97}
98
99/**
100POST `/api/v0/mock-suotar/control/run-registrar-tick`
101
102The combined form of `run-tick`: materialize, preconditions, resolve-enrolments, import and verify in
103pipeline order, for specs that want a completion walked end to end. Always 200; each phase reports
104its own status in the body.
105*/
106async fn run_registrar_tick(
107    app_conf: web::Data<ApplicationConfiguration>,
108    pool: web::Data<PgPool>,
109) -> ControllerResult<HttpResponse> {
110    super::assert_enabled(&app_conf);
111    let token = skip_authorize();
112
113    let mut phases = Vec::new();
114    for phase in CreditRegistrationPhase::REGISTRAR_TICK_SEQUENCE {
115        phases.push(PhaseTickResult::of(
116            phase,
117            run_phase_once(&pool, phase).await?,
118        ));
119    }
120    token.authorized_ok(HttpResponse::Ok().json(RegistrarTickResult { phases }))
121}
122
123fn known_phase_names() -> Vec<String> {
124    CreditRegistrationPhase::ALL
125        .iter()
126        .map(|phase| phase.as_str().to_string())
127        .collect()
128}
129
130pub fn _add_routes(cfg: &mut ServiceConfig) {
131    cfg.route("/run-tick", web::post().to(run_tick))
132        .route("/run-registrar-tick", web::post().to(run_registrar_tick));
133}
134
135#[cfg(test)]
136mod tests {
137    use actix_web::{App, http::StatusCode, test, web::Data};
138    use headless_lms_base::config::{OAuthServerConfiguration, SuotarConfiguration};
139    use secrecy::{SecretBox, SecretString};
140    use std::sync::Arc;
141
142    use super::*;
143    use crate::controllers::configure_controllers;
144
145    /// The mock config is present either way; `test_suotar` alone decides whether the routes exist.
146    fn app_conf(test_suotar: bool) -> ApplicationConfiguration {
147        ApplicationConfiguration {
148            base_url: "http://project-331.local".to_string(),
149            test_mode: true,
150            test_chatbot: false,
151            test_sisu: false,
152            test_suotar,
153            development_uuid_login: false,
154            enable_admin_email_verification: false,
155            enable_email_ownership_verification: false,
156            azure_configuration: None,
157            suotar_configuration: SuotarConfiguration::mock_conf("http://project-331.local")
158                .expect("the mock configuration is built from a constant base url"),
159            tmc_account_creation_origin: None,
160            tmc_admin_access_token: SecretString::new("mock-access-token".to_string().into()),
161            oauth_server_configuration: OAuthServerConfiguration {
162                rsa_public_key: "test".into(),
163                rsa_private_key: SecretString::new("test".into()),
164                oauth_token_hmac_key: SecretString::new("test".into()),
165                dpop_nonce_key: Arc::new(SecretBox::new(Box::new("test".into()))),
166            },
167        }
168    }
169
170    /// Registers the real controller tree so the test sees the same gate production does. The pool is
171    /// lazy and never connected: an unimplemented phase answers before it would need one.
172    async fn call_run_tick(
173        test_suotar: bool,
174        query: &str,
175    ) -> actix_web::dev::ServiceResponse<actix_web::body::BoxBody> {
176        let app_conf = Data::new(app_conf(test_suotar));
177        let pool = Data::new(
178            PgPool::connect_lazy("postgres://headless-lms@localhost:54328/headless_lms_dev")
179                .expect("a lazy pool only parses the url"),
180        );
181        let service = test::init_service(
182            App::new()
183                .app_data(pool)
184                .app_data(app_conf.clone())
185                .service(
186                    web::scope("/api/v0")
187                        .configure(|cfg| configure_controllers(cfg, app_conf.clone())),
188                ),
189        )
190        .await;
191        let req = test::TestRequest::post()
192            .uri(&format!("/api/v0/mock-suotar/control/run-tick{query}"))
193            .to_request();
194        test::call_service(&service, req).await
195    }
196
197    #[actix_web::test]
198    async fn run_tick_reports_phase_not_implemented_when_the_mock_is_enabled() {
199        let res = call_run_tick(true, "?phase=verify").await;
200        assert_eq!(res.status(), StatusCode::NOT_IMPLEMENTED);
201        let body: PhaseTickResult = test::read_body_json(res).await;
202        assert_eq!(
203            body,
204            PhaseTickResult::PhaseNotImplemented {
205                phase: "verify".to_string()
206            }
207        );
208    }
209
210    /// Without the flag the routes must be absent, not merely refuse.
211    #[actix_web::test]
212    async fn run_tick_is_absent_when_the_mock_is_disabled() {
213        let res = call_run_tick(false, "?phase=verify").await;
214        assert_eq!(res.status(), StatusCode::NOT_FOUND);
215    }
216
217    #[actix_web::test]
218    async fn every_canonical_phase_name_dispatches() {
219        for phase in CreditRegistrationPhase::ALL {
220            let res = call_run_tick(true, &format!("?phase={}", phase.as_str())).await;
221            assert_eq!(
222                res.status(),
223                StatusCode::NOT_IMPLEMENTED,
224                "phase {} did not dispatch",
225                phase.as_str()
226            );
227        }
228    }
229
230    #[actix_web::test]
231    async fn an_invented_phase_name_is_rejected() {
232        let res = call_run_tick(true, "?phase=materialise").await;
233        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
234        let body: PhaseTickResult = test::read_body_json(res).await;
235        assert_eq!(
236            body,
237            PhaseTickResult::UnknownPhase {
238                phase: Some("materialise".to_string()),
239                known_phases: known_phase_names(),
240            }
241        );
242    }
243}