Skip to main content

arminius_lib_nats/
test_utils.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4//! Utilities for running tests against a NATS instance.
5
6#![expect(
7	clippy::indexing_slicing,
8	clippy::expect_used,
9	reason = "This is test code, so this is fine"
10)]
11
12mod tests;
13
14use std::{env, error};
15
16use async_nats::{Client, ConnectError as NatsError};
17use color_eyre::eyre::Context as _;
18use nats_io_jwt::{KeyPair, OperatorLimits};
19use rand::distr::{Alphanumeric, SampleString as _};
20
21use crate::{
22	NatsConfiguration, NatsCredentials, NatsJwtCredentials, SharedNatsClient,
23	auth_management::{create_account, create_user},
24};
25
26impl NatsConfiguration {
27	/// Creates a new NATS configuration using a separate NATS account.
28	pub async fn for_test() -> NatsConfiguration {
29		let credentials =
30			create_nats_account_for_test(&Self::nats_url_from_env().expect("missing env var")[0])
31				.await
32				.expect("error creating NATS account for testing");
33		NatsConfiguration::with_credentials(credentials)
34			.expect("error creating NATS configuration for test")
35	}
36
37	/// connects to a running NATS server. provides dummy service info for
38	/// testing
39	pub async fn connect_for_test(&self) -> SharedNatsClient {
40		self.connect("test".into(), "1.0.0".into(), "description".into())
41			.await
42			.expect("error connecting to NATS for test")
43	}
44}
45
46/// Creates a new NATS account to ensure that unit tests can run concurrently
47/// without interfering with each other.
48///
49/// It is assumed that the server is started using the provided
50/// `nats-server-test.conf`
51///
52/// The following stuff is set up:
53///  - an account is created and signed with the operator's private key. The account is the entity
54///    representing the namespace / tenant.
55///  - a connection to NATS is established and authenticated using the `system user`
56///  - the account is then registered with the server (by publishing to a special topic)
57///  - a user is created and signed with the account's private key
58///  - a JWT is created and signed with the user's private key
59///  - a new connection to NATS is established and authenticated using the newly created JWT
60pub async fn create_nats_account_for_test(
61	url: &str,
62) -> Result<NatsCredentials, Box<dyn error::Error>> {
63	let system_credentials =
64		NatsSystemCredentials::from_env().context("NATS system credentials not configured")?;
65
66	let (account_name, user_name) = {
67		let name = Alphanumeric.sample_string(&mut rand::rng(), 16);
68		(name.to_ascii_uppercase(), name.to_ascii_lowercase())
69	};
70
71	let (test_account_key, test_account_token) = create_account(
72		&system_credentials.operator_account_seed,
73		// TODO: there's likely more configuration to be done on the account
74		Some(OperatorLimits::builder().conn(8).try_into()?),
75		&account_name,
76	)?;
77
78	// connect to server with system account
79	let nc = authenticate_as_sys(url, &system_credentials).await.unwrap_or_else(|err| {
80		panic!("failed to authenticating to NATS server using system credentials: {err:?}")
81	});
82
83	// register new account
84	let response = nc.request("$SYS.REQ.CLAIMS.UPDATE", test_account_token.into()).await?;
85
86	tracing::trace!("register account response: {response:?}");
87
88	// create a new user
89	let (test_user_key, test_user_token) = create_user(&test_account_key, &user_name)?;
90
91	let nats_credentials = NatsJwtCredentials { key: test_user_key, token: test_user_token };
92
93	// Ok(NatsCredentials::JWT { signing_key: (), token: () } { urls:
94	// vec![credentials.nats_url], credentials: nats_credentials })
95	Ok(nats_credentials.into())
96}
97
98/// Attempts to create a new connection to a running NATS server,
99/// using the system user credentials. A system user (belonging to the SYS
100/// account) is the only account eligible to creating other accounts.
101async fn authenticate_as_sys(
102	nats_url: &str,
103	credentials: &NatsSystemCredentials,
104) -> Result<Client, NatsError> {
105	authenticate_via_jwt(nats_url, &credentials.system_user_seed, &credentials.system_user_jwt)
106		.await
107}
108
109/// Creates a NATS connection using the provided credentials
110async fn authenticate_via_jwt(
111	nats_url: &str,
112	user_key: &KeyPair,
113	jwt: impl Into<String>,
114) -> Result<Client, NatsError> {
115	let user_key: KeyPair = user_key.to_owned();
116	let options = async_nats::ConnectOptions::with_jwt(jwt.into(), move |nonce| {
117		tracing::trace!("signing server nonce: {nonce:?}");
118		let user_key = user_key.clone();
119		async move { user_key.sign(&nonce).map_err(async_nats::AuthError::new) }
120	});
121	async_nats::connect_with_options(nats_url, options).await
122}
123
124/// Holds Keys for system- and operator account, which must match the server
125/// configuration.
126///
127/// I'd prefer parsing those directly from the server configuration file, but
128/// since NATS decided for a weird brew of json and yaml, these are kept
129/// redundant for now.
130struct NatsSystemCredentials {
131	/// Private Key of the NATS system account, which is the trust root and
132	/// required for registering new accounts.
133	system_user_seed: KeyPair,
134	/// JWT to login as a user in the system group.
135	system_user_jwt: String,
136	/// Private Key of the operator account. Required for signing new accounts.
137	operator_account_seed: KeyPair,
138}
139
140impl NatsSystemCredentials {
141	/// Reads System Credentials from env vars
142	fn from_env() -> color_eyre::eyre::Result<NatsSystemCredentials> {
143		fn var(name: &str) -> color_eyre::eyre::Result<String> {
144			env::var(name).context(format!("missing env var: {name}"))
145		}
146
147		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
148		let operator_account_seed = KeyPair::from_seed(&var("NATS_OPERATOR_ACCOUNT_SEED")?)
149			.context("invalid NATS operator account seed")?;
150		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
151		let system_user_seed = KeyPair::from_seed(&var("NATS_SYSTEM_USER_SEED")?)
152			.context("invalid NATS system user seed")?;
153		// FIXME(#1413): Address this lint.  ast-grep-ignore: missing-context
154		let system_user_jwt = var("NATS_SYSTEM_USER_JWT")?;
155
156		Ok(NatsSystemCredentials { operator_account_seed, system_user_seed, system_user_jwt })
157	}
158}