M4: sync pull and push (E-31, E-32), audit writer and diff, FCM sender, push batching, scheduler, pruning, rotate-epoch
- src/sync/: pull with UNION ALL paging and cursor_ahead; push processing of SPEC §14.4.4 steps 1 to 12 in one BEGIN IMMEDIATE with a savepoint per mutation, LWW with delete-wins, tombstones, clock_skew, replay from sync_mutations, sync_batch for more than 3 notifiable changes; 90-day pruning. - src/audit/: SqlAuditWriter and the §14.4.7 diff (now the default in AppState::new). - src/push/: bounded PushHub (100, drop oldest), FCM HTTP v1 client with service-account JWT (RS256, jsonwebtoken on aws-lc-rs) and token cache, sender with 1/5/25 s retries and token clearing; disabled without credentials. - src/tasks/: start installs push and spawns the 06:00 generation (and at start) and 04:00 pruning loops on the injectable clock. - cli: rotate-epoch; serve uses into_make_service_with_connect_info; stale doc comments updated; /i/ empty code gets the HTML 404. - tests: sync_api, push_fcm (local stub of Google's endpoints), scheduler_daily; skeleton routes test now asserts no route is 501. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
//! The FCM sender against the stub (SPEC §14.6).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
||||
use kvitt_server::push::fcm::{SCOPE, TOKEN_URL, message_body};
|
||||
use kvitt_server::push::queue::QUEUE_CAPACITY;
|
||||
use kvitt_server::push::sender::Delivery;
|
||||
use kvitt_server::push::{
|
||||
FcmClient, PushHub, PushMessage, PushQueue, PushType, RetryPolicy, Sender,
|
||||
};
|
||||
use kvitt_server::time::{format_ts, parse_ts};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4;
|
||||
use crate::stub::*;
|
||||
|
||||
const EXPENSE: &str = "01929c5e-1111-7aaa-8bbb-000000000001";
|
||||
const FAST: [u64; 3] = [10, 20, 40];
|
||||
|
||||
struct Setup {
|
||||
app: TestApp,
|
||||
stub: Stub,
|
||||
key: TestKey,
|
||||
hub: Arc<PushHub>,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
async fn new() -> Self {
|
||||
let app = m4::app().await;
|
||||
for (device, tok) in [
|
||||
(ADMIN_DEVICE_ID, "tok-admin"),
|
||||
(MEMBER_DEVICE_ID, "tok-member"),
|
||||
(ADMIN_OLD_DEVICE_ID, "tok-revoked"),
|
||||
] {
|
||||
set_fcm_token(&app, device, Some(tok)).await;
|
||||
}
|
||||
Self {
|
||||
app,
|
||||
stub: Stub::start().await,
|
||||
key: service_account(),
|
||||
hub: Arc::new(PushHub::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(&self) -> FcmClient {
|
||||
FcmClient::new(
|
||||
&self.key.json,
|
||||
"kvitt-1337",
|
||||
self.stub.endpoints(),
|
||||
self.app.state.clock.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sender(&self) -> Sender {
|
||||
Sender::new(
|
||||
self.app.state.db.clone(),
|
||||
self.hub.clone(),
|
||||
self.client(),
|
||||
RetryPolicy {
|
||||
delays: FAST.iter().map(|ms| Duration::from_millis(*ms)).collect(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_fcm_token(app: &TestApp, device: &str, tok: Option<&str>) {
|
||||
sqlx::query("UPDATE devices SET fcm_token = ? WHERE id = ?")
|
||||
.bind(tok)
|
||||
.bind(device)
|
||||
.execute(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn fcm_token(app: &TestApp, device: &str) -> Option<String> {
|
||||
sqlx::query_scalar("SELECT fcm_token FROM devices WHERE id = ?")
|
||||
.bind(device)
|
||||
.fetch_one(&app.state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn message(push_type: PushType, actor: &str) -> PushMessage {
|
||||
PushMessage {
|
||||
push_type,
|
||||
entity_id: (push_type != PushType::SyncBatch).then(|| EXPENSE.parse().unwrap()),
|
||||
actor_user_id: actor.parse().unwrap(),
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_of_every_type_equals_the_contract_example() {
|
||||
let all = [
|
||||
PushType::ExpenseCreated,
|
||||
PushType::ExpenseUpdated,
|
||||
PushType::ExpenseDeleted,
|
||||
PushType::SettlementCreated,
|
||||
PushType::SettlementUpdated,
|
||||
PushType::SettlementDeleted,
|
||||
PushType::RecurringExpenseCreated,
|
||||
PushType::SyncBatch,
|
||||
];
|
||||
for push_type in all {
|
||||
let expected = example(&format!("push/{}.json", push_type.as_str()));
|
||||
let data = &expected["message"]["data"];
|
||||
let entity_id = data["entity_id"].as_str().unwrap();
|
||||
let msg = PushMessage {
|
||||
push_type,
|
||||
entity_id: (!entity_id.is_empty()).then(|| entity_id.parse::<Uuid>().unwrap()),
|
||||
actor_user_id: data["actor_user_id"].as_str().unwrap().parse().unwrap(),
|
||||
count: data["count"].as_str().unwrap().parse().unwrap(),
|
||||
};
|
||||
let body = message_body(expected["message"]["token"].as_str().unwrap(), &msg);
|
||||
assert_eq!(body, expected, "{}", push_type.as_str());
|
||||
// Data only, every value a string (SPEC §14.6), ids only (AC-13.8).
|
||||
assert!(body["message"].get("notification").is_none());
|
||||
assert!(
|
||||
body["message"]["data"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.all(Value::is_string)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_jwt_and_send_request_follow_the_http_v1_flow() {
|
||||
let s = Setup::new().await;
|
||||
let deliveries = s
|
||||
.sender()
|
||||
.deliver(&message(PushType::ExpenseCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deliveries, [Delivery::Sent { attempts: 1 }]);
|
||||
|
||||
let tokens = s.stub.tokens();
|
||||
assert_eq!(tokens.len(), 1);
|
||||
let form = String::from_utf8(tokens[0].body.to_vec()).unwrap();
|
||||
let (grant, assertion) = form.split_once('&').unwrap();
|
||||
assert_eq!(
|
||||
grant,
|
||||
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant%2Dtype%3Ajwt%2Dbearer"
|
||||
);
|
||||
let jwt = assertion.strip_prefix("assertion=").unwrap();
|
||||
let header = jsonwebtoken::decode_header(jwt).unwrap();
|
||||
assert_eq!(header.alg, Algorithm::RS256);
|
||||
assert_eq!(header.kid.as_deref(), Some("test-key-1"));
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_audience(&[TOKEN_URL]);
|
||||
validation.set_issuer(&["kvitt-fcm@kvitt-1337.iam.gserviceaccount.com"]);
|
||||
// The scripted clock is in the future relative to the machine's: check exp by hand.
|
||||
validation.validate_exp = false;
|
||||
let claims: Value = jsonwebtoken::decode(
|
||||
jwt,
|
||||
&DecodingKey::from_rsa_der(&s.key.public_der),
|
||||
&validation,
|
||||
)
|
||||
.unwrap()
|
||||
.claims;
|
||||
assert_eq!(claims["scope"], SCOPE);
|
||||
let now = parse_ts(NOW).unwrap().timestamp();
|
||||
assert_eq!(claims["iat"], now);
|
||||
assert_eq!(claims["exp"], now + 3600);
|
||||
|
||||
// One message to the other user's one non-revoked device.
|
||||
let sends = s.stub.sends();
|
||||
assert_eq!(sends.len(), 1);
|
||||
assert_eq!(sends[0].path, "/v1/projects/kvitt-1337/messages:send");
|
||||
assert_eq!(
|
||||
sends[0].authorization.as_deref(),
|
||||
Some("Bearer ya29.stub-1")
|
||||
);
|
||||
let expected = json!({"message": {"token": "tok-member", "android": {"priority": "high", "ttl": "86400s"},
|
||||
"data": {"v": "1", "type": "expense_created", "entity_type": "expense", "entity_id": EXPENSE,
|
||||
"actor_user_id": ADMIN_ID, "count": "1"}}});
|
||||
assert_eq!(sends[0].json(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_token_is_cached_until_5_minutes_before_expiry() {
|
||||
let s = Setup::new().await;
|
||||
let sender = s.sender();
|
||||
let msg = message(PushType::ExpenseUpdated, ADMIN_ID);
|
||||
sender.deliver(&msg).await.unwrap();
|
||||
s.app.set_now(&format_ts(
|
||||
parse_ts(NOW).unwrap() + ChronoDuration::minutes(54),
|
||||
));
|
||||
sender.deliver(&msg).await.unwrap();
|
||||
assert_eq!(s.stub.tokens().len(), 1);
|
||||
s.app.set_now(&format_ts(
|
||||
parse_ts(NOW).unwrap() + ChronoDuration::minutes(55),
|
||||
));
|
||||
sender.deliver(&msg).await.unwrap();
|
||||
assert_eq!(s.stub.tokens().len(), 2);
|
||||
assert_eq!(
|
||||
s.stub.sends()[2].authorization.as_deref(),
|
||||
Some("Bearer ya29.stub-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recipients_are_the_other_users_live_devices_with_a_token_recurring_goes_to_both() {
|
||||
let s = Setup::new().await;
|
||||
let sender = s.sender();
|
||||
let ids = |r: Vec<kvitt_server::push::sender::Recipient>| {
|
||||
r.into_iter().map(|r| r.device_id).collect::<Vec<_>>()
|
||||
};
|
||||
// Admin acts: the member's device. Member acts: the admin's current device (the revoked one is skipped).
|
||||
assert_eq!(
|
||||
ids(sender
|
||||
.recipients(&message(PushType::SettlementCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap()),
|
||||
[MEMBER_DEVICE_ID]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(sender
|
||||
.recipients(&message(PushType::ExpenseDeleted, MEMBER_ID))
|
||||
.await
|
||||
.unwrap()),
|
||||
[ADMIN_DEVICE_ID]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(sender
|
||||
.recipients(&message(PushType::SyncBatch, MEMBER_ID))
|
||||
.await
|
||||
.unwrap()),
|
||||
[ADMIN_DEVICE_ID]
|
||||
);
|
||||
// AC-13.2: recurring to every live device of both users, the actor's included.
|
||||
assert_eq!(
|
||||
ids(sender
|
||||
.recipients(&message(PushType::RecurringExpenseCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap()),
|
||||
[ADMIN_DEVICE_ID, MEMBER_DEVICE_ID]
|
||||
);
|
||||
// A device without a token is skipped.
|
||||
set_fcm_token(&s.app, MEMBER_DEVICE_ID, None).await;
|
||||
assert!(
|
||||
sender
|
||||
.recipients(&message(PushType::ExpenseCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_retry_delays_are_1_5_25_seconds() {
|
||||
assert_eq!(
|
||||
RetryPolicy::default().delays,
|
||||
[
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(25)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_429_and_5xx_with_the_policy_delays_then_succeeds_or_gives_up() {
|
||||
let s = Setup::new().await;
|
||||
let sender = s.sender();
|
||||
let msg = message(PushType::ExpenseCreated, ADMIN_ID);
|
||||
s.stub.script_sends(&[
|
||||
(503, fcm_error(503, "UNAVAILABLE", "UNAVAILABLE")),
|
||||
(500, fcm_error(500, "INTERNAL", "INTERNAL")),
|
||||
(429, fcm_error(429, "RESOURCE_EXHAUSTED", "QUOTA_EXCEEDED")),
|
||||
]);
|
||||
let started = Instant::now();
|
||||
assert_eq!(
|
||||
sender.deliver(&msg).await.unwrap(),
|
||||
[Delivery::Sent { attempts: 4 }]
|
||||
);
|
||||
assert!(started.elapsed() >= Duration::from_millis(FAST.iter().sum()));
|
||||
assert_eq!(s.stub.sends().len(), 4);
|
||||
|
||||
s.stub.script_sends(&[
|
||||
(503, json!({})),
|
||||
(503, json!({})),
|
||||
(503, json!({})),
|
||||
(503, json!({})),
|
||||
]);
|
||||
let d = sender.deliver(&msg).await.unwrap();
|
||||
assert!(
|
||||
matches!(&d[0], Delivery::GaveUp { attempts: 4, .. }),
|
||||
"{d:?}"
|
||||
);
|
||||
assert_eq!(s.stub.sends().len(), 8);
|
||||
|
||||
// Other 4xx: no retry, token kept.
|
||||
s.stub
|
||||
.script_sends(&[(400, fcm_error(400, "INVALID_ARGUMENT", "INVALID_ARGUMENT"))]);
|
||||
let d = sender.deliver(&msg).await.unwrap();
|
||||
assert!(
|
||||
matches!(&d[0], Delivery::GaveUp { attempts: 1, .. }),
|
||||
"{d:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fcm_token(&s.app, MEMBER_DEVICE_ID).await.as_deref(),
|
||||
Some("tok-member")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn network_errors_are_retried() {
|
||||
let s = Setup::new().await;
|
||||
// A port nobody listens on.
|
||||
let dead = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = dead.local_addr().unwrap();
|
||||
drop(dead);
|
||||
let endpoints = kvitt_server::push::Endpoints {
|
||||
token_url: format!("http://{addr}/token"),
|
||||
send_base_url: format!("http://{addr}"),
|
||||
};
|
||||
let client = FcmClient::new(
|
||||
&s.key.json,
|
||||
"kvitt-1337",
|
||||
endpoints,
|
||||
s.app.state.clock.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let sender = Sender::new(
|
||||
s.app.state.db.clone(),
|
||||
s.hub.clone(),
|
||||
client,
|
||||
RetryPolicy {
|
||||
delays: FAST.iter().map(|ms| Duration::from_millis(*ms)).collect(),
|
||||
},
|
||||
);
|
||||
let d = sender
|
||||
.deliver(&message(PushType::ExpenseCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&d[0], Delivery::GaveUp { attempts: 4, .. }),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_or_404_clears_the_devices_token() {
|
||||
let s = Setup::new().await;
|
||||
let sender = s.sender();
|
||||
s.stub
|
||||
.script_sends(&[(404, fcm_error(404, "NOT_FOUND", "UNREGISTERED"))]);
|
||||
assert_eq!(
|
||||
sender
|
||||
.deliver(&message(PushType::ExpenseCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap(),
|
||||
[Delivery::TokenCleared]
|
||||
);
|
||||
assert_eq!(fcm_token(&s.app, MEMBER_DEVICE_ID).await, None);
|
||||
assert_eq!(s.stub.sends().len(), 1, "no retry");
|
||||
// UNREGISTERED under another status, and a bare 404.
|
||||
s.stub.script_sends(&[
|
||||
(400, fcm_error(400, "INVALID_ARGUMENT", "UNREGISTERED")),
|
||||
(404, json!({})),
|
||||
]);
|
||||
set_fcm_token(&s.app, MEMBER_DEVICE_ID, Some("tok-member")).await;
|
||||
sender
|
||||
.deliver(&message(PushType::ExpenseCreated, ADMIN_ID))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fcm_token(&s.app, MEMBER_DEVICE_ID).await, None);
|
||||
sender
|
||||
.deliver(&message(PushType::ExpenseCreated, MEMBER_ID))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fcm_token(&s.app, ADMIN_DEVICE_ID).await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_queue_drops_the_oldest() {
|
||||
let hub = PushHub::new();
|
||||
for i in 0..=QUEUE_CAPACITY {
|
||||
hub.enqueue(PushMessage {
|
||||
push_type: PushType::SyncBatch,
|
||||
entity_id: None,
|
||||
actor_user_id: ADMIN_ID.parse().unwrap(),
|
||||
count: u32::try_from(i).unwrap(),
|
||||
});
|
||||
}
|
||||
assert_eq!(QUEUE_CAPACITY, 100);
|
||||
assert_eq!(hub.len(), 100);
|
||||
assert_eq!(hub.pop().unwrap().count, 1, "message 0 was dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_or_unusable_credentials_disable_push() {
|
||||
let s = Setup::new().await;
|
||||
let missing = s.app.data_dir().join("no-such-key.json");
|
||||
let client = FcmClient::from_file(
|
||||
&missing,
|
||||
"kvitt-1337",
|
||||
s.stub.endpoints(),
|
||||
s.app.state.clock.clone(),
|
||||
);
|
||||
assert!(client.unwrap().is_none());
|
||||
// tasks::start_push with the configured path (missing in the test config): the no-op queue; nothing is sent.
|
||||
let mut state = s.app.state.clone();
|
||||
let mut config = (*state.config).clone();
|
||||
config.fcm_credentials = missing;
|
||||
state.config = Arc::new(config.clone());
|
||||
let queue = kvitt_server::tasks::start_push(&state, s.stub.endpoints(), RetryPolicy::default());
|
||||
queue.enqueue(message(PushType::ExpenseCreated, ADMIN_ID));
|
||||
// An unparsable file: disabled too.
|
||||
let bad = s.app.data_dir().join("bad-key.json");
|
||||
std::fs::write(
|
||||
&bad,
|
||||
"{\"client_email\": \"x\", \"private_key\": \"not a key\"}",
|
||||
)
|
||||
.unwrap();
|
||||
config.fcm_credentials = bad;
|
||||
state.config = Arc::new(config);
|
||||
let queue = kvitt_server::tasks::start_push(&state, s.stub.endpoints(), RetryPolicy::default());
|
||||
queue.enqueue(message(PushType::ExpenseCreated, ADMIN_ID));
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(s.stub.sends().is_empty());
|
||||
assert!(s.stub.tokens().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_push_reaches_fcm_through_the_queue_and_the_sender_task() {
|
||||
let mut s = Setup::new().await;
|
||||
let key_file = s.app.data_dir().join("fcm.json");
|
||||
std::fs::write(&key_file, &s.key.json).unwrap();
|
||||
let mut config = (*s.app.state.config).clone();
|
||||
config.fcm_credentials = key_file;
|
||||
s.app.state.config = Arc::new(config);
|
||||
let queue = kvitt_server::tasks::start_push(
|
||||
&s.app.state,
|
||||
s.stub.endpoints(),
|
||||
RetryPolicy {
|
||||
delays: vec![Duration::from_millis(10)],
|
||||
},
|
||||
);
|
||||
m4::with_push(&mut s.app, queue);
|
||||
let id = m4::new_id();
|
||||
m4::push_one(
|
||||
&s.app,
|
||||
&member_token(),
|
||||
m4::exp(m4::expense(&id, "2026-10-17T11:00:00.000Z")),
|
||||
)
|
||||
.await;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.stub.sends().is_empty() && Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let sends = s.stub.sends();
|
||||
assert_eq!(sends.len(), 1);
|
||||
let body = sends[0].json();
|
||||
assert_eq!(body["message"]["token"], "tok-admin");
|
||||
assert_eq!(body["message"]["data"]["entity_id"], id);
|
||||
assert_eq!(body["message"]["data"]["actor_user_id"], MEMBER_ID);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! M4 push tests (SPEC §14.6, FR-13): payloads of every type against `contracts/examples/push/`, recipients, the
|
||||
//! OAuth2 service-account flow and token cache, retries and their delays, token clearing on `UNREGISTERED`/404,
|
||||
//! queue overflow, push disabled without credentials, all against a local stub of Google's two endpoints.
|
||||
|
||||
#[path = "../core_api/common.rs"]
|
||||
pub mod common;
|
||||
#[path = "../sync_api/m4.rs"]
|
||||
pub mod m4;
|
||||
pub mod stub;
|
||||
|
||||
mod fcm;
|
||||
|
||||
impl Default for common::TempDir {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! A local stub of `oauth2.googleapis.com/token` and `fcm.googleapis.com/v1/projects/{p}/messages:send`, plus a
|
||||
//! throwaway RSA service-account key generated per test (no key is committed).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aws_lc_rs::encoding::AsDer;
|
||||
use aws_lc_rs::rsa::{KeyPair, KeySize};
|
||||
use aws_lc_rs::signature::KeyPair as _;
|
||||
use axum::Router;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use base64::Engine;
|
||||
use kvitt_server::push::Endpoints;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// One request the stub received.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Received {
|
||||
pub path: String,
|
||||
pub authorization: Option<String>,
|
||||
pub body: Bytes,
|
||||
}
|
||||
|
||||
impl Received {
|
||||
pub fn json(&self) -> Value {
|
||||
serde_json::from_slice(&self.body).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StubState {
|
||||
pub tokens: Mutex<Vec<Received>>,
|
||||
pub sends: Mutex<Vec<Received>>,
|
||||
/// Scripted send responses, used in order; afterwards 200.
|
||||
pub send_script: Mutex<VecDeque<(u16, Value)>>,
|
||||
/// Scripted token responses; afterwards a fresh token valid 3600 s.
|
||||
pub token_script: Mutex<VecDeque<(u16, Value)>>,
|
||||
}
|
||||
|
||||
pub struct Stub {
|
||||
pub addr: SocketAddr,
|
||||
pub state: Arc<StubState>,
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
pub async fn start() -> Self {
|
||||
let state = Arc::new(StubState::default());
|
||||
let app = Router::new()
|
||||
.route("/token", post(token))
|
||||
.route("/v1/projects/{project}/{action}", post(send))
|
||||
.with_state(state.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
Self { addr, state }
|
||||
}
|
||||
|
||||
pub fn endpoints(&self) -> Endpoints {
|
||||
Endpoints {
|
||||
token_url: format!("http://{}/token", self.addr),
|
||||
send_base_url: format!("http://{}", self.addr),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_sends(&self, responses: &[(u16, Value)]) {
|
||||
self.state
|
||||
.send_script
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend(responses.iter().cloned());
|
||||
}
|
||||
|
||||
pub fn sends(&self) -> Vec<Received> {
|
||||
self.state.sends.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn tokens(&self) -> Vec<Received> {
|
||||
self.state.tokens.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn record(headers: &HeaderMap, path: String, body: Bytes) -> Received {
|
||||
Received {
|
||||
path,
|
||||
authorization: headers
|
||||
.get("authorization")
|
||||
.map(|v| v.to_str().unwrap().to_owned()),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
async fn token(State(state): State<Arc<StubState>>, headers: HeaderMap, body: Bytes) -> Response {
|
||||
let n = {
|
||||
let mut tokens = state.tokens.lock().unwrap();
|
||||
tokens.push(record(&headers, "/token".into(), body));
|
||||
tokens.len()
|
||||
};
|
||||
if let Some((status, body)) = state.token_script.lock().unwrap().pop_front() {
|
||||
return (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response();
|
||||
}
|
||||
axum::Json(json!({"access_token": format!("ya29.stub-{n}"), "expires_in": 3600, "token_type": "Bearer"}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn send(
|
||||
State(state): State<Arc<StubState>>,
|
||||
Path((project, action)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
state.sends.lock().unwrap().push(record(
|
||||
&headers,
|
||||
format!("/v1/projects/{project}/{action}"),
|
||||
body,
|
||||
));
|
||||
match state.send_script.lock().unwrap().pop_front() {
|
||||
Some((status, body)) => {
|
||||
(StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
|
||||
}
|
||||
None => {
|
||||
axum::Json(json!({"name": format!("projects/{project}/messages/1")})).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A generated service-account key file and the public key to verify its JWTs.
|
||||
pub struct TestKey {
|
||||
pub json: String,
|
||||
pub public_der: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn service_account() -> TestKey {
|
||||
let pair = KeyPair::generate(KeySize::Rsa2048).unwrap();
|
||||
let der = AsDer::<aws_lc_rs::encoding::Pkcs8V1Der>::as_der(&pair).unwrap();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref());
|
||||
let lines: Vec<&str> = b64
|
||||
.as_bytes()
|
||||
.chunks(64)
|
||||
.map(|c| std::str::from_utf8(c).unwrap())
|
||||
.collect();
|
||||
let pem = format!(
|
||||
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
|
||||
lines.join("\n")
|
||||
);
|
||||
let json = json!({
|
||||
"type": "service_account",
|
||||
"project_id": "kvitt-1337",
|
||||
"private_key_id": "test-key-1",
|
||||
"private_key": pem,
|
||||
"client_email": "kvitt-fcm@kvitt-1337.iam.gserviceaccount.com",
|
||||
"token_uri": "https://oauth2.googleapis.com/token"
|
||||
})
|
||||
.to_string();
|
||||
TestKey {
|
||||
json,
|
||||
public_der: pair.public_key().as_ref().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An FCM error body with an `FcmError` detail.
|
||||
pub fn fcm_error(code: u16, status: &str, error_code: &str) -> Value {
|
||||
json!({"error": {"code": code, "message": "stub", "status": status, "details": [
|
||||
{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": error_code}
|
||||
]}})
|
||||
}
|
||||
Reference in New Issue
Block a user