15056dc7df
- 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
91 lines
3.0 KiB
Rust
91 lines
3.0 KiB
Rust
//! `serve` as deployed: the router with `ConnectInfo<SocketAddr>` (so the rate limiter sees each client's address,
|
|
//! SPEC §8.6), `/i/` with an empty code as the HTML 404 page, and `rotate-epoch` as the real binary (SPEC §10.4).
|
|
|
|
use std::net::SocketAddr;
|
|
use std::process::Command;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request};
|
|
use kvitt_server::cli::make_service;
|
|
use tokio::net::TcpListener;
|
|
|
|
use crate::common::*;
|
|
|
|
#[tokio::test]
|
|
async fn two_client_addresses_get_separate_rate_limit_buckets_end_to_end() {
|
|
let app = TestApp::empty().await;
|
|
// One service, so one rate limiter, reached from two client addresses: 127.0.0.1 and ::1.
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let v4 = listener.local_addr().unwrap();
|
|
let listener6 = TcpListener::bind("[::1]:0").await.unwrap();
|
|
let v6 = listener6.local_addr().unwrap();
|
|
let service = make_service(app.state.clone());
|
|
let service6 = service.clone();
|
|
tokio::spawn(async move { axum::serve(listener, service).await.unwrap() });
|
|
tokio::spawn(async move { axum::serve(listener6, service6).await.unwrap() });
|
|
|
|
let client = reqwest::Client::new();
|
|
let get = |addr: SocketAddr| {
|
|
let client = client.clone();
|
|
async move {
|
|
client
|
|
.get(format!("http://{addr}/health"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
.as_u16()
|
|
}
|
|
};
|
|
for _ in 0..60 {
|
|
assert_eq!(get(v4).await, 200);
|
|
}
|
|
assert_eq!(
|
|
get(v4).await,
|
|
429,
|
|
"the 61st request from 127.0.0.1 in the minute"
|
|
);
|
|
assert_eq!(get(v6).await, 200, "::1 has its own bucket");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_invite_code_is_the_html_404_page() {
|
|
let app = TestApp::empty().await;
|
|
let reply = app
|
|
.send(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/i/")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
assert_eq!(reply.status, 404);
|
|
assert_eq!(reply.headers["content-type"], "text/html; charset=utf-8");
|
|
assert!(reply.text().contains("<p>404</p>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() {
|
|
let app = TestApp::empty().await;
|
|
let epoch = || async {
|
|
sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'sync_epoch'")
|
|
.fetch_one(&app.state.db)
|
|
.await
|
|
.unwrap()
|
|
};
|
|
let before = epoch().await;
|
|
let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
|
|
.arg("rotate-epoch")
|
|
.env("KVITT_DATA_DIR", app.data_dir())
|
|
.env("RUST_LOG", "warn")
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(output.status.code(), Some(0), "{output:?}");
|
|
let after = epoch().await;
|
|
assert_ne!(before, after);
|
|
assert_eq!(after.len(), 32);
|
|
assert!(after.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains(&after));
|
|
}
|