use std::fs;
use std::path::PathBuf;
use thiserror::Error;
use super::paths;
use crate::oauth::token::Token;
#[derive(Debug, Error)]
pub enum TokenStoreError {
#[error("Could not determine config directory: {0}")]
Path(#[from] paths::PathError),
#[error("Failed to create directory: {0}")]
CreateDir(#[from] std::io::Error),
#[error("Failed to serialize token: {0}")]
Serialize(#[from] serde_json::Error),
#[error("Token not found")]
NotFound,
}
pub struct TokenStore {
path: PathBuf,
}
impl TokenStore {
pub fn new() -> Result<Self, TokenStoreError> {
let path = paths::token_file()?;
Ok(Self { path })
}
pub fn save(&self, token: &Token) -> Result<(), TokenStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(token)?;
fs::write(&self.path, json)?;
self.set_secure_permissions();
Ok(())
}
#[cfg(unix)]
fn set_secure_permissions(&self) {
use std::os::unix::fs::PermissionsExt;
use tracing::warn;
if let Ok(metadata) = fs::metadata(&self.path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600); if let Err(e) = fs::set_permissions(&self.path, perms) {
warn!(path = %self.path.display(), error = %e, "Failed to set secure permissions on token file");
}
}
}
#[cfg(not(unix))]
fn set_secure_permissions(&self) {
}
pub fn load(&self) -> Result<Token, TokenStoreError> {
if !self.path.exists() {
return Err(TokenStoreError::NotFound);
}
let json = fs::read_to_string(&self.path)?;
let token = serde_json::from_str(&json)?;
Ok(token)
}
pub fn delete(&self) -> Result<(), TokenStoreError> {
if self.path.exists() {
fs::remove_file(&self.path)?;
}
Ok(())
}
pub fn exists(&self) -> bool {
self.path.exists()
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::token::SpotifyTokenResponse;
use std::env;
const TOKEN_FILE: &str = "token.json";
fn temp_store() -> TokenStore {
let temp_dir = env::temp_dir().join(format!("spotify-cli-test-{}", rand::random::<u64>()));
TokenStore {
path: temp_dir.join(TOKEN_FILE),
}
}
fn make_token() -> Token {
Token::from_response(SpotifyTokenResponse {
access_token: "test_access".to_string(),
token_type: "Bearer".to_string(),
scope: "user-read-playback-state".to_string(),
expires_in: 3600,
refresh_token: Some("test_refresh".to_string()),
})
}
#[test]
fn save_and_load_token() {
let store = temp_store();
let token = make_token();
store.save(&token).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.access_token, token.access_token);
assert_eq!(loaded.refresh_token, token.refresh_token);
store.delete().unwrap();
}
#[test]
fn load_nonexistent_returns_not_found() {
let store = temp_store();
let result = store.load();
assert!(matches!(result, Err(TokenStoreError::NotFound)));
}
#[test]
fn exists_returns_false_when_no_token() {
let store = temp_store();
assert!(!store.exists());
}
#[test]
fn exists_returns_true_after_save() {
let store = temp_store();
let token = make_token();
store.save(&token).unwrap();
assert!(store.exists());
store.delete().unwrap();
}
#[cfg(unix)]
#[test]
fn save_sets_secure_permissions() {
use std::os::unix::fs::PermissionsExt;
let store = temp_store();
let token = make_token();
store.save(&token).unwrap();
let metadata = fs::metadata(store.path()).unwrap();
let mode = metadata.permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"Token file should have 0600 permissions"
);
store.delete().unwrap();
}
#[test]
fn delete_nonexistent_is_ok() {
let store = temp_store();
let result = store.delete();
assert!(result.is_ok());
}
#[test]
fn path_returns_correct_path() {
let store = temp_store();
assert!(store.path().to_str().unwrap().contains("token.json"));
}
#[test]
fn token_store_error_display() {
let err = TokenStoreError::NotFound;
let display = format!("{}", err);
assert!(display.contains("not found"));
}
}