use ironflow_store::models::{Run, RunActor, TriggerKind};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const ID_PREFIX_LEN: usize = 8;
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CreatedByKind {
User,
ApiKey,
System,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreatedBy {
pub kind: CreatedByKind,
pub id: Option<Uuid>,
pub label: String,
}
fn short_id(id: Uuid) -> String {
let text = id.to_string();
text[..ID_PREFIX_LEN.min(text.len())].to_string()
}
fn trigger_label(trigger: &TriggerKind) -> String {
match trigger {
TriggerKind::Manual => "manual".to_string(),
TriggerKind::Api => "api".to_string(),
TriggerKind::Workflow => "workflow".to_string(),
TriggerKind::Retry { .. } => "retry".to_string(),
TriggerKind::Webhook { path } => path.clone(),
TriggerKind::Cron { schedule } => schedule.clone(),
TriggerKind::Nats { subject } => format!("nats:{subject}"),
TriggerKind::RunEvent { event_kind, .. } => format!("event:{event_kind}"),
TriggerKind::Polling { probe } => format!("polling:{probe}"),
}
}
impl From<&Run> for CreatedBy {
fn from(run: &Run) -> Self {
match run.created_by {
None => CreatedBy {
kind: CreatedByKind::System,
id: None,
label: trigger_label(&run.trigger),
},
Some(RunActor::User { user_id }) => CreatedBy {
kind: CreatedByKind::User,
id: Some(user_id),
label: run
.created_by_label
.clone()
.unwrap_or_else(|| format!("user {}", short_id(user_id))),
},
Some(RunActor::ApiKey { api_key_id, .. }) => CreatedBy {
kind: CreatedByKind::ApiKey,
id: Some(api_key_id),
label: run
.created_by_label
.clone()
.unwrap_or_else(|| format!("key {}", short_id(api_key_id))),
},
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use ironflow_store::api_key_store::ApiKeyStore;
use ironflow_store::memory::InMemoryStore;
use ironflow_store::models::{ApiKeyScope, NewApiKey, NewRun, NewUser};
use ironflow_store::store::RunStore;
use ironflow_store::user_store::UserStore;
use serde_json::json;
use super::*;
async fn run_with(
store: &InMemoryStore,
trigger: TriggerKind,
created_by: Option<RunActor>,
) -> Run {
store
.create_run(NewRun {
workflow_name: "deploy".to_string(),
trigger,
payload: json!({}),
max_retries: 0,
handler_version: None,
labels: HashMap::new(),
scheduled_at: None,
created_by,
idempotency_key: None,
max_cost_usd: None,
})
.await
.expect("create run")
.into_run()
}
async fn seed_user(store: &InMemoryStore, username: &str) -> Uuid {
store
.create_user(NewUser {
email: format!("{username}@example.com"),
username: username.to_string(),
password_hash: "hash".to_string(),
is_admin: Some(false),
})
.await
.expect("create user")
.id
}
async fn seed_api_key(store: &InMemoryStore, user_id: Uuid, name: &str) -> Uuid {
store
.create_api_key(NewApiKey {
user_id,
name: name.to_string(),
key_hash: "hash".to_string(),
key_prefix: "irfl_0000".to_string(),
scopes: vec![ApiKeyScope::RunsWrite],
expires_at: None,
rate_limit_override: None,
})
.await
.expect("create api key")
.id
}
#[tokio::test]
async fn user_actor_uses_the_resolved_username() {
let store = InMemoryStore::new();
let user_id = seed_user(&store, "alice").await;
let run = run_with(&store, TriggerKind::Api, Some(RunActor::User { user_id })).await;
let created_by = CreatedBy::from(&run);
assert_eq!(created_by.kind, CreatedByKind::User);
assert_eq!(created_by.id, Some(user_id));
assert_eq!(created_by.label, "alice");
}
#[tokio::test]
async fn user_actor_falls_back_to_a_short_id() {
let store = InMemoryStore::new();
let user_id = Uuid::now_v7();
let run = run_with(&store, TriggerKind::Api, Some(RunActor::User { user_id })).await;
let created_by = CreatedBy::from(&run);
assert_eq!(created_by.kind, CreatedByKind::User);
assert_eq!(created_by.id, Some(user_id));
assert_eq!(created_by.label, format!("user {}", short_id(user_id)));
}
#[tokio::test]
async fn api_key_actor_exposes_the_key_id_and_a_combined_label() {
let store = InMemoryStore::new();
let user_id = seed_user(&store, "alice").await;
let api_key_id = seed_api_key(&store, user_id, "ci-deploy").await;
let run = run_with(
&store,
TriggerKind::Api,
Some(RunActor::ApiKey {
api_key_id,
user_id,
}),
)
.await;
let created_by = CreatedBy::from(&run);
assert_eq!(created_by.kind, CreatedByKind::ApiKey);
assert_eq!(created_by.id, Some(api_key_id));
assert_eq!(created_by.label, "ci-deploy (alice)");
}
#[tokio::test]
async fn api_key_actor_falls_back_to_a_short_id() {
let store = InMemoryStore::new();
let api_key_id = Uuid::now_v7();
let run = run_with(
&store,
TriggerKind::Api,
Some(RunActor::ApiKey {
api_key_id,
user_id: Uuid::now_v7(),
}),
)
.await;
let created_by = CreatedBy::from(&run);
assert_eq!(created_by.kind, CreatedByKind::ApiKey);
assert_eq!(created_by.label, format!("key {}", short_id(api_key_id)));
}
#[tokio::test]
async fn system_label_is_derived_from_every_trigger() {
let store = InMemoryStore::new();
let cases = [
(TriggerKind::Manual, "manual"),
(TriggerKind::Api, "api"),
(TriggerKind::Workflow, "workflow"),
(
TriggerKind::Retry {
parent_run_id: Uuid::now_v7(),
},
"retry",
),
(
TriggerKind::Webhook {
path: "/hooks/github".to_string(),
},
"/hooks/github",
),
(
TriggerKind::Cron {
schedule: "0 */5 * * * *".to_string(),
},
"0 */5 * * * *",
),
(
TriggerKind::Nats {
subject: "orders.created".to_string(),
},
"nats:orders.created",
),
(
TriggerKind::RunEvent {
source_run_id: Uuid::now_v7(),
event_kind: "completed".to_string(),
},
"event:completed",
),
(
TriggerKind::Polling {
probe: "http".to_string(),
},
"polling:http",
),
];
for (trigger, expected) in cases {
let run = run_with(&store, trigger, None).await;
let created_by = CreatedBy::from(&run);
assert_eq!(created_by.kind, CreatedByKind::System);
assert_eq!(created_by.id, None);
assert_eq!(created_by.label, expected);
}
}
#[tokio::test]
async fn a_pre_migration_run_reports_a_system_author() {
let store = InMemoryStore::new();
let run = run_with(&store, TriggerKind::Manual, None).await;
let created_by = CreatedBy::from(&run);
assert_eq!(created_by.kind, CreatedByKind::System);
assert_eq!(created_by.id, None);
assert_eq!(created_by.label, "manual");
}
#[tokio::test]
async fn label_is_never_empty() {
let store = InMemoryStore::new();
let run = run_with(
&store,
TriggerKind::Webhook {
path: "/h".to_string(),
},
None,
)
.await;
assert!(!CreatedBy::from(&run).label.is_empty());
}
#[test]
fn short_id_keeps_eight_characters() {
let id = Uuid::now_v7();
assert_eq!(short_id(id).len(), ID_PREFIX_LEN);
assert!(id.to_string().starts_with(&short_id(id)));
}
#[test]
fn serde_uses_snake_case_kinds() {
let created_by = CreatedBy {
kind: CreatedByKind::ApiKey,
id: Some(Uuid::now_v7()),
label: "ci (alice)".to_string(),
};
let json = serde_json::to_value(&created_by).expect("serialize");
assert_eq!(json["kind"], "api_key");
let back: CreatedBy = serde_json::from_value(json).expect("deserialize");
assert_eq!(back, created_by);
}
}