Skip to main content

omry_integration/
helpers.rs

1//! Helper functions and data structures for integration tests.
2use anyhow::{Context, Result};
3use fluent_uri::Uri;
4use omry_archiving::{Document, Marshal, Record, RecordParams, ToRecord};
5use omry_service::iroh::EndpointAddr;
6use std::env;
7use std::fs::{self, File};
8use std::io::prelude::*;
9use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6, TcpListener, ToSocketAddrs};
10use std::path::{Path, PathBuf};
11use tempfile::{TempDir, tempdir};
12use testcontainers::core::Mount;
13use tracing::Level;
14use tracing_subscriber::FmtSubscriber;
15use url::Url;
16use uuid::Uuid;
17
18use crate::db::TempDb;
19
20// Env var key for the Docker host.
21const DOCKER_HOST_KEY: &str = "DOCKER_HOST";
22
23/// Environment variable key that corresponds to the Cargo workspace directory.
24const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
25
26/// The path to the serialized web page used in tests.
27const TEST_ARCHIVE_SUBPATH: &str = "test_data/example.postcard";
28
29/// Maximum number of times we'll try to find a free port to bind to.
30const FIND_PORT_MAX_RETRIES: u8 = 5;
31
32/// Gets a free port for the given socket from the OS. Returns the listener and the port.
33///
34/// The listener is returned to deal with TOCTOU, so that the OS
35/// doesn't give the port to another service before we bind to it for real
36/// wherever we actually need that port in our tests.
37fn free_port_from_socket_addr(
38    socket: impl ToSocketAddrs,
39    max_attempts: u8,
40) -> anyhow::Result<(TcpListener, u16)> {
41    let mut attempts_left = max_attempts;
42    let socket_addrs = socket.to_socket_addrs()?.collect::<Vec<_>>();
43
44    while attempts_left > 0 {
45        if let Ok(listener) = TcpListener::bind(socket_addrs.as_slice()) {
46            let port = listener.local_addr()?.port();
47            return Ok((listener, port));
48        }
49        attempts_left -= 1;
50    }
51
52    anyhow::bail!("Couldn't bind a listener to a port in {max_attempts} attempts.");
53}
54
55/// Gets a free IPv4 port from the OS. Returns the listener and the port.
56///
57/// The listener is returned to account for TOCTOU, so that the OS
58/// doesn't release the port for another service before we bind to it for real
59/// in our tests.
60///
61/// ## Panics
62/// If the port can't be found within some number of retries (currently 3).
63#[must_use]
64pub fn free_port_ipv4() -> (TcpListener, u16) {
65    let socket = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0);
66
67    // Okay to panic if we can't find a free port, since we can't run integration tests
68    // without a port anyway.
69    #[allow(clippy::expect_used)]
70    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
71}
72
73/// Gets a free IPv6 port from the OS. See [`free_port_ipv4`] for details about the return value.
74///
75/// ## Panics
76/// If the port can't be found within some number of retries (currently 3).
77#[must_use]
78pub fn free_port_ipv6() -> (TcpListener, u16) {
79    let socket = SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0);
80
81    // Okay to panic if we can't find a free port, since we can't run integration tests
82    // without a port anyway.
83    #[allow(clippy::expect_used)]
84    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
85}
86
87/// The Docker host (where the Docker socket is running).
88#[derive(Debug, Clone)]
89pub(crate) struct DockerHost {
90    uri: Uri<String>,
91}
92
93impl DockerHost {
94    /// Gets the URI for the `DOCKER_HOST`, if any.
95    ///
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if:
100    /// - `DOCKER_HOST` is not set
101    /// - `DOCKER_HOST` cannot be parsed into a [`Uri`].
102    pub(crate) fn from_env() -> Result<Self> {
103        let uri = env::var(DOCKER_HOST_KEY)
104            .map_err(|e| {
105                anyhow::anyhow!("Failed to get the docker host from the environment: {e}")
106            })?
107            .parse()
108            .map_err(|e| anyhow::anyhow!("Can't parse the docker host: {e}"))?;
109
110        Ok(Self { uri })
111    }
112
113    /// Returns [`true`] if the `DOCKER_HOST` appears to be remote.
114    ///
115    /// The heuristic is (all must be true):
116    ///
117    /// - `DOCKER_HOST` env var is set,
118    /// - `DOCKER_HOST` URI scheme is `tcp`,
119    /// - `DOCKER_HOST` host is pretent
120    pub(crate) fn is_remote(&self) -> bool {
121        self.uri.scheme().as_str() == "tcp" && self.host().is_some()
122    }
123
124    /// Gets the host component of the Docker host (if any).
125    ///
126    /// Will only be present if the Docker host is remote.
127    pub(crate) fn host(&self) -> Option<&str> {
128        Some(self.uri.authority()?.host())
129    }
130}
131
132/// Takes the given subpath, and returns the canonical path within
133/// the current Cargo project.
134///
135/// ## Errors
136/// Returns [`anyhow::Error`] if we fail to:
137///
138/// - Get the path to the current project via `CARGO_WORKSPACE_DIR` env variable
139/// - Get the full canonical path from it
140pub fn path_in_project(subpath: &str) -> Result<PathBuf> {
141    let (_, workspace_dir_path) = env::vars()
142        .find(|(k, _)| k == WORKSPACE_DIR_KEY)
143        .ok_or_else(|| anyhow::anyhow!("{WORKSPACE_DIR_KEY} is not set"))?;
144
145    let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
146    Ok(workspace_dir_path.join(subpath))
147}
148
149/// Loads a document to be used in tests.
150///
151/// ## Errors
152/// Returns [`anyhow::Error`] if we fail to:
153///
154/// - Get the path to the current project via `CARGO_WORKSPACE_DIR` env variable
155/// - Get the full canonical path from it
156/// - Read the file from that path
157/// - Deserialize the data from that file
158pub fn make_test_archive_holder() -> anyhow::Result<Document> {
159    let test_archive_path = path_in_project(TEST_ARCHIVE_SUBPATH)?;
160    let mut file = File::open(test_archive_path)?;
161    let mut buf = vec![];
162    file.read_to_end(&mut buf)?;
163    let archive_holder = Document::from_bytes(&buf)?;
164    Ok(archive_holder)
165}
166
167/// Makes an instance of [`RecordParams`] for tests.
168///
169/// ## Errors
170/// Returns [`anyhow::Error`] if the test document can't be loaded.
171pub fn make_test_record_params() -> anyhow::Result<RecordParams> {
172    let document = make_test_archive_holder()?;
173    let url = "https://example.org/".to_string();
174    let title = "Example".to_string();
175    let client_datetime = "2024-10-11T13:49:46-05:00".to_string();
176    let language = Some("en".to_string());
177
178    Ok(RecordParams {
179        id: None,
180        url,
181        title,
182        language,
183        client_datetime,
184        document,
185        timestamp_flora: None,
186        updated_at: None,
187    })
188}
189
190/// Makes a [`Record`] instance for tests.
191///
192/// ## Errors
193/// Returns [`anyhow::Error`] if the test record can't be loaded.
194pub fn make_test_record() -> anyhow::Result<Record> {
195    let incoming_record = Record::new(make_test_record_params()?);
196    Ok(incoming_record)
197}
198
199/// Gets records that have been inserted into the in-memory SQLite,
200/// so that they have unique ids.
201///
202/// ## Errors
203/// Returns [`anyhow::Error`] if any of the following operations fail:
204///
205/// - We can't create a new [`TempDb`].
206/// - We can't insert the test record into SQLite.
207/// - We can't get that record back from SQLite.
208pub async fn make_test_db_records(how_many: usize) -> anyhow::Result<Vec<impl ToRecord>> {
209    let db = TempDb::new().await?;
210    let mut db_records = Vec::with_capacity(how_many);
211    for _ in 0..how_many {
212        let record = make_test_record()?;
213        let row_id = db.insert(record.try_into()?).await?;
214        let db_record = db.get_record(row_id).await?;
215        db_records.push(db_record);
216    }
217    Ok(db_records)
218}
219
220/// Temporary path and its string representation.
221#[derive(Debug)]
222pub struct TempPathWithString {
223    /// The path.
224    path: PathBuf,
225
226    /// The string representation of the same path, to make it easier to use
227    /// in some functions.
228    path_string: String,
229}
230
231impl TempPathWithString {
232    /// Creates a new [`tempfile::TempDir`], and stores its associated path
233    /// and string representation.
234    ///
235    /// ## Errors
236    /// If we can't create a temporary directory or construct the string representation
237    /// of its path.
238    pub fn new() -> anyhow::Result<Self> {
239        let temp_path = tempdir()?.keep();
240
241        // If we can't get a string representation of the temp path, then its display
242        // repesentation is unlikely to be useful.
243        // So clippy's suggestion isn't helpful here.
244        #[allow(clippy::unnecessary_debug_formatting)]
245        let temp_path_string = temp_path
246            .to_str()
247            .ok_or_else(|| anyhow::anyhow!("Couldn't get a path string from {temp_path:?}"))?
248            .to_string();
249
250        Ok(Self {
251            path: temp_path,
252            path_string: temp_path_string,
253        })
254    }
255
256    /// Returns a reference to this temporary directory's path.
257    #[must_use]
258    pub fn as_path(&self) -> &Path {
259        &self.path
260    }
261
262    /// Returns a reference to this temporary path's string representation.
263    #[must_use]
264    pub fn as_str(&self) -> &str {
265        &self.path_string
266    }
267}
268
269/// Initializes a tracing subscriber for tests.
270///
271/// ## Errors
272/// Returns [`anyhow::Error`] if
273/// we can't set the tracing subscriber as the global default.
274pub fn init_tracing_subscriber() -> anyhow::Result<()> {
275    let subscriber = FmtSubscriber::builder()
276        .with_max_level(Level::INFO)
277        .finish();
278    tracing::subscriber::set_global_default(subscriber)?;
279
280    Ok(())
281}
282
283/// A temporary location for storing traces.
284///
285/// Used for tests that need to verify certain events happened
286/// based on the application traces.
287#[derive(Debug)]
288pub struct TempTracingLocation {
289    /// The temporary directory to store the trace.
290    dir: TempDir,
291
292    /// The string representation of the temp tracing path, pre-computed
293    /// in [`TempTracingLocation::new`].
294    /// Storing the pre-computed string makes it easier for users of this
295    /// structure to get a string path without having to do their own error handling.
296    dir_path: String,
297
298    /// The UUID7 prefix of the trace files in the temporary directory.
299    file_prefix: String,
300}
301
302impl TempTracingLocation {
303    /// Creates a new temporary directory for trace files, and generates their
304    /// UUID7 filename prefix.
305    ///
306    /// ## Errors
307    /// Returns [`anyhow::Error`] if we fail to:
308    /// - Create a new temporary directory
309    /// - Get the string representation of that directory's path
310    pub fn new() -> Result<Self> {
311        let dir = TempDir::new()?;
312        let dir_path = dir
313            .path()
314            .to_str()
315            .ok_or_else(|| anyhow::anyhow!("Cannot get the string path to the tracing dir."))?
316            .to_string();
317
318        let file_prefix = Uuid::now_v7().to_string();
319
320        Ok(Self {
321            dir,
322            dir_path,
323            file_prefix,
324        })
325    }
326
327    /// Returns a reference to the temporary directory which will hold the trace files.
328    #[must_use]
329    pub fn dir(&self) -> &TempDir {
330        &self.dir
331    }
332
333    /// Returns a string representation of the path, corresponding to [`Self::dir`].
334    #[must_use]
335    pub fn path(&self) -> &str {
336        &self.dir_path
337    }
338
339    /// The trace filename prefix.
340    #[must_use]
341    pub fn file_prefix(&self) -> &str {
342        &self.file_prefix
343    }
344
345    /// Iterates over the temporary directory and finds the first trace file that matches the [`Self::file_prefix`].
346    ///
347    /// ## Returns
348    /// The contents of the found trace file, if any.
349    ///
350    /// ## Errors
351    /// Returns [`anyhow::Error`] if we fail to:
352    /// - Read the temporary directory
353    /// - Get entries in that directory
354    /// - Read the entry matching the given prefix into a string
355    pub fn read_first_trace(&self) -> Result<Option<String>> {
356        let dir = fs::read_dir(self.dir())?;
357
358        for entry in dir {
359            let entry = entry?;
360            let file_name = entry.file_name();
361            let file_name = file_name.to_string_lossy();
362
363            if file_name.starts_with(self.file_prefix()) {
364                let output = fs::read_to_string(entry.path())?;
365                return Ok(Some(output));
366            }
367        }
368
369        Ok(None)
370    }
371}
372
373/// Host-side ports for the Omry server container to use.
374#[derive(Debug, Copy, Clone)]
375pub struct HostPorts {
376    /// Port for tarpc to bind to.
377    pub rpc: u16,
378
379    /// Port for the web server to bind to.
380    pub web: u16,
381}
382
383impl HostPorts {
384    /// Requests any free ports from the host OS.
385    ///
386    /// Returns dummy listeners for both respective ports to avoid
387    /// the ports becoming unavailable due to TOCTOU before
388    /// the caller can bind to them.
389    #[must_use]
390    pub(crate) fn auto() -> (Self, (TcpListener, TcpListener)) {
391        let (rpc_listener, rpc) = free_port_ipv6();
392        let (web_listener, web) = free_port_ipv6();
393        (Self { rpc, web }, (rpc_listener, web_listener))
394    }
395}
396
397/// A mount for a container.
398#[derive(Debug)]
399pub struct ContainerMount {
400    /// The mount for the container.
401    pub mount: Mount,
402
403    /// Temporary path on the host. Used if the Docker host is local.
404    pub temp_path: Option<TempPathWithString>,
405}
406
407impl ContainerMount {
408    /// Creates a mount for the container with the given path.
409    ///
410    /// If we're using a remote Docker host (set by `USING_REMOTE_DOCKER_HOST_KEY`),
411    /// will use [`Mount::tmpfs_mount`].
412    /// This assumes the remote Docker host is running on Linux (and will fail otherwise).
413    ///
414    /// Otherwise, will use [`Mount::bind_mount`].
415    ///
416    /// # Errors
417    ///
418    /// Returns [`anyhow::Error`] if the Docker host is local,
419    /// and we can't create a temporary path on the host.
420    pub fn new(container_path: &str) -> Result<Self> {
421        if let Ok(docker_host) = DockerHost::from_env()
422            && docker_host.is_remote()
423        {
424            let mount = Mount::tmpfs_mount(container_path);
425            return Ok(Self {
426                mount,
427                temp_path: None,
428            });
429        }
430
431        let temp_path = TempPathWithString::new()?;
432        let mount = Mount::bind_mount(temp_path.as_str(), container_path);
433
434        Ok(Self {
435            mount,
436            temp_path: Some(temp_path),
437        })
438    }
439}
440
441/// Gets the iroh endpoint address for the given server,
442/// via its web API.
443#[allow(clippy::missing_errors_doc)]
444pub async fn endpoint_addr(server_root: &str) -> Result<EndpointAddr> {
445    const ENDPOINT_SUBPATH: &str = "/endpoint-addr";
446    let url = Url::parse(server_root).context("error parsing the URL")?;
447    let url = url.join(ENDPOINT_SUBPATH)?;
448    let bytes = reqwest::get(url)
449        .await?
450        .error_for_status()
451        .context("Couldn't get the endpoint_address")?
452        .bytes()
453        .await
454        .context("Couldn't read the bytes")?;
455    let endpoint_addr =
456        postcard::from_bytes(&bytes).context("Error deserializing the endpoint address")?;
457    Ok(endpoint_addr)
458}
459
460/// Module for generating an authentication token.
461///
462/// Inspired by
463/// <https://github.com/EffortlessMetrics/uselesskey/blob/52375d7e2987de345006ce50e7831383ddbc0ab4/crates/uselesskey-core-token-shape/src/lib.rs#L70>.
464pub mod token {
465    use rand::RngExt;
466    use rand::distr::Alphanumeric;
467    use rand::rngs::ChaCha20Rng;
468
469    /// Generates a new random token with the given length.
470    pub fn generate(len: usize) -> String {
471        let rng = rand::make_rng::<ChaCha20Rng>();
472        rng.sample_iter(Alphanumeric)
473            .take(len)
474            .map(char::from)
475            .collect()
476    }
477}