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}