#![cfg(feature = "graphql")]
use axum::body::Body;
use axum::http::Request;
use axum::Router;
use jsonwebtoken::{encode, EncodingKey, Header};
use postrust_core::AppConfig;
use postrust_server::{build, Host};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tower::ServiceExt;
const SECRET: &str = "reallyreallyreallyreallyverysafe";
fn config(secret: Option<&str>) -> AppConfig {
let mut config = AppConfig::default();
config.db_uri = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/postrust_test".into());
config.db_schemas = vec!["public".to_string()];
config.db_anon_role = Some("web_anon".to_string());
config.jwt_secret = secret.map(str::to_string);
config
}
async fn router(secret: Option<&str>) -> Router {
build(config(secret), Host::LAMBDA)
.await
.expect("build")
.router
}
fn token(secret: &str, claims: Value) -> String {
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
async fn ask(app: &Router, authorization: Option<String>) -> Value {
let headers: Vec<(&str, String)> = authorization
.into_iter()
.map(|value| ("authorization", value))
.collect();
post(app, &headers, json!({"query": "{ __typename }"})).await
}
async fn post(app: &Router, headers: &[(&str, String)], body: Value) -> Value {
let mut request = Request::post("/v1/graphql").header("content-type", "application/json");
for (name, value) in headers {
request = request.header(*name, value);
}
let response = app
.clone()
.oneshot(request.body(Body::from(body.to_string())).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&body).unwrap()
}
fn refused_as_invalid_jwt(body: &Value) -> bool {
body.pointer("/errors/0/extensions/code") == Some(&json!("invalid-jwt"))
&& body.get("data").is_none_or(Value::is_null)
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_token_that_does_not_verify_is_refused() {
let app = router(Some(SECRET)).await;
let far = 4_000_000_000_u64;
let answered = ask(&app, None).await;
assert_eq!(answered["data"]["__typename"], "query_root", "{answered}");
let valid = token(SECRET, json!({"role": "web_user", "exp": far}));
let answered = ask(&app, Some(format!("Bearer {valid}"))).await;
assert_eq!(answered["data"]["__typename"], "query_root", "{answered}");
for (what, header) in [
(
"signed with another key",
format!(
"Bearer {}",
token(
"another-secret-another-secret-xx",
json!({"role": "web_user", "exp": far})
)
),
),
(
"expired",
format!(
"Bearer {}",
token(SECRET, json!({"role": "web_user", "exp": 1}))
),
),
("not a JWT", "Bearer nope".to_string()),
("not a bearer token", format!("Basic {valid}")),
] {
let answered = ask(&app, Some(header)).await;
assert!(refused_as_invalid_jwt(&answered), "{what}: {answered}");
}
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn without_a_secret_the_header_is_ignored() {
let app = router(None).await;
let answered = ask(&app, Some("Bearer anything".to_string())).await;
assert_eq!(answered["data"]["__typename"], "query_root", "{answered}");
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_batch_under_a_bad_token_is_refused_in_every_part() {
let app = router(Some(SECRET)).await;
let answered = post(
&app,
&[("authorization", "Bearer nope".to_string())],
json!([{"query": "{ __typename }"}, {"query": "{ __typename }"}]),
)
.await;
let parts = answered.as_array().expect("an array");
assert_eq!(parts.len(), 2, "{answered}");
assert!(parts.iter().all(refused_as_invalid_jwt), "{answered}");
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn the_admin_secret_wins_over_a_bad_token() {
let mut config = config(Some(SECRET));
config.hasura_admin_secret = Some("s3cret".to_string());
let app = build(config, Host::LAMBDA).await.expect("build").router;
let answered = post(
&app,
&[
("authorization", "Bearer nope".to_string()),
("x-hasura-admin-secret", "s3cret".to_string()),
],
json!({"query": "{ __typename }"}),
)
.await;
assert_eq!(answered["data"]["__typename"], "query_root", "{answered}");
let answered = ask(&app, Some("Bearer nope".to_string())).await;
assert!(refused_as_invalid_jwt(&answered), "{answered}");
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_verified_token_naming_no_role_is_refused_without_an_anonymous_role() {
let mut config = config(Some(SECRET));
config.db_anon_role = None;
let app = build(config, Host::LAMBDA).await.expect("build").router;
let roleless = token(SECRET, json!({"sub": "someone", "exp": 4_000_000_000_u64}));
let answered = ask(&app, Some(format!("Bearer {roleless}"))).await;
assert_eq!(
answered.pointer("/errors/0/extensions/code"),
Some(&json!("jwt-missing-role-claims")),
"{answered}"
);
assert_eq!(
answered.pointer("/errors/0/message"),
Some(&json!("JWT claim does not contain role")),
"{answered}"
);
assert!(
answered.get("data").is_none_or(Value::is_null),
"{answered}"
);
let answered = ask(&app, None).await;
assert_eq!(answered["data"]["__typename"], "query_root", "{answered}");
let app = router(Some(SECRET)).await;
let answered = ask(&app, Some(format!("Bearer {roleless}"))).await;
assert_eq!(answered["data"]["__typename"], "query_root", "{answered}");
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn a_bad_token_is_refused_at_connection_init() {
let app = build(config(Some(SECRET)), Host::SERVER)
.await
.expect("build")
.router;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let valid = token(
SECRET,
json!({"role": "web_user", "exp": 4_000_000_000_u64}),
);
let answered = connection_init(address, &format!("Bearer {valid}")).await;
assert_eq!(answered["type"], "connection_ack", "{answered}");
let answered = connection_init(address, "Bearer nope").await;
assert_eq!(answered["type"], "connection_error", "{answered}");
let message = answered["payload"]["message"].as_str().unwrap_or_default();
assert!(message.starts_with("Could not verify JWT"), "{answered}");
}
async fn connection_init(address: std::net::SocketAddr, authorization: &str) -> Value {
let mut stream = TcpStream::connect(address).await.unwrap();
let handshake = format!(
"GET /v1/graphql/ws HTTP/1.1\r\nHost: {address}\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: graphql-ws\r\n\r\n"
);
stream.write_all(handshake.as_bytes()).await.unwrap();
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
head.push(stream.read_u8().await.unwrap());
}
let head = String::from_utf8_lossy(&head);
assert!(head.starts_with("HTTP/1.1 101"), "{head}");
let payload =
json!({"type": "connection_init", "payload": {"headers": {"Authorization": authorization}}})
.to_string();
let mut frame = vec![0x81];
match payload.len() {
n if n < 126 => frame.push(0x80 | n as u8),
n => {
frame.push(0x80 | 126);
frame.extend_from_slice(&(n as u16).to_be_bytes());
}
}
frame.extend_from_slice(&[0; 4]);
frame.extend_from_slice(payload.as_bytes());
stream.write_all(&frame).await.unwrap();
loop {
let opcode = stream.read_u8().await.unwrap() & 0x0f;
let length = match stream.read_u8().await.unwrap() & 0x7f {
126 => u64::from(stream.read_u16().await.unwrap()),
127 => stream.read_u64().await.unwrap(),
n => u64::from(n),
};
let mut body = vec![0; length as usize];
stream.read_exact(&mut body).await.unwrap();
if opcode == 0x1 {
return serde_json::from_slice(&body).unwrap();
}
assert_ne!(opcode, 0x8, "closed: {}", String::from_utf8_lossy(&body));
}
}