#![allow(dead_code)]
#![allow(unreachable_pub)]
#![allow(clippy::disallowed_types)]
#![allow(clippy::missing_panics_doc)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use base64ct::{Base64UrlUnpadded, Encoding};
use bytes::Bytes;
use cratefield_core::{
AnyError, BoxFuture, Clock, Config, ConfigError, Defer, EventHandler, EventName, HttpClient,
HttpError, MapConfig, Migrations, Module, ModuleContext, Notification, PersonalDataCatalog,
Port, Ports, Push, PushError, PushOutcome, Recipient, ScheduledBudget, Scope, Statement,
TemplateRegistry, UlidIdGen, Venture,
};
use cratefield_module_notifications::{Category, Notifications, Notifier, Transport};
use cratefield_testing::{FakePush, PushMode, TestHarness};
use p256::ecdsa::signature::Signer as _;
use p256::ecdsa::{self, Signature};
use serde_json::{Value, json};
use time::OffsetDateTime;
pub const ISSUER: &str = "https://auth.test.example";
pub const CLIENT: &str = "client-notifications";
pub const NOW: i64 = 1_800_000_000;
pub const ALICE: &str = "acct-alice";
pub const BOB: &str = "acct-bob";
pub const BOOKING: &str = "booking";
pub const COACH_NOTES: &str = "coach_notes";
pub const ROOM_STARTING: &str = "room_starting";
fn signing_key() -> (ecdsa::SigningKey, Value) {
let secret = p256::SecretKey::from_slice(&[7u8; 32]).expect("a valid scalar");
let signing = ecdsa::SigningKey::from(&secret);
let point = signing.verifying_key().to_sec1_point(false);
let sec1 = point.as_bytes();
assert_eq!(sec1.len(), 65, "uncompressed point");
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"kid": "test-key-1",
"x": Base64UrlUnpadded::encode_string(&sec1[1..33]),
"y": Base64UrlUnpadded::encode_string(&sec1[33..65]),
});
(signing, jwk)
}
fn b64(bytes: &[u8]) -> String {
Base64UrlUnpadded::encode_string(bytes)
}
pub fn token_for(sub: &str) -> String {
token_with(&json!({
"sub": sub,
"aud": CLIENT,
"iss": ISSUER,
"sid": "session-1",
"exp": NOW + 3_600,
"iat": NOW - 10,
}))
}
pub fn token_for_verified_email(sub: &str, email: &str) -> String {
token_with(&json!({
"sub": sub,
"aud": CLIENT,
"iss": ISSUER,
"sid": "session-1",
"exp": NOW + 3_600,
"iat": NOW - 10,
"email": email,
"email_verified": true,
}))
}
pub fn token_with(claims: &Value) -> String {
let (signing, _) = signing_key();
let header = json!({ "alg": "ES256", "kid": "test-key-1", "typ": "at+jwt" });
let input = format!(
"{}.{}",
b64(serde_json::to_string(&header).expect("header").as_bytes()),
b64(serde_json::to_string(claims).expect("claims").as_bytes())
);
let signature: Signature = signing.sign(input.as_bytes());
format!("{input}.{}", b64(&signature.to_bytes()))
}
pub struct StaticJwks {
body: String,
fetches: Arc<AtomicUsize>,
}
impl StaticJwks {
pub fn new() -> Self {
let (_, jwk) = signing_key();
Self {
body: json!({ "keys": [jwk] }).to_string(),
fetches: Arc::new(AtomicUsize::new(0)),
}
}
pub fn fetches(&self) -> Arc<AtomicUsize> {
Arc::clone(&self.fetches)
}
}
#[async_trait]
impl HttpClient for StaticJwks {
async fn send(
&self,
_request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
self.fetches.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(200)
.body(Bytes::from(self.body.clone()))
.map_err(|err| HttpError::Transport(err.to_string()))
}
}
#[derive(Default)]
pub struct EventLog(Mutex<Vec<(String, Value)>>);
impl EventLog {
pub fn all(&self) -> Vec<(String, Value)> {
self.0.lock().expect("event log").clone()
}
pub fn payloads(&self, event: &str) -> Vec<Value> {
self.0
.lock()
.expect("event log")
.iter()
.filter(|(name, _)| name == event)
.map(|(_, payload)| payload.clone())
.collect()
}
}
pub struct EventProbe {
pub log: Arc<EventLog>,
}
impl Module for EventProbe {
fn name(&self) -> &'static str {
"event-probe"
}
fn version(&self) -> &'static str {
"0.0.0"
}
fn requires(&self) -> &'static [Port] {
&[]
}
fn migrations(&self) -> Migrations {
Migrations::EMPTY
}
fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
Ok(())
}
fn router(&self, _ctx: ModuleContext) -> axum::Router {
axum::Router::new()
}
fn events(&self) -> Vec<(EventName, EventHandler)> {
[
cratefield_module_notifications::EVENT_SUBSCRIPTION_PRUNED,
cratefield_module_notifications::EVENT_SUBSCRIPTION_REHOMED,
cratefield_module_notifications::EVENT_MISSING_TRANSLATION,
]
.into_iter()
.map(|event| {
let log = Arc::clone(&self.log);
let handler: EventHandler = Arc::new(move |_scope, payload| {
let log = Arc::clone(&log);
Box::pin(async move {
log.0
.lock()
.expect("event log")
.push((event.to_owned(), payload));
Ok::<(), AnyError>(())
}) as BoxFuture<'static, Result<(), AnyError>>
});
(event.to_owned(), handler)
})
.collect()
}
}
#[derive(Clone)]
pub struct TestClock(Arc<Mutex<OffsetDateTime>>);
impl TestClock {
pub fn at(unix: i64) -> Self {
Self(Arc::new(Mutex::new(
OffsetDateTime::from_unix_timestamp(unix).expect("in range"),
)))
}
pub fn advance(&self, seconds: i64) {
let mut now = self.0.lock().expect("clock");
*now = now.saturating_add(time::Duration::seconds(seconds));
}
pub fn now_unix(&self) -> i64 {
self.0.lock().expect("clock").unix_timestamp()
}
}
impl Clock for TestClock {
fn now(&self) -> OffsetDateTime {
*self.0.lock().expect("clock")
}
}
#[derive(Clone, Default)]
pub struct ScriptedPush {
inner: Arc<ScriptedInner>,
}
#[derive(Default)]
struct ScriptedInner {
queue: Mutex<Vec<Result<PushOutcome, PushError>>>,
calls: Mutex<Vec<(Recipient, Notification)>>,
}
impl ScriptedPush {
pub fn new(results: Vec<Result<PushOutcome, PushError>>) -> Self {
let mut queue = results;
queue.reverse();
Self {
inner: Arc::new(ScriptedInner {
queue: Mutex::new(queue),
calls: Mutex::new(Vec::new()),
}),
}
}
pub fn calls(&self) -> Vec<(Recipient, Notification)> {
self.inner.calls.lock().expect("script").clone()
}
}
#[async_trait]
impl Push for ScriptedPush {
async fn send(
&self,
to: &Recipient,
notification: &Notification,
) -> Result<PushOutcome, PushError> {
self.inner
.calls
.lock()
.expect("script")
.push((to.clone(), notification.clone()));
self.inner
.queue
.lock()
.expect("script")
.pop()
.unwrap_or(Ok(PushOutcome::Delivered { id: None }))
}
}
#[derive(Clone)]
pub struct RacingDb {
inner: Arc<dyn cratefield_core::Database>,
interloper: Arc<Mutex<Option<(String, Statement)>>>,
}
impl RacingDb {
pub fn new(inner: Arc<dyn cratefield_core::Database>) -> Self {
Self {
inner,
interloper: Arc::new(Mutex::new(None)),
}
}
pub fn interleave(&self, when: &str, run: Statement) {
*self.interloper.lock().expect("interloper") = Some((when.to_owned(), run));
}
pub fn fired(&self) -> bool {
self.interloper.lock().expect("interloper").is_none()
}
async fn maybe_race(&self, sql: &str) {
let armed = {
let mut slot = self.interloper.lock().expect("interloper");
match slot.as_ref() {
Some((when, _)) if sql.contains(when.as_str()) => slot.take().map(|(_, run)| run),
_ => None,
}
};
if let Some(statement) = armed {
self.inner
.execute(&statement)
.await
.expect("the concurrent write commits");
}
}
}
#[async_trait]
impl cratefield_core::Database for RacingDb {
async fn execute(&self, stmt: &Statement) -> Result<u64, cratefield_core::DbError> {
self.maybe_race(&stmt.sql).await;
self.inner.execute(stmt).await
}
async fn query(
&self,
stmt: &Statement,
) -> Result<cratefield_core::Rows, cratefield_core::DbError> {
self.inner.query(stmt).await
}
async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), cratefield_core::DbError> {
for stmt in stmts {
self.maybe_race(&stmt.sql).await;
}
self.inner.batch_atomic(stmts).await
}
}
#[derive(Clone, Default)]
pub struct ConcurrentPush {
inner: Arc<ConcurrentInner>,
}
#[derive(Default)]
struct ConcurrentInner {
in_flight: AtomicUsize,
peak: AtomicUsize,
calls: AtomicUsize,
}
impl ConcurrentPush {
pub fn peak_in_flight(&self) -> usize {
self.inner.peak.load(Ordering::SeqCst)
}
pub fn calls(&self) -> usize {
self.inner.calls.load(Ordering::SeqCst)
}
}
struct YieldOnce(bool);
impl std::future::Future for YieldOnce {
type Output = ();
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<()> {
if self.0 {
std::task::Poll::Ready(())
} else {
self.0 = true;
cx.waker().wake_by_ref();
std::task::Poll::Pending
}
}
}
#[async_trait]
impl Push for ConcurrentPush {
async fn send(
&self,
_to: &Recipient,
_notification: &Notification,
) -> Result<PushOutcome, PushError> {
let now = self.inner.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
self.inner.peak.fetch_max(now, Ordering::SeqCst);
YieldOnce(false).await;
self.inner.calls.fetch_add(1, Ordering::SeqCst);
self.inner.in_flight.fetch_sub(1, Ordering::SeqCst);
Ok(PushOutcome::Delivered { id: None })
}
}
pub struct Kit {
pub harness: TestHarness,
pub notifier: Notifier,
pub events: Arc<EventLog>,
pub clock: TestClock,
pub scheduled_push: FakePush,
pub jwks_fetches: Arc<AtomicUsize>,
config: Arc<dyn Config>,
}
pub fn categories() -> Vec<Category> {
vec![
Category::new(BOOKING),
Category::new(COACH_NOTES).default_enabled(false),
Category::new(ROOM_STARTING).badge(true),
]
}
pub fn kit() -> Kit {
kit_with(
Arc::new(cratefield_testing::FakePush::new(
cratefield_testing::PushMode::DeliverOk,
)),
categories(),
&[],
)
}
pub fn kit_with(push: Arc<dyn Push>, categories: Vec<Category>, config: &[(&str, &str)]) -> Kit {
build_kit(
push,
categories,
config,
Wiring::default(),
|module| module,
|db| db,
)
}
pub fn kit_customised(
push: Arc<dyn Push>,
categories: Vec<Category>,
config: &[(&str, &str)],
customise: impl FnOnce(Notifications) -> Notifications,
) -> Kit {
build_kit(
push,
categories,
config,
Wiring::default(),
customise,
|db| db,
)
}
pub fn kit_without_signer(
push: Arc<dyn Push>,
categories: Vec<Category>,
config: &[(&str, &str)],
) -> Kit {
build_kit(
push,
categories,
config,
Wiring {
no_signer: true,
..Wiring::default()
},
|module| module,
|db| db,
)
}
pub const VAPID_KEY_CONFIG: &str = "TEST_APPLICATION_SERVER_KEY";
pub fn kit_serving_vapid(key: &str) -> Kit {
build_kit(
Arc::new(cratefield_testing::FakePush::new(
cratefield_testing::PushMode::DeliverOk,
)),
categories(),
&[(VAPID_KEY_CONFIG, key)],
Wiring {
vapid_key_probe: true,
..Wiring::default()
},
|module| module,
|db| db,
)
}
pub fn kit_racing(
push: Arc<dyn Push>,
categories: Vec<Category>,
config: &[(&str, &str)],
) -> (Kit, RacingDb) {
let racing: Arc<Mutex<Option<RacingDb>>> = Arc::new(Mutex::new(None));
let captured = Arc::clone(&racing);
let kit = build_kit(
push,
categories,
config,
Wiring::default(),
|module| module,
move |db| {
let wrapper = RacingDb::new(db);
*captured.lock().expect("racing db") = Some(wrapper.clone());
Arc::new(wrapper)
},
);
let wrapper = racing.lock().expect("racing db").clone().expect("wrapped");
(kit, wrapper)
}
#[derive(Default, Clone, Copy)]
struct Wiring {
vapid_key_probe: bool,
no_signer: bool,
}
fn build_kit(
push: Arc<dyn Push>,
categories: Vec<Category>,
config: &[(&str, &str)],
wiring: Wiring,
customise: impl FnOnce(Notifications) -> Notifications,
wrap_db: impl FnOnce(Arc<dyn cratefield_core::Database>) -> Arc<dyn cratefield_core::Database>,
) -> Kit {
let jwks = StaticJwks::new();
let jwks_fetches = jwks.fetches();
let mut module = Notifications::new();
let notifier = module.notifier();
for category in categories {
module = module.category(category);
}
if wiring.vapid_key_probe {
module = module.vapid_public_key(|cfg| cfg.get(VAPID_KEY_CONFIG));
}
let module = customise(module);
let events = Arc::new(EventLog::default());
let clock = TestClock::at(NOW);
let scheduled_push = FakePush::new(PushMode::DeliverOk);
let mut pairs: Vec<(String, String)> = vec![
("HARNESS_SECRET".to_owned(), TEST_SECRET.to_owned()),
("NOTIFICATIONS_AUTH_ISSUER".to_owned(), ISSUER.to_owned()),
("NOTIFICATIONS_AUTH_CLIENT_ID".to_owned(), CLIENT.to_owned()),
];
for (key, value) in config {
pairs.push(((*key).to_owned(), (*value).to_owned()));
}
let map: Arc<dyn Config> = Arc::new(MapConfig::from_pairs(pairs));
let probe = EventProbe {
log: Arc::clone(&events),
};
let ports_config = Arc::clone(&map);
let ports_clock = clock.clone();
let harness = TestHarness::with_ports(vec![Box::new(module), Box::new(probe)], move |ports| {
ports.push = Some(push);
ports.http = Some(Arc::new(jwks));
ports.clock = Some(Arc::new(ports_clock));
ports.config = ports_config;
if wiring.no_signer {
ports.signer = None;
}
if let Some(db) = ports.db.take() {
ports.db = Some(wrap_db(db));
}
});
Kit {
harness,
notifier,
events,
clock,
scheduled_push,
jwks_fetches,
config: map,
}
}
const TEST_SECRET: &str = "cratefield-testing-dummy-secret-0123456789";
impl Kit {
pub fn scope(&self) -> Scope {
let defer: Arc<dyn Defer> = Arc::new(self.harness.defer.clone());
Scope {
request_id: "test-request-0123".to_owned(),
defer,
span: tracing::Span::none(),
}
}
pub fn scheduled_context(&self) -> ModuleContext {
self.context_over(Arc::new(self.scheduled_push.clone()), None)
}
pub fn context_over(
&self,
push: Arc<dyn Push>,
http: Option<Arc<dyn HttpClient>>,
) -> ModuleContext {
let mut ports = Ports::with_config(Arc::clone(&self.config));
ports.db = Some(Arc::clone(&self.harness.db));
ports.push = Some(push);
ports.http = http;
ports.clock = Some(Arc::new(self.clock.clone()));
ports.defer = Some(Arc::new(self.harness.defer.clone()));
ports.id_gen = Some(Arc::new(UlidIdGen));
ModuleContext {
ports,
config: Arc::clone(&self.config),
events: self.harness.harness.events().clone(),
templates: Arc::new(TemplateRegistry::default()),
venture: Arc::new(Venture::new("test-venture", "test.example")),
unprotected_writes_accepted: false,
ui_mounted: false,
personal_data: Arc::new(PersonalDataCatalog::default()),
scheduled: Arc::new(ScheduledBudget::unbounded()),
}
}
pub fn module(&self) -> Arc<dyn Module> {
Arc::clone(
self.harness
.modules
.iter()
.find(|module| module.name() == "notifications")
.expect("the notifications module is mounted"),
)
}
pub fn db(&self) -> Arc<dyn cratefield_core::Database> {
Arc::clone(&self.harness.db)
}
pub async fn count(&self, table: &str) -> i64 {
let rows = self
.harness
.db
.query(&Statement::new(format!(
"SELECT COUNT(*) AS n FROM {table}"
)))
.await
.expect("count");
rows.first()
.and_then(|row| row.get::<i64>("n"))
.unwrap_or(-1)
}
pub async fn rows(&self, table: &str) -> Vec<cratefield_core::Row> {
self.harness
.db
.query(&Statement::new(format!("SELECT * FROM {table}")))
.await
.expect("select")
.rows
}
}
pub struct Answer {
pub status: http::StatusCode,
pub body: Bytes,
}
impl Answer {
pub fn json(&self) -> Value {
serde_json::from_slice(&self.body)
.unwrap_or_else(|err| panic!("body is not JSON ({err}): {:?}", self.text()))
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
pub async fn send(
router: &axum::Router,
method: http::Method,
path: &str,
token: Option<&str>,
body: Option<Value>,
) -> Answer {
use tower::ServiceExt as _;
let mut builder = http::Request::builder().method(method).uri(path);
if let Some(token) = token {
builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
let payload = match body {
Some(value) => {
builder = builder.header(http::header::CONTENT_TYPE, "application/json");
axum::body::Body::from(value.to_string())
}
None => axum::body::Body::empty(),
};
let response = router
.clone()
.oneshot(builder.body(payload).expect("request builds"))
.await
.expect("router answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("body reads");
Answer { status, body }
}
pub async fn send_unmounted(
router: &axum::Router,
method: http::Method,
path: &str,
token: Option<&str>,
) -> Answer {
use tower::ServiceExt as _;
let mut builder = http::Request::builder()
.method(method)
.uri(path)
.extension(Scope {
request_id: "test-request-direct".to_owned(),
defer: Arc::new(cratefield_core::NoopDefer),
span: tracing::Span::none(),
});
if let Some(token) = token {
builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = router
.clone()
.oneshot(
builder
.body(axum::body::Body::empty())
.expect("request builds"),
)
.await
.expect("router answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("body reads");
Answer { status, body }
}
pub async fn send_raw(
router: &axum::Router,
method: http::Method,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> Answer {
use tower::ServiceExt as _;
let mut builder = http::Request::builder()
.method(method)
.uri(path)
.header(http::header::CONTENT_TYPE, "application/json");
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let response = router
.clone()
.oneshot(
builder
.body(axum::body::Body::from(body.to_owned()))
.expect("request builds"),
)
.await
.expect("router answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("body reads");
Answer { status, body }
}
pub async fn send_with_headers(
router: &axum::Router,
method: http::Method,
path: &str,
token: &str,
headers: &[(&str, &str)],
body: Option<Value>,
) -> Answer {
use tower::ServiceExt as _;
let mut builder = http::Request::builder()
.method(method)
.uri(path)
.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let payload = match body {
Some(value) => {
builder = builder.header(http::header::CONTENT_TYPE, "application/json");
axum::body::Body::from(value.to_string())
}
None => axum::body::Body::empty(),
};
let response = router
.clone()
.oneshot(builder.body(payload).expect("request builds"))
.await
.expect("router answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("body reads");
Answer { status, body }
}
pub fn register_body(transport: Transport, recipient: &cratefield_core::Recipient) -> Value {
json!({
"transport": transport,
"recipient": recipient,
})
}