mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
* test: add unit tests across 20 modules for coverage push Add 300+ unit tests covering config, context, evaluation, extensions, LLM, secrets, tools/builder, and tools/mcp modules. All tests are pure unit tests (no mocks) exercising serde roundtrips, edge cases, error paths, and business logic. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(tests): replace hardcoded /tmp paths with tempfile::tempdir The e2e_metrics_test::test_metrics_collected_from_tool_trace test was failing because setup_test_dir() created /tmp/ironclaw_metrics_test but the fixture referenced /tmp/ironclaw_e2e_test/hello.txt (path mismatch). Added LlmTrace::replace_paths() to substitute fixture paths at runtime, then converted all 12 test files from hardcoded /tmp/ironclaw_* paths to tempfile::tempdir(). Tests are now isolated, parallel-safe, and leave no debris on disk. Regression test: test_metrics_collected_from_tool_trace now passes consistently regardless of prior /tmp state. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
319 lines
11 KiB
Rust
319 lines
11 KiB
Rust
use crate::config::helpers::optional_env;
|
|
use crate::error::ConfigError;
|
|
use crate::settings::Settings;
|
|
|
|
/// Tunnel configuration for exposing the agent to the internet.
|
|
///
|
|
/// Used by channels and tools that need public webhook endpoints.
|
|
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
|
|
///
|
|
/// Two modes:
|
|
/// - **Static URL** (`TUNNEL_URL`): set the public URL directly (manual tunnel)
|
|
/// - **Managed provider** (`TUNNEL_PROVIDER`): lifecycle-managed tunnel process
|
|
///
|
|
/// When a managed provider is configured _and_ no static URL is set,
|
|
/// the gateway starts the tunnel on boot and populates `public_url`.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct TunnelConfig {
|
|
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
|
|
/// Set statically via `TUNNEL_URL` or populated at runtime by a managed tunnel.
|
|
pub public_url: Option<String>,
|
|
/// Provider configuration for lifecycle-managed tunnels.
|
|
/// `None` when using a static URL or no tunnel at all.
|
|
pub provider: Option<crate::tunnel::TunnelProviderConfig>,
|
|
}
|
|
|
|
impl TunnelConfig {
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
let public_url = optional_env("TUNNEL_URL")?
|
|
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
|
|
|
|
if let Some(ref url) = public_url
|
|
&& !url.starts_with("https://")
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "TUNNEL_URL".to_string(),
|
|
message: "must start with https:// (webhooks require HTTPS)".to_string(),
|
|
});
|
|
}
|
|
|
|
// Resolve managed tunnel provider config.
|
|
// Priority: env var > settings > default (none).
|
|
let provider_name = optional_env("TUNNEL_PROVIDER")?
|
|
.or_else(|| settings.tunnel.provider.clone())
|
|
.unwrap_or_default();
|
|
|
|
let provider = if provider_name.is_empty() || provider_name == "none" {
|
|
None
|
|
} else {
|
|
Some(crate::tunnel::TunnelProviderConfig {
|
|
provider: provider_name.clone(),
|
|
cloudflare: optional_env("TUNNEL_CF_TOKEN")?
|
|
.or_else(|| settings.tunnel.cf_token.clone())
|
|
.map(|token| crate::tunnel::CloudflareTunnelConfig { token }),
|
|
tailscale: Some(crate::tunnel::TailscaleTunnelConfig {
|
|
funnel: optional_env("TUNNEL_TS_FUNNEL")?
|
|
.map(|s| s == "true" || s == "1")
|
|
.unwrap_or(settings.tunnel.ts_funnel),
|
|
hostname: optional_env("TUNNEL_TS_HOSTNAME")?
|
|
.or_else(|| settings.tunnel.ts_hostname.clone()),
|
|
}),
|
|
ngrok: {
|
|
let ngrok_domain = optional_env("TUNNEL_NGROK_DOMAIN")?
|
|
.or_else(|| settings.tunnel.ngrok_domain.clone());
|
|
optional_env("TUNNEL_NGROK_TOKEN")?
|
|
.or_else(|| settings.tunnel.ngrok_token.clone())
|
|
.map(|auth_token| crate::tunnel::NgrokTunnelConfig {
|
|
auth_token,
|
|
domain: ngrok_domain,
|
|
})
|
|
},
|
|
custom: {
|
|
let health_url = optional_env("TUNNEL_CUSTOM_HEALTH_URL")?
|
|
.or_else(|| settings.tunnel.custom_health_url.clone());
|
|
let url_pattern = optional_env("TUNNEL_CUSTOM_URL_PATTERN")?
|
|
.or_else(|| settings.tunnel.custom_url_pattern.clone());
|
|
optional_env("TUNNEL_CUSTOM_COMMAND")?
|
|
.or_else(|| settings.tunnel.custom_command.clone())
|
|
.map(|start_command| crate::tunnel::CustomTunnelConfig {
|
|
start_command,
|
|
health_url,
|
|
url_pattern,
|
|
})
|
|
},
|
|
})
|
|
};
|
|
|
|
Ok(Self {
|
|
public_url,
|
|
provider,
|
|
})
|
|
}
|
|
|
|
/// Check if a tunnel is configured (static URL or managed provider).
|
|
pub fn is_enabled(&self) -> bool {
|
|
self.public_url.is_some() || self.provider.is_some()
|
|
}
|
|
|
|
/// Get the webhook URL for a given path.
|
|
pub fn webhook_url(&self, path: &str) -> Option<String> {
|
|
self.public_url.as_ref().map(|base| {
|
|
let base = base.trim_end_matches('/');
|
|
let path = path.trim_start_matches('/');
|
|
format!("{}/{}", base, path)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::config::tunnel::TunnelConfig;
|
|
use crate::tunnel::{
|
|
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
|
|
TunnelProviderConfig,
|
|
};
|
|
|
|
// ── Default ─────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn default_is_disabled() {
|
|
let cfg = TunnelConfig::default();
|
|
assert!(cfg.public_url.is_none());
|
|
assert!(cfg.provider.is_none());
|
|
assert!(!cfg.is_enabled());
|
|
}
|
|
|
|
// ── is_enabled ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn is_enabled_with_static_url() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://tunnel.example.com".to_string()),
|
|
provider: None,
|
|
};
|
|
assert!(cfg.is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn is_enabled_with_provider() {
|
|
let cfg = TunnelConfig {
|
|
public_url: None,
|
|
provider: Some(TunnelProviderConfig {
|
|
provider: "cloudflare".to_string(),
|
|
cloudflare: Some(CloudflareTunnelConfig {
|
|
token: "cf-tok".to_string(),
|
|
}),
|
|
tailscale: None,
|
|
ngrok: None,
|
|
custom: None,
|
|
}),
|
|
};
|
|
assert!(cfg.is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn is_enabled_with_both() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://example.com".to_string()),
|
|
provider: Some(TunnelProviderConfig {
|
|
provider: "ngrok".to_string(),
|
|
cloudflare: None,
|
|
tailscale: None,
|
|
ngrok: Some(NgrokTunnelConfig {
|
|
auth_token: "ngrok-tok".to_string(),
|
|
domain: None,
|
|
}),
|
|
custom: None,
|
|
}),
|
|
};
|
|
assert!(cfg.is_enabled());
|
|
}
|
|
|
|
// ── webhook_url ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn webhook_url_none_when_no_public_url() {
|
|
let cfg = TunnelConfig::default();
|
|
assert!(cfg.webhook_url("/hook").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_url_basic() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://abc.ngrok.io".to_string()),
|
|
provider: None,
|
|
};
|
|
assert_eq!(
|
|
cfg.webhook_url("/webhook/telegram"),
|
|
Some("https://abc.ngrok.io/webhook/telegram".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_url_trims_trailing_slash_on_base() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://abc.ngrok.io/".to_string()),
|
|
provider: None,
|
|
};
|
|
assert_eq!(
|
|
cfg.webhook_url("/hook"),
|
|
Some("https://abc.ngrok.io/hook".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_url_trims_leading_slash_on_path() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://abc.ngrok.io".to_string()),
|
|
provider: None,
|
|
};
|
|
// Path without leading slash should also work
|
|
assert_eq!(
|
|
cfg.webhook_url("hook"),
|
|
Some("https://abc.ngrok.io/hook".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_url_double_slash_normalization() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://abc.ngrok.io/".to_string()),
|
|
provider: None,
|
|
};
|
|
// Both base trailing and path leading slashes trimmed
|
|
assert_eq!(
|
|
cfg.webhook_url("/api/webhook"),
|
|
Some("https://abc.ngrok.io/api/webhook".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_url_empty_path() {
|
|
let cfg = TunnelConfig {
|
|
public_url: Some("https://abc.ngrok.io".to_string()),
|
|
provider: None,
|
|
};
|
|
assert_eq!(
|
|
cfg.webhook_url(""),
|
|
Some("https://abc.ngrok.io/".to_string())
|
|
);
|
|
}
|
|
|
|
// ── TunnelProviderConfig field coverage ─────────────────────────
|
|
|
|
#[test]
|
|
fn provider_config_cloudflare() {
|
|
let p = TunnelProviderConfig {
|
|
provider: "cloudflare".to_string(),
|
|
cloudflare: Some(CloudflareTunnelConfig {
|
|
token: "cf-secret".to_string(),
|
|
}),
|
|
tailscale: None,
|
|
ngrok: None,
|
|
custom: None,
|
|
};
|
|
assert_eq!(p.provider, "cloudflare");
|
|
assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret");
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_tailscale() {
|
|
let ts = TailscaleTunnelConfig {
|
|
funnel: true,
|
|
hostname: Some("my-host".to_string()),
|
|
};
|
|
assert!(ts.funnel);
|
|
assert_eq!(ts.hostname.as_deref(), Some("my-host"));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_tailscale_defaults() {
|
|
let ts = TailscaleTunnelConfig::default();
|
|
assert!(!ts.funnel);
|
|
assert!(ts.hostname.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_ngrok() {
|
|
let ng = NgrokTunnelConfig {
|
|
auth_token: "ng-tok".to_string(),
|
|
domain: Some("custom.ngrok.dev".to_string()),
|
|
};
|
|
assert_eq!(ng.auth_token, "ng-tok");
|
|
assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev"));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_ngrok_defaults() {
|
|
let ng = NgrokTunnelConfig::default();
|
|
assert!(ng.auth_token.is_empty());
|
|
assert!(ng.domain.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_custom() {
|
|
let c = CustomTunnelConfig {
|
|
start_command: "bore local {port}".to_string(),
|
|
health_url: Some("http://localhost:8080/health".to_string()),
|
|
url_pattern: Some("https://bore.pub".to_string()),
|
|
};
|
|
assert_eq!(c.start_command, "bore local {port}");
|
|
assert!(c.health_url.is_some());
|
|
assert!(c.url_pattern.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_custom_defaults() {
|
|
let c = CustomTunnelConfig::default();
|
|
assert!(c.start_command.is_empty());
|
|
assert!(c.health_url.is_none());
|
|
assert!(c.url_pattern.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn cloudflare_config_defaults() {
|
|
let cf = CloudflareTunnelConfig::default();
|
|
assert!(cf.token.is_empty());
|
|
}
|
|
}
|