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:
teernisse
2026-02-18 16:27:35 -05:00
parent a4df8e5444
commit 30ed02c694
7 changed files with 303 additions and 23 deletions

View File

@@ -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());
}
}