use std::future::Future;
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
use axum::body::Body;
use axum::extract::Query;
use axum::http::request::{Builder, Parts};
use axum::http::{HeaderMap, Request, StatusCode, Uri};
use axum::response::{AppendHeaders, IntoResponse};
use axum::{Json, Router};
use stackure::{DirectoryOptions, DirectoryPage, DirectoryUser, StackureError, Team, User};
use tokio::runtime::Runtime;
use tower::Service;
const APP_ID: &str = "7f3c1a2e-9b4d-4e6f-8a1b-2c3d4e5f6071";
const APP_SECRET: &str = "app-secret-under-test";
const TOKEN: &str = "0b9f6c1e-5a3d-4c2b-9e8f-7a6b5c4d3e2f";
const INVALID: &str = "1c8e7d2f-6b4a-4d3c-8f9e-6b5a4c3d2e1f";
const REFUSED: &str = "2d7f8e3a-7c5b-4e4d-a0af-5c4b3d2e1f0a";
const THROTTLED: &str = "3e8a9f4b-8d6c-4f5e-b1b0-4d3c2e1f0a9b";
const REJECTED: &str = "4f9b0a5c-9e7d-4a6f-82c1-3e2d1f0a9b8c";
const ADMIN: &str = "5a0c1b6d-0f8e-4b7a-93d2-2f1e0a9b8c7d";
const MEMBER: &str = "6b1d2c7e-1a9f-4c8b-a4e3-1a0f9b8c7d6e";
const OLDER: &str = "7c2e3d8f-2b0a-4d9c-b5f4-0b1a2c3d4e5f";
const USERS: &str = "/api/public/directory/users";
const TEAMS: &str = "/api/public/directory/teams";
#[derive(Debug, PartialEq)]
struct Call {
path: String,
query: Vec<(String, String)>,
authorization: String,
app_secret: String,
cookie: String,
user_agent: String,
forwarded_for: String,
}
fn ada() -> User {
User {
user_id: "8d3f4e9a-3c1b-4eab-86a5-1c2b3d4e5f6a".into(),
account_id: "9e4a5f0b-4d2c-4fbc-97b6-2d3c4e5f6a7b".into(),
user_email: "ada@example.com".into(),
user_first_name: "Ada".into(),
user_last_name: "Lovelace".into(),
user_is_app_admin: false,
user_teams: Vec::new(),
}
}
fn ops() -> Team {
Team {
team_id: "4b5c6d7e-8f9a-4b1c-8d2e-3f4a5b6c7d8e".into(),
team_name: "Ops".into(),
}
}
fn listed() -> DirectoryUser {
DirectoryUser {
user_id: "8d3f4e9a-3c1b-4eab-86a5-1c2b3d4e5f6a".into(),
user_email: "ada@example.com".into(),
user_first_name: "Ada".into(),
user_last_name: "Lovelace".into(),
}
}
fn page<T>(item: T, next_cursor: Option<&str>, total: Option<u64>) -> DirectoryPage<T> {
DirectoryPage {
items: vec![item],
next_cursor: next_cursor.map(String::from),
total,
}
}
static CALLS: Mutex<Vec<Call>> = Mutex::new(Vec::new());
fn recorded() -> MutexGuard<'static, Vec<Call>> {
CALLS.lock().unwrap_or_else(PoisonError::into_inner)
}
async fn api(
uri: Uri,
Query(query): Query<Vec<(String, String)>>,
headers: HeaderMap,
) -> axum::response::Response {
let header = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string()
};
let authorization = header("authorization");
let cookie = header("cookie");
let token = cookie
.strip_prefix("session=")
.or_else(|| authorization.strip_prefix("Bearer "))
.unwrap_or_default()
.to_string();
let first = !query.iter().any(|(k, _)| k == "cursor");
recorded().push(Call {
path: uri.path().into(),
query,
authorization,
app_secret: header("x-app-secret"),
cookie,
user_agent: header("user-agent"),
forwarded_for: header("x-forwarded-for"),
});
let mut user = serde_json::json!({
"user_id": "8d3f4e9a-3c1b-4eab-86a5-1c2b3d4e5f6a",
"account_id": "9e4a5f0b-4d2c-4fbc-97b6-2d3c4e5f6a7b",
"user_email": "ada@example.com",
"user_first_name": "Ada",
"user_last_name": "Lovelace",
});
match token.as_str() {
TOKEN => {
let items = if uri.path() == TEAMS {
serde_json::json!([ops()])
} else {
serde_json::json!([listed()])
};
let headers = if first {
vec![("x-next-cursor", "c1"), ("x-total-count", "3")]
} else {
vec![]
};
return (AppendHeaders(headers), Json(items)).into_response();
}
INVALID => {
return (StatusCode::UNAUTHORIZED, r#"{"error":"invalid session"}"#).into_response();
}
REFUSED => {
return (
StatusCode::UNAUTHORIZED,
r#"{"error":"invalid app secret"}"#,
)
.into_response();
}
THROTTLED => {
return (StatusCode::TOO_MANY_REQUESTS, r#"{"error":"rate limited"}"#).into_response();
}
REJECTED => {
return (
StatusCode::BAD_REQUEST,
r#"{"error":"invalid input: cursor"}"#,
)
.into_response();
}
ADMIN => {
user["user_is_app_admin"] = true.into();
user["user_teams"] = serde_json::json!([ops()]);
}
MEMBER => {
user["user_is_app_admin"] = false.into();
user["user_teams"] = serde_json::json!([]);
}
_ => {}
}
Json(serde_json::json!({ "authenticated": true, "user": user })).into_response()
}
struct Platform {
runtime: Runtime,
}
fn platform() -> &'static Platform {
static PLATFORM: OnceLock<Platform> = OnceLock::new();
PLATFORM.get_or_init(|| {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
unsafe {
std::env::set_var(
"STACKURE_BASE_URL",
format!("http://{}", listener.local_addr().unwrap()),
);
std::env::set_var("STACKURE_APP_ID", APP_ID);
std::env::set_var("STACKURE_APP_SECRET", APP_SECRET);
}
let runtime = Runtime::new().unwrap();
runtime.spawn(async {
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
axum::serve(listener, Router::new().fallback(api))
.await
.unwrap();
});
Platform { runtime }
})
}
fn serial() -> MutexGuard<'static, ()> {
static SERIAL: Mutex<()> = Mutex::new(());
SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
}
fn run<F: Future>(future: impl FnOnce() -> F) -> (F::Output, Vec<Call>) {
let _serial = serial();
let platform = platform();
recorded().clear();
let out = platform.runtime.block_on(future());
(out, std::mem::take(&mut *recorded()))
}
fn browser() -> Builder {
Request::get("/share")
.header("user-agent", "browser/1")
.header("x-forwarded-for", "203.0.113.7")
}
fn parts(request: Builder) -> Parts {
request.body(()).unwrap().into_parts().0
}
fn with_session(token: &str) -> Parts {
parts(browser().header("cookie", format!("session={token}")))
}
fn directory_call(path: &str, token: &str, extra: &[(&str, &str)]) -> Call {
let mut query = vec![("app_id".into(), APP_ID.into())];
query.extend(extra.iter().map(|&(k, v)| (k.into(), v.into())));
Call {
path: path.into(),
query,
authorization: String::new(),
app_secret: APP_SECRET.into(),
cookie: format!("session={token}"),
user_agent: "browser/1".into(),
forwarded_for: "203.0.113.7".into(),
}
}
async fn tool(parts: Parts) -> Json<Option<User>> {
Json(stackure::user_from_request(&parts).cloned())
}
#[test]
fn identity_facts_are_decoded_for_session_and_mcp_users() {
let admin = User {
user_is_app_admin: true,
user_teams: vec![ops()],
..ada()
};
for (token, want) in [(ADMIN, admin), (MEMBER, ada()), (OLDER, ada())] {
let session = with_session(token);
let (result, _) = run(|| stackure::validate_session(&session));
assert_eq!(result.unwrap().user, Some(want.clone()), "{token}");
let request = Request::post("/mcp")
.header("host", "app.example.com")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let mut app = Router::new().fallback(tool).layer(stackure::mcp());
let (body, _) = run(|| async move {
let response = app.call(request).await.unwrap();
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
});
assert_eq!(
serde_json::from_slice::<Option<User>>(&body).unwrap(),
Some(want),
"{token}"
);
}
}
#[test]
fn directory_pages() {
let session = with_session(TOKEN);
let first = DirectoryOptions {
query: Some("ada lo".into()),
limit: Some(2),
..DirectoryOptions::default()
};
let next = DirectoryOptions {
cursor: Some("c1".into()),
..DirectoryOptions::default()
};
let first_query = [("q", "ada lo"), ("limit", "2")];
let (result, calls) = run(|| stackure::directory_users(&session, &first));
assert_eq!(result, Ok(page(listed(), Some("c1"), Some(3))));
assert_eq!(calls, [directory_call(USERS, TOKEN, &first_query)]);
let (result, calls) = run(|| stackure::directory_teams(&session, &first));
assert_eq!(result, Ok(page(ops(), Some("c1"), Some(3))));
assert_eq!(calls, [directory_call(TEAMS, TOKEN, &first_query)]);
let (result, calls) = run(|| stackure::directory_users(&session, &next));
assert_eq!(result, Ok(page(listed(), None, None)));
assert_eq!(calls, [directory_call(USERS, TOKEN, &[("cursor", "c1")])]);
let (result, calls) = run(|| stackure::directory_teams(&session, &next));
assert_eq!(result, Ok(page(ops(), None, None)));
assert_eq!(calls, [directory_call(TEAMS, TOKEN, &[("cursor", "c1")])]);
let by_ids = DirectoryOptions {
ids: vec!["a".into(), "b".into()],
limit: Some(200),
cursor: Some("c1".into()),
..DirectoryOptions::default()
};
let (result, calls) = run(|| stackure::directory_users(&session, &by_ids));
assert_eq!(result, Ok(page(listed(), None, None)));
assert_eq!(
calls,
[directory_call(
USERS,
TOKEN,
&[("cursor", "c1"), ("limit", "200"), ("id", "a"), ("id", "b")]
)]
);
}
#[test]
fn directory_errors_map_like_other_api_errors() {
let opts = DirectoryOptions::default();
for (token, want) in [
(
INVALID,
StackureError::Auth(r#"{"error":"invalid session"}"#.into()),
),
(
REFUSED,
StackureError::Auth(r#"{"error":"invalid app secret"}"#.into()),
),
(
THROTTLED,
StackureError::Network(r#"api error (429): {"error":"rate limited"}"#.into()),
),
(
REJECTED,
StackureError::Network(r#"api error (400): {"error":"invalid input: cursor"}"#.into()),
),
(
OLDER,
StackureError::Network("unexpected API response format".into()),
),
] {
let session = with_session(token);
let (result, calls) = run(|| stackure::directory_users(&session, &opts));
assert_eq!(result, Err(want.clone()), "{token}");
assert_eq!(calls, [directory_call(USERS, token, &[])], "{token}");
let (result, calls) = run(|| stackure::directory_teams(&session, &opts));
assert_eq!(result, Err(want), "{token}");
assert_eq!(calls, [directory_call(TEAMS, token, &[])], "{token}");
}
}
#[test]
fn directory_without_a_session_is_auth_without_a_call() {
let opts = DirectoryOptions::default();
for request in [
browser(),
browser().header("cookie", "session=not-a-token"),
browser().header("authorization", format!("Bearer {TOKEN}")),
] {
let case = format!("{request:?}");
let request = parts(request);
let (users, calls) = run(|| stackure::directory_users(&request, &opts));
assert_eq!(
users,
Err(StackureError::Auth("invalid session".into())),
"{case}"
);
assert!(calls.is_empty(), "{case}");
let (teams, calls) = run(|| stackure::directory_teams(&request, &opts));
assert_eq!(
teams,
Err(StackureError::Auth("invalid session".into())),
"{case}"
);
assert!(calls.is_empty(), "{case}");
}
}