Skip to main content

sofie_testing/
server.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use http::Method;
5use hyper_util::client::legacy::Client;
6use hyper_util::rt::TokioExecutor;
7use sofie_auth::SessionClaims;
8use sofie_core::{Error, Result, Timestamp, Uuid};
9use sofie_http::{App, CookieNames, SigningKeys};
10use tokio::net::TcpListener;
11use tokio::sync::oneshot;
12use tokio::task::JoinHandle;
13
14use crate::request::{HttpClient, TestRequest, TestSession};
15
16const STOP_TIMEOUT: Duration = Duration::from_secs(5);
17
18pub struct TestServer {
19    base: String,
20    keys: Arc<SigningKeys>,
21    cookies: CookieNames,
22    client: HttpClient,
23    stop: Option<oneshot::Sender<()>>,
24    served: Option<JoinHandle<Result<()>>>,
25}
26
27impl TestServer {
28    pub async fn start(app: App) -> Self {
29        let mut cookies = None;
30        let mut key = [0; 32];
31        sofie_core::random::fill(&mut key).expect("generating a session key");
32        let keys = || SigningKeys::new(&[key]).expect("a 32-byte session key");
33        let listener = TcpListener::bind("127.0.0.1:0")
34            .await
35            .expect("binding a local port");
36        let address = listener.local_addr().expect("reading the bound address");
37        let (stop, stopped) = oneshot::channel::<()>();
38        let app = app.serving(|serving| {
39            cookies = Some(CookieNames::of_serving(&serving));
40            serving.signing_keys(keys())
41        });
42        let cookies = cookies
43            .expect("`App::serving` configures at once")
44            .expect("an app name that names a cookie");
45        let served = tokio::spawn(app.serve(listener, async {
46            let _ = stopped.await;
47        }));
48        let served = started(served).await;
49        Self {
50            base: format!("http://{address}"),
51            keys: Arc::new(keys()),
52            cookies,
53            client: Client::builder(TokioExecutor::new()).build_http(),
54            stop: Some(stop),
55            served: Some(served),
56        }
57    }
58
59    #[must_use]
60    pub fn signing_keys(&self) -> Arc<SigningKeys> {
61        Arc::clone(&self.keys)
62    }
63
64    #[must_use]
65    pub fn origin(&self) -> &str {
66        &self.base
67    }
68
69    pub fn get(&self, path: &str) -> TestRequest {
70        self.request(Method::GET, path)
71    }
72
73    pub fn post(&self, path: &str) -> TestRequest {
74        self.request(Method::POST, path)
75    }
76
77    pub fn request(&self, method: Method, path: &str) -> TestRequest {
78        TestRequest::new(
79            self.client.clone(),
80            method,
81            format!("{}{path}", self.base),
82            self.cookies.clone(),
83        )
84    }
85
86    pub async fn stop(mut self) -> Result<()> {
87        self.signal_stop();
88        let served = self.served.take().expect("a server is stopped once");
89        tokio::time::timeout(STOP_TIMEOUT, served)
90            .await
91            .map_err(|error| Error::internal("waiting for the server to stop", error))?
92            .map_err(|error| Error::internal("joining the server task", error))?
93    }
94
95    pub(crate) fn seal(&self, area: &str, principal: Uuid, now: Timestamp) -> TestSession {
96        let session = Uuid::new_v4().expect("choosing a test session's id");
97        let claims = SessionClaims::new(session, area, principal, now);
98        let sealed = claims.seal(&self.keys).expect("sealing a test session");
99        TestSession::new(format!("{}={sealed}", self.cookies.session()))
100    }
101
102    pub(crate) fn open(&self, session: &TestSession, now: Timestamp) -> Option<SessionClaims> {
103        let sealed = session
104            .cookie()
105            .strip_prefix(self.cookies.session())?
106            .strip_prefix('=')?;
107        SessionClaims::open(&self.keys, sealed, now).ok()
108    }
109
110    fn signal_stop(&mut self) {
111        if let Some(stop) = self.stop.take() {
112            let _ = stop.send(());
113        }
114    }
115}
116
117/// The app's checks run before it first waits on the listener, so one yield lets a failed boot
118/// end the task, and the test fails with its reason rather than a refused connection later.
119async fn started(mut served: JoinHandle<Result<()>>) -> JoinHandle<Result<()>> {
120    tokio::task::yield_now().await;
121    if !served.is_finished() {
122        return served;
123    }
124    let reason = match (&mut served).await {
125        Ok(Ok(())) => "it stopped at once".to_owned(),
126        Ok(Err(error)) => error.to_string(),
127        Err(error) => error.to_string(),
128    };
129    panic!("the test app did not start: {reason}");
130}
131
132impl Drop for TestServer {
133    fn drop(&mut self) {
134        self.signal_stop();
135    }
136}