use chrono::Utc;
use uuid::Uuid;
use crate::api_key_store::ApiKeyStore;
use crate::entities::{ApiKey, ApiKeyUpdate, NewApiKey};
use crate::error::StoreError;
use crate::store::StoreFuture;
use super::InMemoryStore;
impl ApiKeyStore for InMemoryStore {
fn create_api_key(&self, req: NewApiKey) -> StoreFuture<'_, ApiKey> {
Box::pin(async move {
let mut state = self.state.write().await;
let now = Utc::now();
let id = Uuid::now_v7();
let key = ApiKey {
id,
user_id: req.user_id,
name: req.name,
key_hash: req.key_hash,
key_prefix: req.key_prefix,
scopes: req.scopes,
is_active: true,
expires_at: req.expires_at,
last_used_at: None,
created_at: now,
updated_at: now,
};
state.api_keys.insert(id, key.clone());
Ok(key)
})
}
fn find_api_key_by_prefix(&self, prefix: &str) -> StoreFuture<'_, Option<ApiKey>> {
let prefix = prefix.to_string();
Box::pin(async move {
let state = self.state.read().await;
Ok(state
.api_keys
.values()
.find(|k| k.key_prefix == prefix && k.is_active)
.cloned())
})
}
fn find_api_key_by_id(&self, id: Uuid) -> StoreFuture<'_, Option<ApiKey>> {
Box::pin(async move {
let state = self.state.read().await;
Ok(state.api_keys.get(&id).cloned())
})
}
fn list_api_keys_by_user(&self, user_id: Uuid) -> StoreFuture<'_, Vec<ApiKey>> {
Box::pin(async move {
let state = self.state.read().await;
let keys: Vec<ApiKey> = state
.api_keys
.values()
.filter(|k| k.user_id == user_id)
.cloned()
.collect();
Ok(keys)
})
}
fn update_api_key(&self, id: Uuid, update: ApiKeyUpdate) -> StoreFuture<'_, ()> {
Box::pin(async move {
let mut state = self.state.write().await;
let key = state
.api_keys
.get_mut(&id)
.ok_or(StoreError::Database(format!("API key {id} not found")))?;
if let Some(name) = update.name {
key.name = name;
}
if let Some(scopes) = update.scopes {
key.scopes = scopes;
}
if let Some(is_active) = update.is_active {
key.is_active = is_active;
}
if let Some(expires_at) = update.expires_at {
key.expires_at = expires_at;
}
key.updated_at = Utc::now();
Ok(())
})
}
fn touch_api_key(&self, id: Uuid) -> StoreFuture<'_, ()> {
Box::pin(async move {
let mut state = self.state.write().await;
if let Some(key) = state.api_keys.get_mut(&id) {
key.last_used_at = Some(Utc::now());
}
Ok(())
})
}
fn delete_api_key(&self, id: Uuid) -> StoreFuture<'_, ()> {
Box::pin(async move {
let mut state = self.state.write().await;
state.api_keys.remove(&id);
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use crate::entities::ApiKeyScope;
use super::*;
#[tokio::test]
async fn create_api_key_returns_active_key() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let key = store
.create_api_key(NewApiKey {
user_id,
name: "production".to_string(),
key_hash: "bcrypt_hash".to_string(),
key_prefix: "sk_prod_abc123".to_string(),
scopes: vec![ApiKeyScope::WorkflowsRead],
expires_at: None,
})
.await
.unwrap();
assert_eq!(key.user_id, user_id);
assert_eq!(key.name, "production");
assert!(key.is_active);
assert!(key.last_used_at.is_none());
}
#[tokio::test]
async fn find_api_key_by_prefix_existing() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "test".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_test_xyz".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
let found = store
.find_api_key_by_prefix("sk_test_xyz")
.await
.unwrap()
.expect("key should exist");
assert_eq!(found.id, created.id);
}
#[tokio::test]
async fn find_api_key_by_prefix_missing_returns_none() {
let store = InMemoryStore::new();
let found = store
.find_api_key_by_prefix("sk_nonexistent")
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn find_api_key_by_prefix_inactive_returns_none() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "test".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_inactive".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
store
.update_api_key(
created.id,
ApiKeyUpdate {
name: None,
scopes: None,
is_active: Some(false),
expires_at: None,
},
)
.await
.unwrap();
let found = store.find_api_key_by_prefix("sk_inactive").await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn find_api_key_by_id_existing() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "test".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_test".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
let found = store
.find_api_key_by_id(created.id)
.await
.unwrap()
.expect("key should exist");
assert_eq!(found.id, created.id);
}
#[tokio::test]
async fn find_api_key_by_id_missing_returns_none() {
let store = InMemoryStore::new();
let found = store.find_api_key_by_id(Uuid::now_v7()).await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn list_api_keys_by_user_returns_only_user_keys() {
let store = InMemoryStore::new();
let user1 = Uuid::now_v7();
let user2 = Uuid::now_v7();
store
.create_api_key(NewApiKey {
user_id: user1,
name: "key1".to_string(),
key_hash: "hash1".to_string(),
key_prefix: "sk_1".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
store
.create_api_key(NewApiKey {
user_id: user1,
name: "key2".to_string(),
key_hash: "hash2".to_string(),
key_prefix: "sk_2".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
store
.create_api_key(NewApiKey {
user_id: user2,
name: "key3".to_string(),
key_hash: "hash3".to_string(),
key_prefix: "sk_3".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
let user1_keys = store.list_api_keys_by_user(user1).await.unwrap();
assert_eq!(user1_keys.len(), 2);
assert!(user1_keys.iter().all(|k| k.user_id == user1));
}
#[tokio::test]
async fn update_api_key_name() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "old-name".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_test".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
store
.update_api_key(
created.id,
ApiKeyUpdate {
name: Some("new-name".to_string()),
scopes: None,
is_active: None,
expires_at: None,
},
)
.await
.unwrap();
let updated = store.find_api_key_by_id(created.id).await.unwrap().unwrap();
assert_eq!(updated.name, "new-name");
}
#[tokio::test]
async fn update_api_key_scopes() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "test".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_test".to_string(),
scopes: vec![ApiKeyScope::WorkflowsRead],
expires_at: None,
})
.await
.unwrap();
let new_scopes = vec![ApiKeyScope::WorkflowsRead, ApiKeyScope::RunsWrite];
store
.update_api_key(
created.id,
ApiKeyUpdate {
name: None,
scopes: Some(new_scopes.clone()),
is_active: None,
expires_at: None,
},
)
.await
.unwrap();
let updated = store.find_api_key_by_id(created.id).await.unwrap().unwrap();
assert_eq!(updated.scopes, new_scopes);
}
#[tokio::test]
async fn touch_api_key_updates_last_used() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "test".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_test".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
assert!(created.last_used_at.is_none());
store.touch_api_key(created.id).await.unwrap();
let touched = store.find_api_key_by_id(created.id).await.unwrap().unwrap();
assert!(touched.last_used_at.is_some());
}
#[tokio::test]
async fn delete_api_key() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let created = store
.create_api_key(NewApiKey {
user_id,
name: "test".to_string(),
key_hash: "hash".to_string(),
key_prefix: "sk_test".to_string(),
scopes: vec![],
expires_at: None,
})
.await
.unwrap();
store.delete_api_key(created.id).await.unwrap();
let found = store.find_api_key_by_id(created.id).await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn delete_nonexistent_api_key_is_idempotent() {
let store = InMemoryStore::new();
let result = store.delete_api_key(Uuid::now_v7()).await;
assert!(result.is_ok());
}
}