mod common;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use open_pincery::api::{self, AppState};
use open_pincery::config::Config;
use open_pincery::models::{agent, user, workspace};
fn test_config() -> Config {
Config {
database_url: String::new(),
host: "127.0.0.1".into(),
port: 0,
bootstrap_token: "test-token".into(),
llm_api_base_url: "http://localhost:9999".into(),
llm_api_key: "fake".into(),
llm_model: "test".into(),
llm_maintenance_model: "test".into(),
max_prompt_chars: 100000,
iteration_cap: 50,
stale_wake_hours: 2,
wake_summary_limit: 20,
event_window_limit: 200,
}
}
#[tokio::test]
async fn test_api_crud_agents() {
let pool = common::test_pool().await;
let state = AppState::new(pool.clone(), test_config());
let app = api::router(state);
let req = Request::builder()
.method("POST")
.uri("/api/bootstrap")
.header(header::AUTHORIZATION, "Bearer test-token")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = json["session_token"].as_str().unwrap().to_string();
let req = Request::builder()
.method("POST")
.uri("/api/agents")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name": "test-agent"}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let agent: serde_json::Value = serde_json::from_slice(&body).unwrap();
let agent_id = agent["id"].as_str().unwrap();
let req = Request::builder()
.method("GET")
.uri("/api/agents")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let agents: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(agents.len(), 1);
let req = Request::builder()
.method("GET")
.uri(format!("/api/agents/{agent_id}"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method("POST")
.uri(format!("/api/agents/{agent_id}/messages"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"content": "Hello agent"}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let req = Request::builder()
.method("GET")
.uri(format!("/api/agents/{agent_id}/events"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let events: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(events["events"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn test_api_requires_auth() {
let pool = common::test_pool().await;
let state = AppState::new(pool, test_config());
let app = api::router(state);
let req = Request::builder()
.method("GET")
.uri("/api/agents")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_agent_routes_are_scoped_to_workspace() {
let pool = common::test_pool().await;
let state = AppState::new(pool.clone(), test_config());
let app = api::router(state);
let allowed_user = user::create_local_admin(&pool, "allowed@test.com", "Allowed")
.await
.unwrap();
let allowed_org =
workspace::create_organization(&pool, "allowed-org", "allowed-org", allowed_user.id)
.await
.unwrap();
let allowed_ws = workspace::create_workspace(
&pool,
allowed_org.id,
"allowed-ws",
"allowed-ws",
allowed_user.id,
)
.await
.unwrap();
workspace::add_org_membership(&pool, allowed_org.id, allowed_user.id, "owner")
.await
.unwrap();
workspace::add_workspace_membership(&pool, allowed_ws.id, allowed_user.id, "owner")
.await
.unwrap();
let outsider_id: uuid::Uuid = sqlx::query_scalar(
"INSERT INTO users (email, display_name, auth_provider, auth_subject)
VALUES ($1, $2, $3, $4)
RETURNING id",
)
.bind("outsider@test.com")
.bind("Outsider")
.bind("local_test")
.bind("outsider")
.fetch_one(&pool)
.await
.unwrap();
let outsider_org =
workspace::create_organization(&pool, "outsider-org", "outsider-org", outsider_id)
.await
.unwrap();
let outsider_ws = workspace::create_workspace(
&pool,
outsider_org.id,
"outsider-ws",
"outsider-ws",
outsider_id,
)
.await
.unwrap();
workspace::add_org_membership(&pool, outsider_org.id, outsider_id, "owner")
.await
.unwrap();
workspace::add_workspace_membership(&pool, outsider_ws.id, outsider_id, "owner")
.await
.unwrap();
let outsider_agent = agent::create_agent(&pool, "outsider-agent", outsider_ws.id, outsider_id)
.await
.unwrap();
let token_hash = open_pincery::auth::hash_token("workspace-scope-token");
user::create_session(&pool, allowed_user.id, &token_hash, "local_admin")
.await
.unwrap();
let req = Request::builder()
.method("GET")
.uri(format!("/api/agents/{}", outsider_agent.id))
.header(header::AUTHORIZATION, "Bearer workspace-scope-token")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let req = Request::builder()
.method("PATCH")
.uri(format!("/api/agents/{}", outsider_agent.id))
.header(header::AUTHORIZATION, "Bearer workspace-scope-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"renamed"}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let req = Request::builder()
.method("POST")
.uri(format!("/api/agents/{}/messages", outsider_agent.id))
.header(header::AUTHORIZATION, "Bearer workspace-scope-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"content":"forbidden"}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let req = Request::builder()
.method("GET")
.uri(format!("/api/agents/{}/events", outsider_agent.id))
.header(header::AUTHORIZATION, "Bearer workspace-scope-token")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let req = Request::builder()
.method("DELETE")
.uri(format!("/api/agents/{}", outsider_agent.id))
.header(header::AUTHORIZATION, "Bearer workspace-scope-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let event_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE agent_id = $1")
.bind(outsider_agent.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(event_count, 0);
let still_enabled: bool = sqlx::query_scalar("SELECT is_enabled FROM agents WHERE id = $1")
.bind(outsider_agent.id)
.fetch_one(&pool)
.await
.unwrap();
assert!(still_enabled);
}