#![allow(clippy::unwrap_used, clippy::panic)]
use std::sync::{Arc, Mutex};
use chrono::Utc;
use fraiseql_core::security::{GuestQueryBridge, SecurityContext};
use super::BeforeMutationHost;
use crate::{
HostContext,
types::{EventPayload, LogLevel},
};
struct RecordingReader {
asked: Mutex<Vec<(String, Option<serde_json::Value>)>>,
}
impl GuestQueryBridge for RecordingReader {
fn query<'a>(
&'a self,
graphql: &'a str,
variables: Option<&'a serde_json::Value>,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = fraiseql_error::Result<serde_json::Value>> + Send + 'a,
>,
> {
self.asked.lock().unwrap().push((graphql.to_string(), variables.cloned()));
Box::pin(async { Ok(serde_json::json!({ "data": { "credit": 42 } })) })
}
}
fn payload() -> EventPayload {
EventPayload {
trigger_type: "before:mutation:pay".to_string(),
entity: "pay".to_string(),
event_kind: "before".to_string(),
data: serde_json::json!({ "input": { "amount": 10 } }),
timestamp: Utc::now(),
}
}
fn principal() -> SecurityContext {
SecurityContext {
user_id: "u1".into(),
roles: vec!["payer".to_string()],
tenant_id: None,
scopes: vec![],
attributes: std::collections::HashMap::default(),
request_id: "req-1328".to_string(),
ip_address: Some("203.0.113.7".to_string()),
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn host_with_reader() -> (BeforeMutationHost, Arc<RecordingReader>) {
let reader = Arc::new(RecordingReader {
asked: Mutex::new(Vec::new()),
});
let host = BeforeMutationHost::new(
payload(),
Some(Arc::clone(&reader) as Arc<dyn GuestQueryBridge>),
Some(&principal()),
);
(host, reader)
}
#[tokio::test]
async fn the_read_answers() {
let (host, reader) = host_with_reader();
let value = host
.query("{ credit { remaining } }", serde_json::json!({ "id": 1 }))
.await
.expect("the read bridge is the one op a before-hook has");
assert_eq!(value["data"]["credit"], 42);
assert_eq!(
reader.asked.lock().unwrap()[0],
("{ credit { remaining } }".to_string(), Some(serde_json::json!({ "id": 1 }))),
"the document and its variables must reach the bridge verbatim"
);
}
#[tokio::test]
async fn absent_variables_are_absent_not_null() {
let (host, reader) = host_with_reader();
host.query("{ credit { remaining } }", serde_json::Value::Null).await.unwrap();
assert_eq!(reader.asked.lock().unwrap()[0].1, None);
}
#[tokio::test]
async fn every_op_outside_the_surface_refuses_by_name() {
let (host, _reader) = host_with_reader();
let refusals: Vec<(&str, fraiseql_error::FraiseQLError)> = vec![
("sql_query", host.sql_query("SELECT 1", &[]).await.unwrap_err()),
(
"http_request",
host.http_request("GET", "https://example.tld", &[], None).await.unwrap_err(),
),
("storage_get", host.storage_get("b", "k").await.unwrap_err()),
("storage_put", host.storage_put("b", "k", b"x", "text/plain").await.unwrap_err()),
("env_var", host.env_var("PATH").unwrap_err()),
];
for (op, error) in refusals {
match &error {
fraiseql_error::FraiseQLError::Authorization {
action, resource, ..
} => {
assert_eq!(action.as_deref(), Some(op), "the refusal must name the op: {error:?}");
assert_eq!(
resource.as_deref(),
Some("before:mutation"),
"the refusal must name the surface: {error:?}"
);
},
other => panic!("`{op}` must refuse with its own diagnosis, got {other:?}"),
}
}
}
#[tokio::test]
async fn send_email_refuses_by_name() {
let (host, _reader) = host_with_reader();
let request = crate::outbound::SendEmailRequest {
to: "a@example.tld".to_string(),
subject: "hi".to_string(),
text: Some("hi".to_string()),
html: None,
reply_to: None,
};
let error = host.send_email(&request).await.unwrap_err();
assert!(
matches!(&error, fraiseql_error::FraiseQLError::Authorization { action, .. }
if action.as_deref() == Some("send_email")),
"{error:?}"
);
}
#[tokio::test]
async fn the_auth_context_is_the_callers_and_carries_no_ip() {
let (host, _reader) = host_with_reader();
let context = host.auth_context().expect("an authenticated write has a context");
assert_eq!(context["sub"], "u1");
assert_eq!(context["roles"][0], "payer");
assert!(
context.get("ip_address").is_none(),
"the guest projection must not carry the caller's IP: {context}"
);
}
#[tokio::test]
async fn an_anonymous_write_has_no_auth_context() {
let host = BeforeMutationHost::new(payload(), None, None);
let error = host.auth_context().unwrap_err();
assert!(
error.to_string().contains("anonymously"),
"the refusal must say the write was anonymous, not that wiring is missing: {error}"
);
}
#[tokio::test]
async fn a_host_with_no_bridge_refuses_the_read() {
let host = BeforeMutationHost::new(payload(), None, Some(&principal()));
let error = host
.query("{ credit { remaining } }", serde_json::Value::Null)
.await
.unwrap_err();
assert!(error.to_string().contains("read bridge"), "{error}");
}
#[tokio::test]
async fn the_payload_is_the_writes_arguments() {
let (host, _reader) = host_with_reader();
assert_eq!(host.event_payload().data["input"]["amount"], 10);
assert_eq!(host.event_payload().event_kind, "before");
}
#[tokio::test]
async fn logging_is_on_the_surface() {
let (host, _reader) = host_with_reader();
for level in [
LogLevel::Debug,
LogLevel::Info,
LogLevel::Warn,
LogLevel::Error,
] {
host.log(level, "a guest diagnostic");
}
}
#[tokio::test]
async fn a_hook_is_neither_a_dispatch_nor_a_source() {
let (host, _reader) = host_with_reader();
assert!(host.idempotency_token().is_none());
assert!(host.cursor().await.unwrap().is_none());
assert!(host.advance_cursor(serde_json::json!(1)).await.is_err());
}