Files
clawmates/crates/cm-api/tests/clerk_api.rs
T
Omar SobhandClaude Fable 5 add4f79fed Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.

- Display brand: Clawmates (manifest, titles, hero, login/rail logo
  'clawmates'); default host app.clawmates.work; registry
  ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
  clawmates-server/broker/bundler; images clawmates/*; env prefix
  CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
  clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
  resources; db names clawmates*; sockets /run/clawmates; cookie
  cm_session; kind cluster clawmates-test; seccomp node profile
  clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
  spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
  test server clawmates-test-pg, kind cluster recreated with image +
  profile, compose images rebuilt under clawmates/*

Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 12:31:25 -05:00

127 lines
4.1 KiB
Rust

//! The full stack accepts Clerk session JWTs: a real RSA issuer, a real
//! router, and `Authorization: Bearer <session JWT>` straight into a
//! protected endpoint — the exact shape `@clerk/nextjs`'s getToken()
//! produces.
use std::sync::Arc;
use cm_api::AppState;
use cm_auth::JwtVerifier;
use cm_domain::{Workspace, WorkspaceId};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::traits::PublicKeyParts;
use rsa::RsaPrivateKey;
use serde_json::{json, Value};
fn b64url(bytes: &[u8]) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
URL_SAFE_NO_PAD.encode(bytes)
}
#[tokio::test]
async fn a_clerk_session_jwt_reaches_protected_endpoints() {
let pool = cm_testkit::test_pool().await;
// Real issuer with a real key.
let mut rng = rand_core::OsRng;
let key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
let public = key.to_public_key();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap());
let jwks = json!({"keys": [{
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "k1",
"n": b64url(&public.n().to_bytes_be()),
"e": b64url(&public.e().to_bytes_be()),
}]});
let discovery = json!({
"issuer": issuer,
"jwks_uri": format!("{issuer}/.well-known/jwks.json"),
});
let idp = axum::Router::new()
.route(
"/.well-known/openid-configuration",
axum::routing::get({
let doc = discovery.clone();
move || {
let doc = doc.clone();
async move { axum::Json(doc) }
}
}),
)
.route(
"/.well-known/jwks.json",
axum::routing::get({
let doc = jwks.clone();
move || {
let doc = doc.clone();
async move { axum::Json(doc) }
}
}),
);
tokio::spawn(async move {
axum::serve(listener, idp).await.unwrap();
});
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
// The app, Clerk-configured.
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
let verifier = JwtVerifier::discover(&issuer).await.unwrap();
let app =
cm_api::router(AppState::new(pool.clone(), runtime).with_auth_verifier(Arc::new(verifier)));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// What getToken() returns in the browser.
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("k1".into());
let pem = key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF).unwrap();
let session_jwt = encode(
&header,
&json!({
"iss": issuer, "sub": "user_2clerk", "iat": now, "exp": now + 60,
"email": "[email protected]", "role": "org:admin",
}),
&EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap(),
)
.unwrap();
let client = reqwest::Client::new();
let me: Value = client
.get(format!("{base}/api/user/me"))
.bearer_auth(&session_jwt)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(me["email"], "[email protected]");
assert_eq!(me["role"], "owner");
// No token still means no entry.
let anonymous = client
.get(format!("{base}/api/user/me"))
.send()
.await
.unwrap();
assert_eq!(anonymous.status(), 401);
}