Files
crank/crates/crank-registry/tests/integration/onboarding.rs
T

845 lines
25 KiB
Rust

#![allow(dead_code, unused_imports)]
use super::common::*;
use crank_core::{
AgentOperationBinding, AgentStatus, InvocationSource, InvocationStatus, OnboardingStepId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, ProductEventId, Workspace, WorkspaceId,
};
use crank_registry::{
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateWorkspaceRequest, OnboardingPresentationMilestone, PublishAgentRequest, PublishRequest,
RecordOnboardingMilestoneRequest,
};
use serde_json::json;
use sqlx::Row;
use time::{Duration, OffsetDateTime};
#[path = "onboarding_lifecycle.rs"]
mod onboarding_lifecycle;
fn truncate_to_micros(value: OffsetDateTime) -> OffsetDateTime {
value
.replace_nanosecond((value.nanosecond() / 1_000) * 1_000)
.unwrap()
}
#[tokio::test]
async fn migration_exposes_product_events_and_exact_invocation_key_scope() {
let database = TestDatabase::new().await;
let _registry = database.registry().await;
let pool = database.raw_pool().await;
let product_events = relation_exists(&pool, "product_events").await;
let product_rollups = relation_exists(&pool, "product_event_daily_rollups").await;
let key_scope = column_exists(&pool, "invocation_logs", "platform_api_key_id").await;
assert!(product_events, "V11 must own immutable local ProductEvents");
assert!(
product_rollups,
"V11 must preserve bounded UTC product-metric denominators"
);
assert!(
key_scope,
"successful MCP evidence must retain the exact non-secret key identity"
);
database.cleanup().await;
}
#[tokio::test]
async fn product_event_idempotency_is_scoped_to_workspace() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
create_workspace(&registry, "ws_onboarding_other", "onboarding-other").await;
insert_product_event(
&pool,
"pe_start_default",
"ws_default",
"onboarding_started",
"eligible:first-login",
)
.await
.unwrap();
let replay = insert_product_event(
&pool,
"pe_start_replay",
"ws_default",
"onboarding_started",
"eligible:first-login",
)
.await;
assert!(
replay.is_err(),
"same workspace/idempotency key must not double-count a milestone"
);
insert_product_event(
&pool,
"pe_start_other",
"ws_onboarding_other",
"onboarding_started",
"eligible:first-login",
)
.await
.expect("an independent workspace may use the same local idempotency key");
let count: i64 =
sqlx::query_scalar("select count(*) from product_events where idempotency_key = $1")
.bind("eligible:first-login")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 2);
database.cleanup().await;
}
#[tokio::test]
async fn exact_key_success_is_authoritative_and_revocation_regresses_progress() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
let (operation_id, agent_id, key_id) = published_path(&registry).await;
let log = test_invocation_log(
"log_onboarding_first_call",
&operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:15:00Z",
);
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.await,
crank_registry::InvocationHistoryWriteOutcome::Recorded
);
sqlx::query("update invocation_logs set platform_api_key_id = $1 where id = $2")
.bind(key_id.as_str())
.bind(log.id.as_str())
.execute(&pool)
.await
.unwrap();
assert!(
authoritative_first_call(&pool, "ws_default", agent_id.as_str(), key_id.as_str()).await,
"an active exact-scope key and real successful Agent tool invocation complete first value"
);
registry
.revoke_platform_api_key_for_agent(
&WorkspaceId::new("ws_default"),
&agent_id,
&key_id,
&OffsetDateTime::now_utc(),
)
.await
.unwrap();
assert!(
!authoritative_first_call(&pool, "ws_default", agent_id.as_str(), key_id.as_str()).await,
"revocation must return connection/onboarding to an actionable state"
);
database.cleanup().await;
}
#[tokio::test]
async fn invocation_key_scope_rejects_cross_workspace_evidence() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
assert!(
column_exists(&pool, "invocation_logs", "platform_api_key_id").await,
"cross-workspace rejection requires the exact-key evidence column"
);
let (operation_id, agent_id, _key_id) = published_path(&registry).await;
create_workspace(&registry, "ws_onboarding_foreign", "onboarding-foreign").await;
let foreign_key = PlatformApiKey {
id: PlatformApiKeyId::new("pk_onboarding_foreign"),
workspace_id: WorkspaceId::new("ws_onboarding_foreign"),
agent_id: None,
key_kind: PlatformApiKeyKind::McpClient,
name: "foreign-onboarding-key".to_owned(),
prefix: "crk_foreign".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &foreign_key,
secret_hash: "foreign-secret-hash",
})
.await
.unwrap();
let log = test_invocation_log(
"log_onboarding_cross_workspace",
&operation_id,
Some(agent_id),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.await,
crank_registry::InvocationHistoryWriteOutcome::Recorded
);
let poisoned = sqlx::query(
"update invocation_logs set platform_api_key_id = $1 where workspace_id = $2 and id = $3",
)
.bind(foreign_key.id.as_str())
.bind("ws_default")
.bind(log.id.as_str())
.execute(&pool)
.await;
assert!(
poisoned.is_err(),
"database constraints must prevent foreign-workspace key evidence"
);
database.cleanup().await;
}
#[tokio::test]
async fn projection_moves_from_empty_to_exact_terminal_success_and_regresses_on_revoke() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let empty = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!empty.completed);
assert_eq!(
empty.steps.iter().map(|step| step.id).collect::<Vec<_>>(),
OnboardingStepId::ORDERED
);
let (operation_id, agent_id, key_id) = published_path(&registry).await;
let mut test_log = test_invocation_log(
"log_onboarding_admin_test",
&operation_id,
None,
InvocationStatus::Ok,
20,
"2026-03-25T12:14:00Z",
);
test_log.source = InvocationSource::AdminTestRun;
registry
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
.await;
registry
.touch_platform_api_key(&workspace_id, &key_id, &OffsetDateTime::now_utc())
.await
.unwrap();
let mut pending = test_invocation_log(
"log_onboarding_approval_pending",
&operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
10,
"2026-03-25T12:15:00Z",
);
pending.platform_api_key_id = Some(key_id.clone());
pending.execution_stage = Some(crank_core::ExecutionStage::MandatoryPersistence);
pending.created_at = OffsetDateTime::now_utc();
registry
.create_invocation_log(CreateInvocationLogRequest { log: &pending })
.await;
let not_terminal = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!not_terminal.completed);
assert!(
!not_terminal
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed
);
let mut success = test_invocation_log(
"log_onboarding_terminal_success",
&operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
success.platform_api_key_id = Some(key_id.clone());
success.created_at = truncate_to_micros(OffsetDateTime::now_utc());
registry
.create_invocation_log(CreateInvocationLogRequest { log: &success })
.await;
let complete = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(complete.completed);
assert_eq!(complete.platform_api_key_id, Some(key_id.clone()));
assert_eq!(complete.first_call_log_id, Some(success.id));
assert_eq!(
complete.first_call_tool_name.as_deref(),
Some("create_lead")
);
assert_eq!(complete.first_call_at, Some(success.created_at));
// A different active key/path must not replace the exact path that produced
// the terminal onboarding call when that exact key is later revoked.
let (_other_agent_id, _other_key_id) =
publish_additional_agent_path(&registry, &operation_id).await;
registry
.revoke_platform_api_key_for_agent(
&workspace_id,
&agent_id,
&key_id,
&OffsetDateTime::now_utc(),
)
.await
.unwrap();
let regressed = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!regressed.completed);
assert!(!regressed.step(OnboardingStepId::Key).unwrap().completed);
assert!(
!regressed
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed
);
database.cleanup().await;
}
#[tokio::test]
async fn current_agent_publication_and_key_expiry_invalidate_old_first_call_evidence() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let pool = database.raw_pool().await;
let workspace_id = WorkspaceId::new("ws_default");
let (operation_id, agent_id, key_id) = published_path(&registry).await;
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"publication_before_republish",
OffsetDateTime::now_utc(),
)
.await;
let completed = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(completed.completed);
let unpublished_at = OffsetDateTime::now_utc() + Duration::seconds(1);
registry
.unpublish_agent(&workspace_id, &agent_id, &unpublished_at, None)
.await
.unwrap();
let unpublished = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!unpublished.step(OnboardingStepId::Agent).unwrap().completed);
let republished_at = unpublished_at + Duration::seconds(1);
registry
.publish_agent(PublishAgentRequest {
workspace_id: &workspace_id,
agent_id: &agent_id,
version: 1,
published_at: &republished_at,
published_by: Some("onboarding-republish-test"),
expected_state: None,
})
.await
.unwrap();
let republished = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(
!republished.step(OnboardingStepId::Agent).unwrap().completed,
"republishing must not silently replace the exact catalog revision that completed onboarding"
);
assert!(
!republished
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed,
"a call from an older Agent publication must not complete the current catalog revision"
);
assert_ne!(republished.revision, completed.revision);
let reset = registry
.reset_onboarding_selection(
&workspace_id,
republished.revision,
republished_at + Duration::milliseconds(1),
)
.await
.unwrap();
assert!(reset.step(OnboardingStepId::Agent).unwrap().completed);
assert!(reset.step(OnboardingStepId::Key).unwrap().completed);
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"publication_after_republish",
republished_at + Duration::seconds(1),
)
.await;
let completed_again = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(completed_again.completed);
sqlx::query(
"update platform_api_keys set expires_at = now() - interval '1 minute' where id = $1",
)
.bind(key_id.as_str())
.execute(&pool)
.await
.unwrap();
let expired = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(!expired.step(OnboardingStepId::Key).unwrap().completed);
assert!(!expired.step(OnboardingStepId::FirstCall).unwrap().completed);
assert_ne!(expired.revision, completed_again.revision);
database.cleanup().await;
}
#[tokio::test]
async fn archiving_selected_operation_or_agent_regresses_authoritative_projection() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = WorkspaceId::new("ws_default");
let (operation_id, agent_id, key_id) = published_path(&registry).await;
record_onboarding_test_and_call(
&registry,
&operation_id,
&agent_id,
&key_id,
"archive_agent_path",
OffsetDateTime::now_utc(),
)
.await;
assert!(
registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap()
.completed
);
// A second healthy path must not replace the exact lineage that produced
// the authoritative first call when that lineage is later archived.
let (alternative_operation_id, _, _) = publish_named_path(
&registry,
"op_onboarding_alternative",
"agent_onboarding_alternative",
"pk_onboarding_alternative",
)
.await;
registry
.archive_agent(
&workspace_id,
&agent_id,
&(OffsetDateTime::now_utc() + Duration::seconds(1)),
None,
)
.await
.unwrap();
let archived_agent = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert_eq!(archived_agent.operation_id, Some(operation_id.clone()));
assert_eq!(archived_agent.agent_id, Some(agent_id));
assert_ne!(archived_agent.operation_id, Some(alternative_operation_id));
assert!(
!archived_agent
.step(OnboardingStepId::Agent)
.unwrap()
.completed
);
assert!(
!archived_agent
.step(OnboardingStepId::FirstCall)
.unwrap()
.completed
);
registry
.archive_operation(
&workspace_id,
&operation_id,
&(OffsetDateTime::now_utc() + Duration::seconds(2)),
)
.await
.unwrap();
let archived_operation = registry
.get_onboarding_projection(&workspace_id)
.await
.unwrap();
assert!(
!archived_operation
.step(OnboardingStepId::Operation)
.unwrap()
.completed
);
database.cleanup().await;
}
async fn published_path(
registry: &crank_registry::PostgresRegistry,
) -> (
crank_core::OperationId,
crank_core::AgentId,
PlatformApiKeyId,
) {
publish_named_path(
registry,
"op_onboarding",
"agent_onboarding",
"pk_onboarding_exact",
)
.await
}
async fn publish_named_path(
registry: &crank_registry::PostgresRegistry,
operation_id: &str,
agent_id: &str,
key_id: &str,
) -> (
crank_core::OperationId,
crank_core::AgentId,
PlatformApiKeyId,
) {
let workspace_id = WorkspaceId::new("ws_default");
let operation = test_operation(operation_id, 1, OperationStatus::Draft);
registry
.create_operation(&workspace_id, &operation, None)
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &workspace_id,
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::now_utc(),
published_by: Some("onboarding-test"),
})
.await
.unwrap();
let agent = test_agent(agent_id, AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let bindings = vec![AgentOperationBinding {
operation_id: operation.id.clone(),
operation_version: 1,
tool_name: "create_lead".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
}];
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &bindings,
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
version: 1,
published_at: &OffsetDateTime::now_utc(),
published_by: Some("onboarding-test"),
expected_state: None,
})
.await
.unwrap();
let key_id = PlatformApiKeyId::new(key_id);
let key = PlatformApiKey {
id: key_id.clone(),
workspace_id,
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: format!("onboarding-{key_id}-key"),
prefix: format!("crk_{}", key_id.as_str()),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &key,
secret_hash: "exact-secret-hash",
})
.await
.unwrap();
(operation.id, agent.id, key_id)
}
async fn record_onboarding_test_and_call(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
agent_id: &crank_core::AgentId,
key_id: &PlatformApiKeyId,
suffix: &str,
occurred_at: OffsetDateTime,
) {
let mut test_log = test_invocation_log(
&format!("log_onboarding_test_{suffix}"),
operation_id,
None,
InvocationStatus::Ok,
20,
"2026-03-25T12:14:00Z",
);
test_log.source = InvocationSource::AdminTestRun;
test_log.created_at = truncate_to_micros(occurred_at);
registry
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
.await;
registry
.touch_platform_api_key(
&WorkspaceId::new("ws_default"),
key_id,
&truncate_to_micros(occurred_at),
)
.await
.unwrap();
record_onboarding_call(
registry,
operation_id,
agent_id,
key_id,
suffix,
occurred_at,
)
.await;
}
async fn record_onboarding_call(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
agent_id: &crank_core::AgentId,
key_id: &PlatformApiKeyId,
suffix: &str,
occurred_at: OffsetDateTime,
) {
let mut success = test_invocation_log(
&format!("log_onboarding_call_{suffix}"),
operation_id,
Some(agent_id.clone()),
InvocationStatus::Ok,
25,
"2026-03-25T12:16:00Z",
);
success.platform_api_key_id = Some(key_id.clone());
success.created_at = truncate_to_micros(occurred_at);
registry
.create_invocation_log(CreateInvocationLogRequest { log: &success })
.await;
}
async fn publish_additional_agent_path(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
) -> (crank_core::AgentId, PlatformApiKeyId) {
let workspace_id = WorkspaceId::new("ws_default");
let agent = test_agent("agent_onboarding_later", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let bindings = vec![AgentOperationBinding {
operation_id: operation_id.clone(),
operation_version: 1,
tool_name: "create_lead".to_owned(),
tool_title: "Create lead".to_owned(),
tool_description_override: None,
enabled: true,
}];
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &bindings,
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
version: 1,
published_at: &OffsetDateTime::now_utc(),
published_by: Some("onboarding-test"),
expected_state: None,
})
.await
.unwrap();
let key_id = PlatformApiKeyId::new("pk_onboarding_later");
let key = PlatformApiKey {
id: key_id.clone(),
workspace_id,
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: "onboarding-later-key".to_owned(),
prefix: "crk_later".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &key,
secret_hash: "later-secret-hash",
})
.await
.unwrap();
(agent.id, key_id)
}
async fn authoritative_first_call(
pool: &sqlx::PgPool,
workspace_id: &str,
agent_id: &str,
key_id: &str,
) -> bool {
sqlx::query_scalar(
"select exists (
select 1
from invocation_logs l
join published_agents pa on pa.agent_id = l.agent_id
join platform_api_keys k
on k.id = l.platform_api_key_id
and k.workspace_id = l.workspace_id
and k.agent_id = l.agent_id
where l.workspace_id = $1
and l.agent_id = $2
and l.platform_api_key_id = $3
and l.source = 'agent_tool_call'
and l.status = 'ok'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
)",
)
.bind(workspace_id)
.bind(agent_id)
.bind(key_id)
.fetch_one(pool)
.await
.unwrap()
}
async fn insert_product_event(
pool: &sqlx::PgPool,
id: &str,
workspace_id: &str,
event_name: &str,
idempotency_key: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"insert into product_events (
id, workspace_id, event_name, schema_version, occurred_at, idempotency_key,
properties_json
) values ($1, $2, $3, 1, now(), $4, $5)",
)
.bind(id)
.bind(workspace_id)
.bind(event_name)
.bind(idempotency_key)
.bind(json!({"eligible": true}))
.execute(pool)
.await?;
Ok(())
}
async fn relation_exists(pool: &sqlx::PgPool, relation: &str) -> bool {
sqlx::query_scalar(
"select exists (
select 1 from information_schema.tables
where table_schema = current_schema() and table_name = $1
)",
)
.bind(relation)
.fetch_one(pool)
.await
.unwrap()
}
async fn column_exists(pool: &sqlx::PgPool, relation: &str, column: &str) -> bool {
sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema() and table_name = $1 and column_name = $2
)",
)
.bind(relation)
.bind(column)
.fetch_one(pool)
.await
.unwrap()
}
async fn create_workspace(registry: &crank_registry::PostgresRegistry, id: &str, slug: &str) {
let now = OffsetDateTime::now_utc();
let workspace = Workspace {
id: WorkspaceId::new(id),
slug: slug.to_owned(),
display_name: slug.to_owned(),
status: crank_core::WorkspaceStatus::Active,
settings: json!({}),
created_at: now,
updated_at: now,
};
registry
.create_workspace(CreateWorkspaceRequest {
workspace: &workspace,
})
.await
.unwrap();
}