feat(token): add stored token support with resolve_token and token_source
Introduce a centralized token resolution system that supports both
environment variables and config-file-stored tokens with clear priority
(env var wins). This enables cron-based sync which runs in minimal
shell environments without env vars.
Core changes:
- GitLabConfig gains optional `token` field and `resolve_token()` method
that checks env var first, then config file, returning trimmed values
- `token_source()` returns human-readable provenance ("environment variable"
or "config file") for diagnostics
- `ensure_config_permissions()` enforces 0600 on config files containing
tokens (Unix only, no-op on other platforms)
New CLI commands:
- `lore token set [--token VALUE]` — validates against GitLab API, stores
in config, enforces file permissions. Supports flag, stdin pipe, or
interactive entry.
- `lore token show [--unmask]` — displays masked token with source label
Consumers updated to use resolve_token():
- auth_test: removes manual env var lookup
- doctor: shows token source in health check output
- ingest: uses centralized resolution
Includes 10 unit tests for resolve/source logic and 2 for mask_token.
This commit is contained in:
@@ -12,6 +12,44 @@ pub struct GitLabConfig {
|
||||
|
||||
#[serde(rename = "tokenEnvVar", default = "default_token_env_var")]
|
||||
pub token_env_var: String,
|
||||
|
||||
/// Optional stored token (env var takes priority when both are set).
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl GitLabConfig {
|
||||
/// Resolve token with priority: env var > config file.
|
||||
pub fn resolve_token(&self) -> Result<String> {
|
||||
if let Ok(val) = std::env::var(&self.token_env_var)
|
||||
&& !val.trim().is_empty()
|
||||
{
|
||||
return Ok(val.trim().to_string());
|
||||
}
|
||||
if let Some(ref t) = self.token
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Ok(t.trim().to_string());
|
||||
}
|
||||
Err(LoreError::TokenNotSet {
|
||||
env_var: self.token_env_var.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a human-readable label for where the token was found, or `None`.
|
||||
pub fn token_source(&self) -> Option<&'static str> {
|
||||
if let Ok(val) = std::env::var(&self.token_env_var)
|
||||
&& !val.trim().is_empty()
|
||||
{
|
||||
return Some("environment variable");
|
||||
}
|
||||
if let Some(ref t) = self.token
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Some("config file");
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn default_token_env_var() -> String {
|
||||
@@ -531,6 +569,7 @@ mod tests {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
@@ -554,6 +593,7 @@ mod tests {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
@@ -574,6 +614,7 @@ mod tests {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
@@ -786,4 +827,84 @@ mod tests {
|
||||
};
|
||||
validate_scoring(&scoring).unwrap();
|
||||
}
|
||||
|
||||
// ── token_source / resolve_token ────────────────────────────────
|
||||
|
||||
/// Build a `GitLabConfig` that reads from the given unique env var name
|
||||
/// so parallel tests never collide.
|
||||
fn gitlab_cfg_with_env(env_var: &str, token: Option<&str>) -> GitLabConfig {
|
||||
GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: env_var.to_string(),
|
||||
token: token.map(ToString::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_env_wins_over_config() {
|
||||
const VAR: &str = "LORE_TEST_TS_ENV_WINS";
|
||||
// SAFETY: unique var name, no other code reads it.
|
||||
unsafe { std::env::set_var(VAR, "env-tok") };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
|
||||
assert_eq!(cfg.token_source(), Some("environment variable"));
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_falls_back_to_config() {
|
||||
const VAR: &str = "LORE_TEST_TS_FALLBACK";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
|
||||
assert_eq!(cfg.token_source(), Some("config file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_none_when_both_absent() {
|
||||
const VAR: &str = "LORE_TEST_TS_NONE";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, None);
|
||||
assert_eq!(cfg.token_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_ignores_whitespace_only_env() {
|
||||
const VAR: &str = "LORE_TEST_TS_WS_ENV";
|
||||
unsafe { std::env::set_var(VAR, " ") };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("real"));
|
||||
assert_eq!(cfg.token_source(), Some("config file"));
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_ignores_whitespace_only_config() {
|
||||
const VAR: &str = "LORE_TEST_TS_WS_CFG";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some(" \t "));
|
||||
assert_eq!(cfg.token_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_token_env_wins_over_config() {
|
||||
const VAR: &str = "LORE_TEST_RT_ENV_WINS";
|
||||
unsafe { std::env::set_var(VAR, " env-tok ") };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
|
||||
assert_eq!(cfg.resolve_token().unwrap(), "env-tok");
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_token_config_fallback() {
|
||||
const VAR: &str = "LORE_TEST_RT_FALLBACK";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some(" config-tok "));
|
||||
assert_eq!(cfg.resolve_token().unwrap(), "config-tok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_token_err_when_both_absent() {
|
||||
const VAR: &str = "LORE_TEST_RT_NONE";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, None);
|
||||
assert!(cfg.resolve_token().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user