use crate::doctor::checks::{
DEFAULT_CONTEXT_NAME, resolve_active_provider_context, resolve_secret,
};
use crate::doctor::{CheckResult, CheckStatus, DiagnosticCheck, DiagnosticContext};
use async_trait::async_trait;
use devboy_core::ContextConfig;
use secrecy::ExposeSecret;
use serde_json::json;
pub struct GitHubTokenCheck;
pub struct GitLabTokenCheck;
pub struct ClickUpTokenCheck;
pub struct JiraTokenCheck;
pub struct ConfluenceTokenCheck;
pub struct SlackTokenCheck;
struct CredentialSpec {
provider: &'static str,
label: &'static str,
help_key: &'static str,
format_hint: &'static str,
validator: fn(&str) -> TokenFormat,
}
enum TokenFormat {
Recognized,
Suspicious(&'static str),
}
impl CredentialSpec {
fn build_result(
&self,
check: &dyn DiagnosticCheck,
ctx: &DiagnosticContext,
configured: bool,
) -> CheckResult {
let Some(config) = &ctx.config else {
return skipped(check, "Skipped because config could not be loaded");
};
let Some(active) = resolve_active_provider_context(config) else {
return skipped(check, "Skipped because no active context could be resolved");
};
if !configured {
return skipped(
check,
&format!(
"Skipped because {} is not configured in context '{}'",
self.label, active.name
),
);
}
match resolve_secret(ctx, Some(&active.name), self.provider) {
Ok(Some(secret)) => {
let format = (self.validator)(secret.value.expose_secret());
let status = match format {
TokenFormat::Recognized => CheckStatus::Pass,
TokenFormat::Suspicious(_) => CheckStatus::Warning,
};
let message = match format {
TokenFormat::Recognized => format!("{} token found", self.label),
TokenFormat::Suspicious(reason) => {
format!(
"{} token found but format looks unusual ({reason})",
self.label
)
}
};
CheckResult {
id: check.id().to_string(),
category: check.category().to_string(),
name: check.name().to_string(),
status,
message,
details: ctx.verbose.then(|| {
json!({
"provider": self.provider,
"context": active.name,
"token_key": secret.key,
"token_source": secret.source,
"format_hint": self.format_hint,
})
}),
fix_command: None,
fix_url: None,
}
}
Ok(None) => {
let secret_key = if active.name == DEFAULT_CONTEXT_NAME {
self.help_key.to_string()
} else {
format!("contexts.{}.{}", active.name, self.help_key)
};
CheckResult {
id: check.id().to_string(),
category: check.category().to_string(),
name: check.name().to_string(),
status: CheckStatus::Error,
message: format!("{} token missing", self.label),
details: ctx.verbose.then(|| {
json!({
"provider": self.provider,
"context": active.name,
"expected_secret_key": secret_key,
"token_source_order": ["context", "global"],
})
}),
fix_command: Some(format!("devboy config set-secret {secret_key} <TOKEN>")),
fix_url: None,
}
}
Err(error) => CheckResult {
id: check.id().to_string(),
category: check.category().to_string(),
name: check.name().to_string(),
status: CheckStatus::Error,
message: format!("Could not read {} token: {error}", self.label),
details: ctx.verbose.then(|| {
json!({
"provider": self.provider,
"context": active.name,
"error": error,
})
}),
fix_command: None,
fix_url: None,
},
}
}
}
fn skipped(check: &dyn DiagnosticCheck, message: &str) -> CheckResult {
CheckResult {
id: check.id().to_string(),
category: check.category().to_string(),
name: check.name().to_string(),
status: CheckStatus::Skipped,
message: message.to_string(),
details: None,
fix_command: None,
fix_url: None,
}
}
fn github_configured(context: &ContextConfig) -> bool {
context.github.is_some()
}
fn gitlab_configured(context: &ContextConfig) -> bool {
context.gitlab.is_some()
}
fn clickup_configured(context: &ContextConfig) -> bool {
context.clickup.is_some()
}
fn jira_configured(context: &ContextConfig) -> bool {
context.jira.is_some()
}
fn slack_configured(context: &ContextConfig) -> bool {
context.slack.is_some()
}
fn confluence_configured(context: &ContextConfig) -> bool {
context.confluence.is_some()
}
fn validate_github_token(token: &str) -> TokenFormat {
let token = token.trim();
if token.starts_with("ghp_")
|| token.starts_with("github_pat_")
|| token.starts_with("gho_")
|| token.starts_with("ghu_")
|| token.starts_with("ghs_")
|| token.starts_with("ghr_")
{
TokenFormat::Recognized
} else if token.len() >= 20 {
TokenFormat::Suspicious("expected a GitHub PAT prefix like ghp_ or github_pat_")
} else {
TokenFormat::Suspicious("token is shorter than a typical GitHub PAT")
}
}
fn validate_gitlab_token(token: &str) -> TokenFormat {
let token = token.trim();
if token.starts_with("glpat-") {
TokenFormat::Recognized
} else if token.len() >= 20 {
TokenFormat::Suspicious("expected a GitLab personal access token prefix like glpat-")
} else {
TokenFormat::Suspicious("token is shorter than a typical GitLab personal access token")
}
}
fn validate_clickup_token(token: &str) -> TokenFormat {
let token = token.trim();
if token.len() >= 24 {
TokenFormat::Recognized
} else {
TokenFormat::Suspicious("token is shorter than a typical ClickUp API token")
}
}
fn validate_jira_token(token: &str) -> TokenFormat {
let token = token.trim();
if token.contains(':') || token.len() >= 16 {
TokenFormat::Recognized
} else {
TokenFormat::Suspicious("token is shorter than a typical Jira API token or credential pair")
}
}
fn validate_slack_token(token: &str) -> TokenFormat {
let token = token.trim();
if token.starts_with("xoxb-") {
TokenFormat::Recognized
} else if token.starts_with("xox") {
TokenFormat::Suspicious("expected a Slack bot token prefix like xoxb-")
} else if token.len() >= 20 {
TokenFormat::Suspicious("token does not look like a Slack OAuth token")
} else {
TokenFormat::Suspicious("token is shorter than a typical Slack bot token")
}
}
fn validate_confluence_credential(token: &str) -> TokenFormat {
let token = token.trim();
if token.len() >= 12 {
TokenFormat::Recognized
} else {
TokenFormat::Suspicious("credential is shorter than a typical Confluence PAT or password")
}
}
#[async_trait]
impl DiagnosticCheck for GitHubTokenCheck {
fn id(&self) -> &'static str {
"credentials.github"
}
fn name(&self) -> &'static str {
"GitHub token available"
}
fn category(&self) -> &'static str {
"Credentials"
}
async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
let spec = CredentialSpec {
provider: "github",
label: "GitHub",
help_key: "github.token",
format_hint: "GitHub personal access token",
validator: validate_github_token,
};
let configured = ctx
.config
.as_ref()
.and_then(resolve_active_provider_context)
.is_some_and(|active| github_configured(&active.config));
spec.build_result(self, ctx, configured)
}
}
#[async_trait]
impl DiagnosticCheck for GitLabTokenCheck {
fn id(&self) -> &'static str {
"credentials.gitlab"
}
fn name(&self) -> &'static str {
"GitLab token available"
}
fn category(&self) -> &'static str {
"Credentials"
}
async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
let spec = CredentialSpec {
provider: "gitlab",
label: "GitLab",
help_key: "gitlab.token",
format_hint: "GitLab personal access token",
validator: validate_gitlab_token,
};
let configured = ctx
.config
.as_ref()
.and_then(resolve_active_provider_context)
.is_some_and(|active| gitlab_configured(&active.config));
spec.build_result(self, ctx, configured)
}
}
#[async_trait]
impl DiagnosticCheck for ClickUpTokenCheck {
fn id(&self) -> &'static str {
"credentials.clickup"
}
fn name(&self) -> &'static str {
"ClickUp token available"
}
fn category(&self) -> &'static str {
"Credentials"
}
async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
let spec = CredentialSpec {
provider: "clickup",
label: "ClickUp",
help_key: "clickup.token",
format_hint: "ClickUp API token",
validator: validate_clickup_token,
};
let configured = ctx
.config
.as_ref()
.and_then(resolve_active_provider_context)
.is_some_and(|active| clickup_configured(&active.config));
spec.build_result(self, ctx, configured)
}
}
#[async_trait]
impl DiagnosticCheck for JiraTokenCheck {
fn id(&self) -> &'static str {
"credentials.jira"
}
fn name(&self) -> &'static str {
"Jira token available"
}
fn category(&self) -> &'static str {
"Credentials"
}
async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
let spec = CredentialSpec {
provider: "jira",
label: "Jira",
help_key: "jira.token",
format_hint: "Jira API token or self-hosted user:password pair",
validator: validate_jira_token,
};
let configured = ctx
.config
.as_ref()
.and_then(resolve_active_provider_context)
.is_some_and(|active| jira_configured(&active.config));
spec.build_result(self, ctx, configured)
}
}
#[async_trait]
impl DiagnosticCheck for SlackTokenCheck {
fn id(&self) -> &'static str {
"credentials.slack"
}
fn name(&self) -> &'static str {
"Slack bot token available"
}
fn category(&self) -> &'static str {
"Credentials"
}
async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
let spec = CredentialSpec {
provider: "slack",
label: "Slack",
help_key: "slack.token",
format_hint: "Slack bot token (xoxb-...)",
validator: validate_slack_token,
};
let configured = ctx
.config
.as_ref()
.and_then(resolve_active_provider_context)
.is_some_and(|active| slack_configured(&active.config));
spec.build_result(self, ctx, configured)
}
}
#[async_trait]
impl DiagnosticCheck for ConfluenceTokenCheck {
fn id(&self) -> &'static str {
"credentials.confluence"
}
fn name(&self) -> &'static str {
"Confluence credential available"
}
fn category(&self) -> &'static str {
"Credentials"
}
async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
let spec = CredentialSpec {
provider: "confluence",
label: "Confluence",
help_key: "confluence.token",
format_hint: "Confluence personal access token or password",
validator: validate_confluence_credential,
};
let configured = ctx
.config
.as_ref()
.and_then(resolve_active_provider_context)
.is_some_and(|active| confluence_configured(&active.config));
spec.build_result(self, ctx, configured)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctor::DiagnosticContext;
use devboy_core::{
ClickUpConfig, Config, ConfluenceConfig, ContextConfig, Error, GitHubConfig, GitLabConfig,
JiraConfig,
};
use devboy_storage::{CredentialStore, MemoryStore};
use secrecy::SecretString;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug)]
struct FailingStore;
impl CredentialStore for FailingStore {
fn store(&self, _key: &str, _value: &SecretString) -> devboy_core::Result<()> {
Err(Error::Storage("store failed".to_string()))
}
fn get(&self, _key: &str) -> devboy_core::Result<Option<SecretString>> {
Err(Error::Storage("credential backend unavailable".to_string()))
}
fn delete(&self, _key: &str) -> devboy_core::Result<()> {
Err(Error::Storage("delete failed".to_string()))
}
}
fn context_with_store(
store: Arc<dyn CredentialStore>,
context: ContextConfig,
) -> DiagnosticContext {
let mut contexts = BTreeMap::new();
contexts.insert("workspace".to_string(), context);
DiagnosticContext {
config: Some(Config {
contexts,
active_context: Some("workspace".to_string()),
..Default::default()
}),
config_path: Some(PathBuf::from("config.toml")),
config_exists: true,
config_source: "test",
config_path_error: None,
config_load_error: None,
credential_store: store,
verbose: true,
}
}
fn github_context() -> ContextConfig {
ContextConfig {
github: Some(GitHubConfig {
owner: "owner".to_string(),
repo: "repo".to_string(),
base_url: None,
}),
..Default::default()
}
}
fn confluence_context() -> ContextConfig {
ContextConfig {
confluence: Some(ConfluenceConfig {
base_url: "https://wiki.example.com".to_string(),
api_version: Some("v1".to_string()),
username: Some("dev@example.com".to_string()),
space_key: Some("ENG".to_string()),
}),
..Default::default()
}
}
#[tokio::test]
async fn github_token_check_prefers_context_secret() {
let store = Arc::new(MemoryStore::with_credentials([
(
"contexts.workspace.github.token".to_string(),
"ghp_context_token_1234567890".to_string(),
),
(
"github.token".to_string(),
"ghp_global_token_1234567890".to_string(),
),
]));
let ctx = context_with_store(store, github_context());
let result = GitHubTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Pass);
let details = result.details.unwrap();
assert_eq!(details["token_source"], "context");
assert_eq!(details["token_key"], "contexts.workspace.github.token");
}
#[tokio::test]
async fn github_token_check_errors_when_missing() {
let ctx = context_with_store(Arc::new(MemoryStore::new()), github_context());
let result = GitHubTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Error);
assert_eq!(
result.fix_command.as_deref(),
Some("devboy config set-secret contexts.workspace.github.token <TOKEN>")
);
}
#[tokio::test]
async fn github_token_check_warns_for_suspicious_global_token() {
let store = Arc::new(MemoryStore::with_credentials([(
"github.token".to_string(),
"token-without-known-prefix-but-long".to_string(),
)]));
let ctx = context_with_store(store, github_context());
let result = GitHubTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Warning);
assert_eq!(result.details.unwrap()["token_source"], "global");
}
#[tokio::test]
async fn gitlab_token_check_passes_for_recognized_token() {
let store = Arc::new(MemoryStore::with_credentials([(
"contexts.workspace.gitlab.token".to_string(),
"glpat-12345678901234567890".to_string(),
)]));
let ctx = context_with_store(
store,
ContextConfig {
gitlab: Some(GitLabConfig {
url: "https://gitlab.example.com".to_string(),
project_id: "group/project".to_string(),
}),
..Default::default()
},
);
let result = GitLabTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.details.unwrap()["provider"], "gitlab");
}
#[tokio::test]
async fn clickup_token_check_warns_for_short_token() {
let store = Arc::new(MemoryStore::with_credentials([(
"contexts.workspace.clickup.token".to_string(),
"short-token".to_string(),
)]));
let ctx = context_with_store(
store,
ContextConfig {
clickup: Some(ClickUpConfig {
list_id: "123".to_string(),
team_id: None,
}),
..Default::default()
},
);
let result = ClickUpTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Warning);
assert!(result.message.contains("format looks unusual"));
}
#[tokio::test]
async fn jira_token_check_passes_for_credential_pair() {
let store = Arc::new(MemoryStore::with_credentials([(
"contexts.workspace.jira.token".to_string(),
"user:token".to_string(),
)]));
let ctx = context_with_store(
store,
ContextConfig {
jira: Some(JiraConfig {
url: "https://jira.example.com".to_string(),
project_key: "PROJ".to_string(),
email: "jira@example.com".to_string(),
}),
..Default::default()
},
);
let result = JiraTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.details.unwrap()["provider"], "jira");
}
#[tokio::test]
async fn provider_token_checks_skip_when_provider_not_configured() {
let ctx = context_with_store(Arc::new(MemoryStore::new()), ContextConfig::default());
let result = GitLabTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Skipped);
assert!(result.message.contains("not configured"));
}
#[tokio::test]
async fn provider_token_checks_skip_when_active_context_is_missing() {
let ctx = DiagnosticContext {
config: Some(Config::default()),
config_path: Some(PathBuf::from("config.toml")),
config_exists: true,
config_source: "test",
config_path_error: None,
config_load_error: None,
credential_store: Arc::new(MemoryStore::new()),
verbose: true,
};
let result = GitHubTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Skipped);
assert!(result.message.contains("no active context"));
}
#[tokio::test]
async fn provider_token_checks_error_when_store_fails() {
let ctx = context_with_store(Arc::new(FailingStore), github_context());
let result = GitHubTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Error);
assert!(result.message.contains("Could not read GitHub token"));
assert_eq!(
result.details.unwrap()["error"],
"Storage error: credential backend unavailable"
);
}
#[tokio::test]
async fn confluence_token_check_passes_for_present_credential() {
let store = Arc::new(MemoryStore::with_credentials([(
"contexts.workspace.confluence.token".to_string(),
"pat_confluence_secret".to_string(),
)]));
let ctx = context_with_store(store, confluence_context());
let result = ConfluenceTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.details.unwrap()["provider"], "confluence");
}
#[tokio::test]
async fn confluence_token_check_skips_when_not_configured() {
let ctx = context_with_store(Arc::new(MemoryStore::new()), ContextConfig::default());
let result = ConfluenceTokenCheck.run(&ctx).await;
assert_eq!(result.status, CheckStatus::Skipped);
assert!(result.message.contains("not configured"));
}
#[test]
fn confluence_credential_validator_flags_short_values() {
assert!(matches!(
validate_confluence_credential("short"),
TokenFormat::Suspicious(_)
));
assert!(matches!(
validate_confluence_credential("pat_confluence_secret"),
TokenFormat::Recognized
));
}
#[test]
fn token_format_validators_detect_suspicious_values() {
assert!(matches!(
validate_github_token("short"),
TokenFormat::Suspicious(_)
));
assert!(matches!(
validate_gitlab_token("token-without-prefix-but-long-enough"),
TokenFormat::Suspicious(_)
));
assert!(matches!(
validate_clickup_token("123"),
TokenFormat::Suspicious(_)
));
assert!(matches!(
validate_jira_token("token"),
TokenFormat::Suspicious(_)
));
assert!(matches!(
validate_github_token("ghp_12345678901234567890"),
TokenFormat::Recognized
));
assert!(matches!(
validate_gitlab_token("glpat-12345678901234567890"),
TokenFormat::Recognized
));
assert!(matches!(
validate_clickup_token("123456789012345678901234"),
TokenFormat::Recognized
));
assert!(matches!(
validate_jira_token("1234567890123456"),
TokenFormat::Recognized
));
}
}