use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use jsonwebtoken::{EncodingKey, Header};
use serde_json::json;
use skilj::{IdpConfig, SigningAlgorithm, Skilj};
use skilj_core::db::Pool;
use skilj_core::shared::generate_token_id;
use tower::ServiceExt;
const TEST_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPHVFsUHiWXSbG
/TCig1cTQHNT6FnoYoZtMEjvDiQArsOL/dFoM9pmGRM9CfEtQGNum4TsimPtgJec
awfdPnW0uJCRlIF9wGmYdh2mYNBKw8jqxwp664Gd5uqH5L6A4pN8bfGO7+2niD6p
8t0cNeyYOd0PusbAEDcpzCUZmr6KQyM5i8/wk5oO98gntp+ZpMjUZabAD6R8DyhM
IZmV645jo5NPJG7zuSz+3dmKkNY0/GXz8YwvZ2swqmmOANRZHHfN1vgP2ycK02WZ
4yihx6EiuQCDseddBw+xit9KSvSq6GwmwnV1qVpMVNlSGGOeVX7v7JQ3z/BNbQ85
5p6s/FjhAgMBAAECggEAFu8fKghLIhNUjOpSbVxv0vDrFFqBQitOyV50ZQxCzlSL
0L+dZZWAVJfoOnUUYLdli0TrVioI4K7Bmw97AnO9IvLhB03TfPJGfxxtMhQ8XFsL
r3u03GGhq7N7OusIcUslm7ys5/AHd+qtTbJX65zJAx49LVW4VmI1SYqSfSBWgway
8uGYaXyCfwuxQ+xB4fQd6llm/+9dqS+U36LVSMWgEmVjceorYFhPVLfuX4A1wHjF
mDl40AwPBqzVbOIzFDMDikk4heFi6wlt6N3LGDtyBUUuzEg5TBhyiirvNvTjW+4V
Z4MZs3tez+IqM0+F4EsgAEQUU12YQxa4lobm8/zgZQKBgQD81FMzymNR6xWhUSwY
4RtkVntfMBOMp1rVGcVyBxOLKxEXF6ctk2rV38krfUI50h/lWzrbpl+zJvEe8D1H
vZjYj28sL3wf0CSnPYUeGANTxrW1dTiz1HVzzChfbAEWj3fsVrlghNcnHBkDDhqz
L/rPEfp//fB0SyLAEAJt87cgFwKBgQDRtjtH1gIkGn5GCS3u0FAbxV+qrUlTvu4t
Di1GcEw32jootQQSMZN1PxEvLuehaBlaASEL2OZzZlQ4q60LV1Jisvd7wqv5EYnG
o+sKtrCS5iXKfkxqTmg+JS7OZazggyvgBnv4GXT0US6/G4nw7C9JaS2jyOvPGIPS
K8dsWDIxxwKBgQCgr4FBxTticPqKUECqf0cdeilm0fNazXJZRcvLMNwm8vQlrQ6/
VJXt4BDG5xEUFovXBShfOVpRTkqo0x7fXYyq9l49wuAsh+kDsYHNIo3azMvny9yB
zmHnerWeD9KROBWLy4J96W+kl6L94hTuFWxd9psyhX4xKx+m2YXxw5d7eQKBgFB2
I86PHOkvRQ2oDfiX8nSFSQxaSk0Yb5fX3aUuBwBS+YeO1E4KuXH9zaEV1QeHwlpX
Ho/GG71hIKVRsSYtzc1Sr0PL0GHSydLuJ4tHxv3F0fAcf0M2bCaT656DQk4t5dKh
ikUJt2baEx59+XH3nLkE4t75gwhFdqZX5775I+EXAoGAfnpHlLZdGW48rl9Cl887
hRDjXDm/gP/ljCrvxxiWselEgaLj2o4NiT28QAfq7KgtOIpAeLAGzIBP6vkE7KFp
nAF+t4gRpooXXSI5oXCBcGI9a26q68UV3iDEmQGiP8kVHOsdzcOKY0qk1ulNAIV4
fU919gnTKorSq3FdV6zGZ8s=
-----END PRIVATE KEY-----
";
const TEST_MODULUS_N: &str = "zx1RbFB4ll0mxv0wooNXE0BzU-hZ6GKGbTBI7w4kAK7Di_3RaDPaZhkTPQnxLUBjbpuE7Ipj7YCXnGsH3T51tLiQkZSBfcBpmHYdpmDQSsPI6scKeuuBnebqh-S-gOKTfG3xju_tp4g-qfLdHDXsmDndD7rGwBA3KcwlGZq-ikMjOYvP8JOaDvfIJ7afmaTI1GWmwA-kfA8oTCGZleuOY6OTTyRu87ks_t3ZipDWNPxl8_GML2drMKppjgDUWRx3zdb4D9snCtNlmeMoocehIrkAg7HnXQcPsYrfSkr0quhsJsJ1dalaTFTZUhhjnlV-7-yUN8_wTW0POeaerPxY4Q";
const TEST_EXPONENT_E: &str = "AQAB";
const TEST_KID: &str = "test-key-1";
const TEST_ISSUER: &str = "https://idp.example.test/";
const TEST_AUDIENCE: &str = "skilj-test-client";
struct TestDb {
database_url: String,
}
static TEST_DB: tokio::sync::OnceCell<Option<TestDb>> = tokio::sync::OnceCell::const_new();
fn runtime() -> &'static tokio::runtime::Runtime {
static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
RUNTIME.get_or_init(|| {
tokio::runtime::Runtime::new()
.expect("failed to build a tokio runtime for graphql_admin_console tests")
})
}
async fn test_database_url() -> Option<String> {
TEST_DB
.get_or_init(provision)
.await
.as_ref()
.map(|db| db.database_url.clone())
}
async fn check_reachable(database_url: &str, label: &str) -> bool {
match skilj_core::db::connect(database_url).await {
Ok(pool) => {
skilj_core::db::migrate(&pool).await.is_ok() || {
eprintln!("skipping: migrating {label} failed");
false
}
}
Err(e) => {
eprintln!("skipping: connecting to {label} failed: {e}");
false
}
}
}
async fn provision() -> Option<TestDb> {
let url = skilj_test_support::database_url("skilj_graphql_admin_console_test").await?;
if !check_reachable(&url, "the test database").await {
return None;
}
Some(TestDb { database_url: url })
}
fn unique_name(prefix: &str) -> String {
format!("{prefix}_{}", generate_token_id())
}
async fn serve_jwks() -> String {
let jwks = json!({
"keys": [{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": TEST_KID,
"n": TEST_MODULUS_N,
"e": TEST_EXPONENT_E,
}]
});
let app = axum::Router::new().route(
"/jwks.json",
axum::routing::get(move || {
let jwks = jwks.clone();
async move { axum::Json(jwks) }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind an ephemeral port for the JWKS test server");
let addr = listener
.local_addr()
.expect("a bound listener always has a local address");
tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("the JWKS test server stopped unexpectedly");
});
format!("http://{addr}/jwks.json")
}
fn sign_jwt(subject: &str) -> String {
let mut header = Header::new(jsonwebtoken::Algorithm::RS256);
header.kid = Some(TEST_KID.to_string());
let claims = json!({
"sub": subject,
"iss": TEST_ISSUER,
"aud": TEST_AUDIENCE,
"exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp(),
});
let key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY_PEM.as_bytes())
.expect("the test private key PEM is well-formed");
jsonwebtoken::encode(&header, &claims, &key).expect("signing a well-formed JWT never fails")
}
async fn setup() -> (Skilj, Pool) {
let database_url = test_database_url()
.await
.expect("test_database_url() must be Some - caller already checked");
let jwks_url = serve_jwks().await;
let (skilj, report) = Skilj::builder(database_url.clone())
.identity_provider(IdpConfig::new(
jwks_url.parse().unwrap(),
TEST_ISSUER,
TEST_AUDIENCE,
SigningAlgorithm::Rs256,
))
.build()
.await
.unwrap();
assert_eq!(report.skipped_no_access, Vec::<String>::new());
let pool = skilj_core::db::connect(&database_url).await.unwrap();
(skilj, pool)
}
async fn graphql_request(
router: &axum::Router,
jwt: Option<&str>,
query: &str,
variables: serde_json::Value,
) -> serde_json::Value {
let mut request = Request::builder().method("POST").uri("/graphql");
if let Some(jwt) = jwt {
request = request.header("authorization", format!("Bearer {jwt}"));
}
let request = request
.header("content-type", "application/json")
.body(Body::from(
json!({ "query": query, "variables": variables }).to_string(),
))
.unwrap();
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&body).unwrap()
}
#[test]
fn full_admin_console_lifecycle_end_to_end() {
runtime().block_on(async {
if test_database_url().await.is_none() {
return;
}
let (skilj, _pool) = setup().await;
let router = skilj.graphql_router().await.unwrap();
let bootstrap_secret = skilj
.bootstrap_secret()
.expect("no active superadmin exists yet on a freshly built Skilj")
.to_string();
let root_subject = unique_name("root");
let response = graphql_request(
&router,
None,
"mutation($secret: String!, $subject: String!) { \
createSuperadmin(bootstrapSecret: $secret, name: \"Root\", externalSubject: $subject) { \
id name externalSubject \
} \
}",
json!({ "secret": bootstrap_secret, "subject": root_subject }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["createSuperadmin"]["name"], "Root");
assert_eq!(
response["data"]["createSuperadmin"]["externalSubject"],
root_subject
);
let root_role_id = response["data"]["createSuperadmin"]["id"]
.as_str()
.unwrap()
.to_string();
let response = graphql_request(
&router,
None,
"mutation($secret: String!) { \
createSuperadmin(bootstrapSecret: $secret, name: \"Root2\", externalSubject: \"someone-else\") { id } \
}",
json!({ "secret": bootstrap_secret }),
)
.await;
assert_eq!(
response["errors"][0]["extensions"]["code"],
"bootstrap_secret_unavailable"
);
let response = graphql_request(
&router,
None,
"mutation { addBoundedContext(name: \"billing\") { name } }",
json!({}),
)
.await;
assert_eq!(response["errors"][0]["extensions"]["code"], "unauthenticated");
let unrecognised_jwt = sign_jwt("nobody@example.com");
let response = graphql_request(
&router,
Some(&unrecognised_jwt),
"mutation { addBoundedContext(name: \"billing\") { name } }",
json!({}),
)
.await;
assert_eq!(
response["errors"][0]["extensions"]["code"],
"unrecognised_subject"
);
let root_jwt = sign_jwt(&root_subject);
let bc_name = unique_name("billing");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($name: String!) { addBoundedContext(name: $name) { name status accessMappings { role { name } } } }",
json!({ "name": bc_name }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["addBoundedContext"]["name"], bc_name);
assert_eq!(response["data"]["addBoundedContext"]["status"], "ACTIVE");
assert_eq!(
response["data"]["addBoundedContext"]["accessMappings"],
json!([])
);
let worker_subject = unique_name("worker");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($subject: String!) { \
createRole(name: \"Worker\", superadmin: false, externalSubject: $subject) { id name } \
}",
json!({ "subject": worker_subject }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
let worker_role_id = response["data"]["createRole"]["id"].as_str().unwrap().to_string();
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($roleId: ID!, $bc: String!) { \
grantRoleAccessMapping(roleId: $roleId, boundedContext: $bc, level: ADMIN, canReadSensitive: false) { \
level canReadSensitive status role { id } \
} \
}",
json!({ "roleId": worker_role_id, "bc": bc_name }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["grantRoleAccessMapping"]["level"], "ADMIN");
assert_eq!(response["data"]["grantRoleAccessMapping"]["status"], "ACTIVE");
let response = graphql_request(
&router,
Some(&root_jwt),
"query { boundedContexts { name accessMappings { role { name } level } } }",
json!({}),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
let contexts = response["data"]["boundedContexts"].as_array().unwrap();
let listed = contexts
.iter()
.find(|c| c["name"] == bc_name)
.expect("the just-created bounded context must be in the directory");
assert_eq!(listed["accessMappings"][0]["role"]["name"], "Worker");
assert_eq!(listed["accessMappings"][0]["level"], "ADMIN");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($roleId: ID!, $bc: String!) { \
revokeRoleAccessMapping(roleId: $roleId, boundedContext: $bc) { status } \
}",
json!({ "roleId": worker_role_id, "bc": bc_name }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["revokeRoleAccessMapping"]["status"], "REVOKED");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($roleId: ID!) { revokeRole(roleId: $roleId) { status } }",
json!({ "roleId": worker_role_id }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["revokeRole"]["status"], "REVOKED");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($name: String!) { archiveBoundedContext(name: $name) { status } }",
json!({ "name": bc_name }),
)
.await;
assert_eq!(
response["errors"][0]["extensions"]["code"],
"grant_not_active"
);
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($name: String!) { deleteBoundedContext(name: $name) { name } }",
json!({ "name": bc_name }),
)
.await;
assert_eq!(
response["errors"][0]["extensions"]["code"],
"bounded_context_not_archived"
);
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($roleId: ID!, $bc: String!) { \
grantRoleAccessMapping(roleId: $roleId, boundedContext: $bc, level: ADMIN, canReadSensitive: false) { level } \
}",
json!({ "roleId": root_role_id, "bc": bc_name }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($name: String!) { archiveBoundedContext(name: $name) { status } }",
json!({ "name": bc_name }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["archiveBoundedContext"]["status"], "ARCHIVED");
let response = graphql_request(
&router,
Some(&root_jwt),
"mutation($name: String!) { deleteBoundedContext(name: $name) { name status } }",
json!({ "name": bc_name }),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
assert_eq!(response["data"]["deleteBoundedContext"]["name"], bc_name);
let response = graphql_request(
&router,
Some(&root_jwt),
"query { boundedContexts { name } }",
json!({}),
)
.await;
assert!(response.get("errors").is_none(), "unexpected errors: {response:?}");
let contexts = response["data"]["boundedContexts"].as_array().unwrap();
assert!(!contexts.iter().any(|c| c["name"] == bc_name));
});
}
#[test]
fn a_non_superadmin_cannot_tell_existing_bounded_contexts_from_missing_ones() {
runtime().block_on(async {
if test_database_url().await.is_none() {
return;
}
let (skilj, pool) = setup().await;
let router = skilj.graphql_router().await.unwrap();
let now = chrono::Utc::now();
let prober = skilj_core::access_control::Role {
id: generate_token_id(),
external_subject: unique_name("prober"),
name: "Prober".to_string(),
superadmin: false,
status: skilj_core::access_control::RoleStatus::Active,
created_at: now,
revoked_at: None,
};
skilj_core::db::insert_role(&pool, &prober).await.unwrap();
let existing = unique_name("tenant");
skilj_core::db::insert_bounded_context(
&pool,
&skilj_core::event_store::BoundedContext {
name: existing.clone(),
status: skilj_core::event_store::BoundedContextStatus::Active,
created_at: now,
created_by: skilj_core::bootstrap::ContextCreator::SystemCreator,
template: None,
},
)
.await
.unwrap();
let missing = unique_name("no_such_tenant");
let jwt = sign_jwt(&prober.external_subject);
let mutations = [
"mutation($bc: String!) { deleteBoundedContext(name: $bc) { name } }",
"mutation($bc: String!) { resyncBoundedContextFromTemplate(boundedContext: $bc) { name } }",
"mutation($bc: String!, $role: ID!) { createBoundedContextFromTemplate(\
template: $bc, name: \"copy\", roleId: $role, level: READ, canReadSensitive: false) { name } }",
"mutation($bc: String!, $role: ID!) { grantRoleAccessMapping(\
roleId: $role, boundedContext: $bc, level: READ, canReadSensitive: false) { level } }",
"mutation($bc: String!, $role: ID!) { revokeRoleAccessMapping(roleId: $role, boundedContext: $bc) { level } }",
];
for mutation in mutations {
for bc in [&existing, &missing] {
let response = graphql_request(
&router,
Some(&jwt),
mutation,
json!({ "bc": bc, "role": prober.id }),
)
.await;
assert_eq!(
response["errors"][0]["extensions"]["code"], "not_superadmin",
"{mutation} on {bc}: {response}"
);
}
}
for role in [prober.id.clone(), generate_token_id()] {
let response = graphql_request(
&router,
Some(&jwt),
"mutation($role: ID!) { revokeRole(roleId: $role) { id } }",
json!({ "role": role }),
)
.await;
assert_eq!(
response["errors"][0]["extensions"]["code"], "not_superadmin",
"{response}"
);
}
});
}