use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use tracing::{debug, info, warn};
use crate::secrets;
const ENV_TOKEN: &str = "KANADE_NATS_TOKEN";
const REG_VALUE: &str = "NatsToken";
const ENV_USER: &str = "KANADE_NATS_USER";
const ENV_PASSWORD: &str = "KANADE_NATS_PASSWORD";
const REG_USER: &str = "NatsUser";
const REG_PASSWORD: &str = "NatsPassword";
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const PROBE_NAME: &str = "auth-probe";
const DEAD_CHECK_INTERVAL: Duration = Duration::from_secs(15);
const AUTH_REJECTION_LIMIT: usize = 5;
const AUTH_REJECTION_WINDOW: Duration = Duration::from_secs(10);
const NAME_PREFIX: &str = "kanade-";
const NAME_SEP: char = '/';
const REG_SHARED_SUBKEY: &str = r"SOFTWARE\kanade\agent";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NatsRole {
Agent,
Backend,
Cli,
}
impl NatsRole {
pub fn as_str(self) -> &'static str {
match self {
NatsRole::Agent => "agent",
NatsRole::Backend => "backend",
NatsRole::Cli => "cli",
}
}
fn reg_subkey(self) -> String {
format!(r"SOFTWARE\kanade\{}", self.as_str())
}
}
fn resolve_token_with(
role: NatsRole,
read_reg: impl Fn(&str, &str) -> Option<String>,
env: Option<String>,
) -> Option<String> {
if let Some(t) = read_reg(&role.reg_subkey(), REG_VALUE) {
return Some(t);
}
if let Some(t) = read_reg(REG_SHARED_SUBKEY, REG_VALUE) {
return Some(t);
}
env.filter(|t| !t.is_empty())
}
fn resolve_token(role: NatsRole) -> Option<String> {
resolve_token_with(
role,
secrets::read_hklm_value,
std::env::var(ENV_TOKEN).ok(),
)
}
#[derive(Clone, PartialEq, Eq)]
struct UserCredential {
name: String,
password: String,
}
impl std::fmt::Debug for UserCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserCredential")
.field("name", &self.name)
.field("password", &"<redacted>")
.finish()
}
}
fn pair(
name: Option<String>,
password: Option<String>,
source: &str,
) -> Result<Option<UserCredential>> {
match (name, password) {
(Some(name), Some(password)) => Ok(Some(UserCredential { name, password })),
(None, None) => Ok(None),
(Some(_), None) => bail!("NATS user is set but its password is missing ({source})"),
(None, Some(_)) => bail!("NATS password is set but its user name is missing ({source})"),
}
}
fn resolve_user_with(
role: NatsRole,
read_reg: impl Fn(&str, &str) -> Option<String>,
env_user: Option<String>,
env_password: Option<String>,
) -> Result<Option<UserCredential>> {
let subkey = role.reg_subkey();
let reg_user = read_reg(&subkey, REG_USER);
let reg_password = read_reg(&subkey, REG_PASSWORD);
if reg_user.is_some() || reg_password.is_some() {
let source = format!(r"HKLM\{subkey}: {REG_USER} / {REG_PASSWORD}");
return pair(reg_user, reg_password, &source);
}
let source = format!("${ENV_USER} / ${ENV_PASSWORD}");
pair(
env_user.filter(|v| !v.is_empty()),
env_password.filter(|v| !v.is_empty()),
&source,
)
}
fn resolve_user(role: NatsRole) -> Result<Option<UserCredential>> {
resolve_user_with(
role,
secrets::read_hklm_value,
std::env::var(ENV_USER).ok(),
std::env::var(ENV_PASSWORD).ok(),
)
}
#[derive(Clone, Default)]
pub struct NatsCredentials {
token: Option<String>,
user: Option<UserCredential>,
}
impl NatsCredentials {
pub fn new(token: Option<String>, user: Option<(String, String)>) -> Self {
Self {
token,
user: user.map(|(name, password)| UserCredential { name, password }),
}
}
fn resolve(role: NatsRole) -> Result<Self> {
Ok(Self {
token: resolve_token(role),
user: resolve_user(role)?,
})
}
}
impl std::fmt::Debug for NatsCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NatsCredentials")
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.field("user", &self.user)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Choice {
User,
Token,
}
impl Choice {
fn label(self) -> &'static str {
match self {
Choice::User => "user",
Choice::Token => "token",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProbeOutcome {
Accepted,
Rejected,
Unreachable,
}
impl ProbeOutcome {
fn label(self) -> &'static str {
match self {
ProbeOutcome::Accepted => "accepted",
ProbeOutcome::Rejected => "rejected",
ProbeOutcome::Unreachable => "unreachable",
}
}
}
#[derive(Default)]
struct Live {
decision: Mutex<Option<Choice>>,
refusals: Mutex<Option<(std::time::Instant, usize)>>,
}
impl Live {
fn observe(&self, ev: &async_nats::Event) {
let mut refusals = self.refusals.lock().unwrap_or_else(|e| e.into_inner());
match ev {
async_nats::Event::Connected | async_nats::Event::Disconnected => *refusals = None,
async_nats::Event::ClientError(async_nats::ClientError::Other(kind))
if *kind == async_nats::ConnectErrorKind::AuthorizationViolation.to_string()
|| *kind == async_nats::ConnectErrorKind::Authentication.to_string() =>
{
let (first, n) = refusals.unwrap_or((std::time::Instant::now(), 0));
*refusals = Some((first, n + 1));
}
async_nats::Event::ClientError(_) => *refusals = None,
_ => {}
}
}
fn auth_failed(&self) -> bool {
match *self.refusals.lock().unwrap_or_else(|e| e.into_inner()) {
Some((first, n)) => {
n >= AUTH_REJECTION_LIMIT && first.elapsed() >= AUTH_REJECTION_WINDOW
}
None => false,
}
}
fn get(&self) -> Option<Choice> {
*self.decision.lock().unwrap_or_else(|e| e.into_inner())
}
fn set(&self, c: Choice) {
*self.decision.lock().unwrap_or_else(|e| e.into_inner()) = Some(c);
}
}
fn live_for(role: NatsRole) -> Arc<Live> {
static LIVE: OnceLock<Mutex<HashMap<&'static str, Arc<Live>>>> = OnceLock::new();
let mut map = LIVE
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner());
map.entry(role.as_str()).or_default().clone()
}
fn select(
outcome: ProbeOutcome,
last_worked: Option<Choice>,
have_token: bool,
) -> std::result::Result<Choice, &'static str> {
match outcome {
ProbeOutcome::Accepted => Ok(Choice::User),
ProbeOutcome::Rejected if have_token => Ok(Choice::Token),
ProbeOutcome::Rejected => {
Err("the broker rejected the NATS user and no NatsToken is provisioned")
}
ProbeOutcome::Unreachable => Ok(last_worked.unwrap_or(Choice::User)),
}
}
async fn probe_user(url: &str, user: &UserCredential) -> ProbeOutcome {
let (url, name, password) = (url.to_string(), user.name.clone(), user.password.clone());
let probe = tokio::spawn(async move {
let attempt = async_nats::ConnectOptions::new()
.name(PROBE_NAME)
.connection_timeout(PROBE_TIMEOUT)
.user_and_password(name, password)
.connect(url);
match tokio::time::timeout(PROBE_TIMEOUT + Duration::from_secs(1), attempt).await {
Ok(Ok(_client)) => ProbeOutcome::Accepted,
Ok(Err(e)) if e.kind() == async_nats::ConnectErrorKind::AuthorizationViolation => {
ProbeOutcome::Rejected
}
Ok(Err(_)) | Err(_) => ProbeOutcome::Unreachable,
}
});
probe.await.unwrap_or(ProbeOutcome::Unreachable)
}
async fn choose<P, Fut>(
role: NatsRole,
have_token: bool,
live: &Live,
probe: P,
) -> std::result::Result<Choice, &'static str>
where
P: FnOnce() -> Fut,
Fut: std::future::Future<Output = ProbeOutcome>,
{
let outcome = probe().await;
let previous = live.get();
let choice = select(outcome, previous, have_token)?;
if outcome != ProbeOutcome::Unreachable {
live.set(choice);
}
if previous == Some(choice) {
debug!(
role = role.as_str(),
credential = choice.label(),
probe = outcome.label(),
"NATS credential selected"
);
} else {
info!(
role = role.as_str(),
credential = choice.label(),
probe = outcome.label(),
"NATS credential selected"
);
}
Ok(choice)
}
enum AuthPlan {
Static(Option<String>),
Select {
token: Option<String>,
user: UserCredential,
},
}
fn auth_plan(creds: NatsCredentials) -> AuthPlan {
match creds.user {
None => AuthPlan::Static(creds.token),
Some(user) => AuthPlan::Select {
token: creds.token,
user,
},
}
}
pub fn client_name(role: NatsRole, identity: Option<&str>) -> String {
match identity.map(str::trim).filter(|s| !s.is_empty()) {
Some(id) => format!("{NAME_PREFIX}{}{NAME_SEP}{id}", role.as_str()),
None => format!("{NAME_PREFIX}{}", role.as_str()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientName<'a> {
pub role: &'a str,
pub identity: Option<&'a str>,
}
pub fn parse_client_name(name: &str) -> Option<ClientName<'_>> {
let rest = name.strip_prefix(NAME_PREFIX)?;
Some(match rest.split_once(NAME_SEP) {
Some((role, id)) if !role.is_empty() && !id.is_empty() => ClientName {
role,
identity: Some(id),
},
Some((role, _)) if !role.is_empty() => ClientName {
role,
identity: None,
},
Some(_) => return None,
None if !rest.is_empty() => ClientName {
role: rest,
identity: None,
},
None => return None,
})
}
pub struct CredentialProbe {
token: Option<String>,
user: Option<String>,
live: Option<Arc<Live>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialKind {
None,
Token,
User,
}
impl std::fmt::Debug for CredentialProbe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut held = Vec::new();
if self.token.is_some() {
held.push("<redacted token>".to_string());
}
if let Some(name) = &self.user {
held.push(format!("user {name}"));
}
let rendered = if held.is_empty() {
"<none>".to_string()
} else {
held.join(" + ")
};
f.debug_struct("CredentialProbe")
.field("presented", &rendered)
.field("kind", &self.kind())
.finish()
}
}
impl CredentialProbe {
pub fn for_role(role: NatsRole) -> Self {
Self {
token: resolve_token(role),
user: resolve_user(role).ok().flatten().map(|u| u.name),
live: Some(live_for(role)),
}
}
pub fn from_token(token: Option<String>) -> Self {
Self {
token,
user: None,
live: None,
}
}
pub fn from_user(name: impl Into<String>) -> Self {
Self {
token: None,
user: Some(name.into()),
live: None,
}
}
pub fn kind(&self) -> CredentialKind {
let decided = self.live.as_ref().and_then(|l| l.get());
match decided {
Some(Choice::User) if self.user.is_some() => return CredentialKind::User,
Some(Choice::Token) if self.token.is_some() => return CredentialKind::Token,
_ => {}
}
if self.live.is_none() && self.user.is_some() {
CredentialKind::User
} else if self.token.is_some() {
CredentialKind::Token
} else {
CredentialKind::None
}
}
pub fn is_ours(&self, candidate: &str) -> bool {
self.token.as_deref() == Some(candidate)
}
}
pub async fn connect(role: NatsRole, url: &str) -> Result<async_nats::Client> {
connect_inner(
role,
url,
None,
None::<fn(async_nats::Event) -> std::future::Ready<()>>,
NatsCredentials::resolve(role)?,
live_for(role),
)
.await
}
pub async fn connect_with_event_callback<F, Fut>(
role: NatsRole,
url: &str,
identity: Option<&str>,
cb: F,
) -> Result<async_nats::Client>
where
F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
{
connect_inner(
role,
url,
identity,
Some(cb),
NatsCredentials::resolve(role)?,
live_for(role),
)
.await
}
pub async fn connect_with_credentials(
role: NatsRole,
url: &str,
creds: NatsCredentials,
) -> Result<async_nats::Client> {
connect_inner(
role,
url,
None,
None::<fn(async_nats::Event) -> std::future::Ready<()>>,
creds,
live_for(role),
)
.await
}
pub async fn connect_with_credentials_and_event_callback<F, Fut>(
role: NatsRole,
url: &str,
creds: NatsCredentials,
cb: F,
) -> Result<async_nats::Client>
where
F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
{
connect_inner(role, url, None, Some(cb), creds, live_for(role)).await
}
async fn connect_inner<F, Fut>(
role: NatsRole,
url: &str,
identity: Option<&str>,
cb: Option<F>,
creds: NatsCredentials,
live: Arc<Live>,
) -> Result<async_nats::Client>
where
F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
{
let _ = rustls::crypto::ring::default_provider().install_default();
let tracker = live.clone();
let opts = match auth_plan(creds) {
AuthPlan::Static(token) => {
let opts = async_nats::ConnectOptions::new();
match token {
Some(token) => opts.token(token),
None => opts,
}
}
AuthPlan::Select { token, user } => {
let url = url.to_string();
async_nats::ConnectOptions::with_auth_callback(move |_nonce| {
let (url, token, user, live) =
(url.clone(), token.clone(), user.clone(), live.clone());
async move {
let choice = choose(role, token.is_some(), &live, || probe_user(&url, &user))
.await
.map_err(async_nats::AuthError::new)?;
let mut auth = async_nats::Auth::new();
match choice {
Choice::User => {
auth.username = Some(user.name);
auth.password = Some(user.password);
}
Choice::Token => auth.token = token,
}
Ok(auth)
}
})
}
};
let opts = opts
.retry_on_initial_connect()
.name(client_name(role, identity));
let opts = opts.event_callback(move |ev| {
tracker.observe(&ev);
let forwarded = cb.as_ref().map(|cb| cb(ev));
async move {
if let Some(forwarded) = forwarded {
forwarded.await;
}
}
});
opts.connect(url)
.await
.with_context(|| format!("connect to NATS at {url}"))
}
pub async fn is_dead(client: &async_nats::Client) -> bool {
match tokio::time::timeout(Duration::from_secs(5), client.flush()).await {
Ok(Err(e)) => match e.kind() {
async_nats::client::FlushErrorKind::SendError => true,
async_nats::client::FlushErrorKind::FlushError => false,
},
Ok(Ok(())) | Err(_) => false,
}
}
pub async fn wait_until_dead_every(
role: NatsRole,
client: &async_nats::Client,
interval: Duration,
) {
let live = live_for(role);
let mut tick = tokio::time::interval(interval);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
if live.auth_failed() {
warn!(
role = role.as_str(),
"the NATS broker keeps refusing this role's credential; the client cannot connect"
);
return;
}
if is_dead(client).await {
warn!(
role = role.as_str(),
"NATS connection task has terminated; the client can no longer talk to the broker"
);
return;
}
}
}
pub async fn wait_until_dead(role: NatsRole, client: &async_nats::Client) {
wait_until_dead_every(role, client, DEAD_CHECK_INTERVAL).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn reg(entries: &[(&str, &str)]) -> impl Fn(&str, &str) -> Option<String> {
let map: HashMap<String, String> = entries
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |subkey: &str, value: &str| map.get(&format!(r"{subkey}\{value}")).cloned()
}
#[test]
fn role_subkeys_are_distinct_and_agent_matches_the_shared_path() {
assert_eq!(NatsRole::Backend.reg_subkey(), r"SOFTWARE\kanade\backend");
assert_eq!(NatsRole::Cli.reg_subkey(), r"SOFTWARE\kanade\cli");
assert_eq!(NatsRole::Agent.reg_subkey(), REG_SHARED_SUBKEY);
}
#[test]
fn an_unmigrated_fleet_keeps_presenting_the_shared_token() {
let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
for role in [NatsRole::Agent, NatsRole::Backend, NatsRole::Cli] {
assert_eq!(
resolve_token_with(role, ®istry, None).as_deref(),
Some("shared"),
"{role:?} must keep working before its own key is provisioned"
);
}
}
#[test]
fn a_role_key_wins_over_the_shared_one() {
let registry = reg(&[
(r"SOFTWARE\kanade\agent\NatsToken", "shared"),
(r"SOFTWARE\kanade\backend\NatsToken", "backend-only"),
]);
assert_eq!(
resolve_token_with(NatsRole::Backend, ®istry, None).as_deref(),
Some("backend-only")
);
assert_eq!(
resolve_token_with(NatsRole::Cli, ®istry, None).as_deref(),
Some("shared")
);
}
#[test]
fn removing_a_role_key_falls_back_rather_than_failing() {
let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
assert_eq!(
resolve_token_with(NatsRole::Backend, ®istry, None).as_deref(),
Some("shared")
);
}
#[test]
fn the_registry_outranks_the_environment() {
let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
assert_eq!(
resolve_token_with(NatsRole::Agent, ®istry, Some("from-env".into())).as_deref(),
Some("shared")
);
}
#[test]
fn the_environment_serves_hosts_with_no_registry_at_all() {
let empty = reg(&[]);
assert_eq!(
resolve_token_with(NatsRole::Cli, &empty, Some("from-env".into())).as_deref(),
Some("from-env")
);
assert_eq!(
resolve_token_with(NatsRole::Cli, &empty, Some(String::new())),
None
);
assert_eq!(resolve_token_with(NatsRole::Cli, &empty, None), None);
}
#[test]
fn an_identity_round_trips_through_the_connection_name() {
for pc in ["PC001", "minipc", "Web%01", "ws-9"] {
let name = client_name(NatsRole::Agent, Some(pc));
let parsed = parse_client_name(&name).expect("our own name must parse");
assert_eq!(parsed.role, "agent");
assert_eq!(parsed.identity, Some(pc));
}
}
#[test]
fn a_role_without_an_identity_keeps_the_pre_1270_name() {
assert_eq!(client_name(NatsRole::Backend, None), "kanade-backend");
let parsed = parse_client_name("kanade-agent").unwrap();
assert_eq!(parsed.role, "agent");
assert_eq!(parsed.identity, None);
assert_eq!(client_name(NatsRole::Agent, Some(" ")), "kanade-agent");
}
#[test]
fn foreign_connections_do_not_parse_as_kanade_ones() {
assert!(parse_client_name("NATS CLI Version 0.1.5").is_none());
assert!(parse_client_name("").is_none());
assert!(parse_client_name("kanade-").is_none());
assert!(parse_client_name("kanade-/PC001").is_none());
assert_eq!(parse_client_name("kanade-agent/").unwrap().identity, None);
}
#[test]
fn an_unknown_role_is_preserved_rather_than_dropped() {
let parsed = parse_client_name("kanade-relay/PC001").unwrap();
assert_eq!(parsed.role, "relay");
assert_eq!(parsed.identity, Some("PC001"));
}
#[test]
fn the_probe_recognises_only_the_credential_we_present() {
let probe = CredentialProbe::from_token(Some("shared".into()));
assert_eq!(probe.kind(), CredentialKind::Token);
assert!(probe.is_ours("shared"));
assert!(!probe.is_ours("something-else"));
assert!(!probe.is_ours(""));
}
#[test]
fn a_probe_with_no_credential_matches_nothing() {
let probe = CredentialProbe::from_token(None);
assert_eq!(probe.kind(), CredentialKind::None);
assert!(!probe.is_ours(""));
assert!(!probe.is_ours("anything"));
}
#[test]
fn a_username_is_not_a_secret_we_can_recognise() {
let probe = CredentialProbe::from_user("kanade-backend");
assert_eq!(probe.kind(), CredentialKind::User);
assert!(!probe.is_ours("kanade-backend"));
}
#[test]
fn the_probe_never_prints_the_credential() {
let probe = CredentialProbe::from_token(Some("super-secret-token".into()));
let rendered = format!("{probe:?}");
assert!(!rendered.contains("super-secret-token"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
assert!(
format!("{:?}", CredentialProbe::from_token(None)).contains("none"),
"the no-credential case should be visible, just not the value"
);
assert!(
format!("{:?}", CredentialProbe::from_user("kanade-backend"))
.contains("kanade-backend"),
);
}
fn user(r: Result<Option<UserCredential>>) -> Option<(String, String)> {
r.unwrap().map(|u| (u.name, u.password))
}
#[test]
fn a_users_registry_pair_comes_from_the_roles_own_key() {
let registry = reg(&[
(r"SOFTWARE\kanade\backend\NatsUser", "kanade-backend"),
(r"SOFTWARE\kanade\backend\NatsPassword", "pw"),
]);
assert_eq!(
user(resolve_user_with(NatsRole::Backend, ®istry, None, None)),
Some(("kanade-backend".into(), "pw".into()))
);
for role in [NatsRole::Agent, NatsRole::Cli] {
assert_eq!(user(resolve_user_with(role, ®istry, None, None)), None);
}
let agent = reg(&[
(r"SOFTWARE\kanade\agent\NatsUser", "a"),
(r"SOFTWARE\kanade\agent\NatsPassword", "b"),
]);
assert_eq!(
user(resolve_user_with(NatsRole::Cli, &agent, None, None)),
None
);
}
#[test]
fn the_registry_pair_outranks_the_environment_pair() {
let registry = reg(&[
(r"SOFTWARE\kanade\cli\NatsUser", "reg-user"),
(r"SOFTWARE\kanade\cli\NatsPassword", "reg-pw"),
]);
assert_eq!(
user(resolve_user_with(
NatsRole::Cli,
®istry,
Some("env-user".into()),
Some("env-pw".into())
)),
Some(("reg-user".into(), "reg-pw".into()))
);
}
#[test]
fn the_environment_serves_a_user_pair_when_the_registry_has_none() {
assert_eq!(
user(resolve_user_with(
NatsRole::Cli,
reg(&[]),
Some("u".into()),
Some("p".into())
)),
Some(("u".into(), "p".into()))
);
assert_eq!(
user(resolve_user_with(
NatsRole::Cli,
reg(&[]),
Some(String::new()),
Some(String::new())
)),
None
);
assert_eq!(
user(resolve_user_with(NatsRole::Cli, reg(&[]), None, None)),
None
);
}
#[test]
fn half_a_pair_is_an_error_not_a_fallback() {
let err = |r: Result<Option<UserCredential>>| format!("{:#}", r.unwrap_err());
let only_user = reg(&[(r"SOFTWARE\kanade\cli\NatsUser", "u")]);
assert!(
err(resolve_user_with(NatsRole::Cli, &only_user, None, None))
.contains("password is missing")
);
let only_pw = reg(&[(r"SOFTWARE\kanade\cli\NatsPassword", "secret-pw")]);
let e = err(resolve_user_with(NatsRole::Cli, &only_pw, None, None));
assert!(e.contains("user name is missing"), "{e}");
assert!(!e.contains("secret-pw"), "{e}");
assert!(
resolve_user_with(
NatsRole::Cli,
&only_user,
Some("u".into()),
Some("p".into())
)
.is_err()
);
assert!(resolve_user_with(NatsRole::Cli, reg(&[]), Some("u".into()), None).is_err());
assert!(resolve_user_with(NatsRole::Cli, reg(&[]), None, Some("p".into())).is_err());
}
#[test]
fn credentials_never_print_a_secret() {
let creds = NatsCredentials::new(
Some("tok-secret".into()),
Some(("kanade-agent".into(), "pw-secret".into())),
);
let rendered = format!("{creds:?}");
assert!(!rendered.contains("tok-secret"), "{rendered}");
assert!(!rendered.contains("pw-secret"), "{rendered}");
assert!(rendered.contains("kanade-agent"), "{rendered}");
}
#[test]
fn no_user_means_the_static_token_path_with_no_selection() {
match auth_plan(NatsCredentials::new(Some("t".into()), None)) {
AuthPlan::Static(Some(t)) => assert_eq!(t, "t"),
_ => panic!("a token-only host must take the static path"),
}
assert!(matches!(
auth_plan(NatsCredentials::new(None, None)),
AuthPlan::Static(None)
));
assert!(matches!(
auth_plan(NatsCredentials::new(
Some("t".into()),
Some(("u".into(), "p".into()))
)),
AuthPlan::Select { .. }
));
}
#[test]
fn selection_follows_the_probe() {
use Choice::{Token, User};
use ProbeOutcome::*;
assert_eq!(select(Accepted, None, true), Ok(User));
assert_eq!(select(Accepted, Some(Token), false), Ok(User));
assert_eq!(select(Rejected, Some(User), true), Ok(Token));
let missing = select(Rejected, None, false).unwrap_err();
assert!(missing.contains("NatsToken"), "{missing}");
assert_eq!(select(Unreachable, None, true), Ok(User));
assert_eq!(select(Unreachable, Some(Token), true), Ok(Token));
assert_eq!(select(Unreachable, Some(User), true), Ok(User));
}
#[tokio::test]
async fn only_a_probe_answer_is_remembered() {
let live = Live::default();
let role = NatsRole::Cli;
let run = |o| choose(role, true, &live, move || async move { o });
assert_eq!(run(ProbeOutcome::Unreachable).await, Ok(Choice::User));
assert_eq!(live.get(), None);
assert_eq!(run(ProbeOutcome::Rejected).await, Ok(Choice::Token));
assert_eq!(live.get(), Some(Choice::Token));
assert_eq!(run(ProbeOutcome::Unreachable).await, Ok(Choice::Token));
assert_eq!(run(ProbeOutcome::Accepted).await, Ok(Choice::User));
assert_eq!(live.get(), Some(Choice::User));
let err = choose(role, false, &live, || async { ProbeOutcome::Rejected })
.await
.unwrap_err();
assert!(err.contains("NatsToken"));
assert_eq!(live.get(), Some(Choice::User));
}
#[test]
fn the_probe_reports_the_shape_in_use_right_now() {
let live = Arc::new(Live::default());
let probe = CredentialProbe {
token: Some("tok".into()),
user: Some("kanade-agent".into()),
live: Some(live.clone()),
};
assert_eq!(probe.kind(), CredentialKind::Token);
live.set(Choice::User);
assert_eq!(probe.kind(), CredentialKind::User);
live.set(Choice::Token);
assert_eq!(probe.kind(), CredentialKind::Token);
assert!(probe.is_ours("tok"));
let user_only = CredentialProbe {
token: None,
user: Some("u".into()),
live: Some(Arc::new(Live::default())),
};
assert_eq!(user_only.kind(), CredentialKind::None);
user_only.live.as_ref().unwrap().set(Choice::User);
assert_eq!(user_only.kind(), CredentialKind::User);
}
#[test]
fn a_probe_holding_both_credentials_prints_neither_secret() {
let probe = CredentialProbe {
token: Some("tok-secret".into()),
user: Some("kanade-agent".into()),
live: None,
};
let rendered = format!("{probe:?}");
assert!(!rendered.contains("tok-secret"), "{rendered}");
assert!(rendered.contains("kanade-agent"), "{rendered}");
}
#[test]
fn sustained_refusals_fail_the_client_and_a_connect_clears_them() {
let refused = || {
async_nats::Event::ClientError(async_nats::ClientError::Other(
async_nats::ConnectErrorKind::AuthorizationViolation.to_string(),
))
};
let live = Live::default();
for _ in 0..AUTH_REJECTION_LIMIT {
live.observe(&refused());
}
assert!(!live.auth_failed());
let aged = std::time::Instant::now() - AUTH_REJECTION_WINDOW - Duration::from_secs(1);
*live.refusals.lock().unwrap() = Some((aged, AUTH_REJECTION_LIMIT));
assert!(live.auth_failed());
live.observe(&async_nats::Event::Connected);
assert!(!live.auth_failed());
*live.refusals.lock().unwrap() = Some((aged, AUTH_REJECTION_LIMIT));
live.observe(&async_nats::Event::ClientError(
async_nats::ClientError::Other("io".into()),
));
assert!(!live.auth_failed());
live.observe(&refused());
assert!(!live.auth_failed());
}
#[test]
fn connect_leaves_a_process_crypto_provider_installed() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
connect(NatsRole::Agent, "nats://127.0.0.1:1")
.await
.unwrap();
});
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
}
}