use async_trait::async_trait;
use axum::body::{Body, Bytes, to_bytes};
use axum::extract::OriginalUri;
use axum::http::header::{ALLOW, CACHE_CONTROL, CONTENT_TYPE, HOST};
use axum::http::{Method, Request, StatusCode};
use axum::response::Response;
use axum::routing::{MethodRouter, get, post};
use axum::{Extension, Json, Router};
use kcode_k1_http::{
CanonicalUsername, Config, Identity, IdentityError, IdentityProvider, K1Http, Principal,
RegistrationPrincipal,
};
use kcode_k1_http_replay::{ReplayConfig, ReplayWindow};
use kcode_k1_http_testkit::{Candidate, Fixture, LookupBlock};
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tower::ServiceExt;
struct Adapter;
struct Provider {
identities: Vec<(CanonicalUsername, Identity)>,
blocked: Option<(CanonicalUsername, LookupBlock)>,
}
#[async_trait]
impl IdentityProvider for Provider {
async fn lookup(
&self,
username: &CanonicalUsername,
) -> Result<Option<Identity>, IdentityError> {
if let Some((blocked_username, block)) = &self.blocked
&& blocked_username == username
{
let release = block.release.notified();
tokio::pin!(release);
release.as_mut().enable();
block.entered.notify_one();
release.await;
}
Ok(self
.identities
.iter()
.find(|(candidate, _)| candidate == username)
.map(|(_, identity)| *identity))
}
}
#[derive(Deserialize)]
struct Known {
known: String,
}
async fn whoami(Extension(principal): Extension<Principal>) -> String {
principal.username().as_str().to_owned()
}
async fn target(OriginalUri(uri): OriginalUri) -> String {
uri.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/")
.to_owned()
}
async fn echo(body: Bytes) -> Bytes {
body
}
async fn json(Json(value): Json<Known>) -> String {
value.known
}
async fn register(Extension(principal): Extension<RegistrationPrincipal>) -> String {
principal.username().as_str().to_owned()
}
#[async_trait]
impl Candidate for Adapter {
async fn open(&self, fixture: Fixture) -> Result<Router, String> {
let identities = fixture
.identities
.into_iter()
.map(|identity| {
CanonicalUsername::parse(&identity.username)
.map(|username| {
(
username,
Identity::new(identity.user_id, identity.public_key),
)
})
.map_err(|_| "invalid fixture identity".to_owned())
})
.collect::<Result<Vec<_>, _>>()?;
let blocked = fixture
.blocked_lookup
.map(|block| {
CanonicalUsername::parse(&block.username)
.map(|username| (username, block))
.map_err(|_| "invalid blocked identity".to_owned())
})
.transpose()?;
let replay = ReplayWindow::open(ReplayConfig {
epoch_file: fixture.epoch_file,
max_nonces_per_epoch: fixture.max_nonces_per_epoch,
})
.await
.map_err(|_| "replay unavailable".to_owned())?;
let app = K1Http::new(
Config {
server_id: fixture.server_id,
public_origin: fixture.public_origin,
max_body_bytes: fixture.max_body_bytes,
},
replay,
Arc::new(Provider {
identities,
blocked,
}),
)
.map_err(|_| "configuration invalid".to_owned())?;
let authenticated = Router::new()
.route("/whoami", get(whoami))
.route("/target", get(target))
.route("/echo", post(echo))
.route("/json", post(json));
Ok(app.router(post(register), kcode_k1_terms::endpoint(), authenticated))
}
}
fn epoch_file(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"kcode-k1-http-{label}-{}-{unique}.epoch",
std::process::id()
))
}
async fn local_router(
label: &str,
registration: MethodRouter,
terms: MethodRouter,
authenticated: Router,
) -> (Router, PathBuf) {
let epoch_file = epoch_file(label);
let replay = ReplayWindow::open(ReplayConfig {
epoch_file: epoch_file.clone(),
max_nonces_per_epoch: 64,
})
.await
.expect("local replay opens");
let app = K1Http::new(
Config {
server_id: "local-test".to_owned(),
public_origin: "https://example.test".to_owned(),
max_body_bytes: 64 * 1024,
},
replay,
Arc::new(Provider {
identities: Vec::new(),
blocked: None,
}),
)
.expect("local configuration is valid");
(app.router(registration, terms, authenticated), epoch_file)
}
async fn send(router: Router, method: Method, uri: &str, host: &str) -> Response {
router
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.header(HOST, host)
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("request completes")
}
async fn body(response: Response, limit: usize) -> Bytes {
to_bytes(response.into_body(), limit)
.await
.expect("response body collects")
}
fn assert_decorated(response: &Response) {
assert_eq!(
response
.headers()
.get(CACHE_CONTROL)
.expect("Cache-Control"),
"no-store"
);
assert_eq!(
response
.headers()
.get("x-content-type-options")
.expect("X-Content-Type-Options"),
"nosniff"
);
response
.headers()
.get("k1-epoch")
.expect("K1-Epoch")
.to_str()
.expect("text epoch")
.parse::<u64>()
.expect("decimal epoch");
}
#[tokio::test]
async fn authentication() {
kcode_k1_http_testkit::verify_authentication(&Adapter).await;
}
#[tokio::test]
async fn routing() {
kcode_k1_http_testkit::verify_routing(&Adapter).await;
}
#[tokio::test]
async fn restart_and_replay() {
kcode_k1_http_testkit::verify_restart_and_replay(&Adapter).await;
}
#[tokio::test]
async fn cors_and_isolation() {
kcode_k1_http_testkit::verify_cors_and_isolation(&Adapter).await;
}
#[tokio::test]
async fn terms_get_is_exact_and_decorated() {
let authenticated = Router::new().route("/unused", get(|| async { StatusCode::OK }));
let (router, epoch_file) = local_router(
"terms-get",
post(|| async { StatusCode::OK }),
kcode_k1_terms::endpoint(),
authenticated,
)
.await;
let response = send(router, Method::GET, "/api/terms", "example.test").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(CONTENT_TYPE).expect("Content-Type"),
"text/plain; charset=utf-8"
);
assert_decorated(&response);
assert_eq!(
body(response, 4096).await.as_ref(),
kcode_k1_terms::text().as_bytes()
);
let _ = std::fs::remove_file(epoch_file);
}
#[tokio::test]
async fn terms_head_has_no_body() {
let authenticated = Router::new().route("/unused", get(|| async { StatusCode::OK }));
let (router, epoch_file) = local_router(
"terms-head",
post(|| async { StatusCode::OK }),
kcode_k1_terms::endpoint(),
authenticated,
)
.await;
let response = send(router, Method::HEAD, "/api/terms", "example.test").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(CONTENT_TYPE).expect("Content-Type"),
"text/plain; charset=utf-8"
);
assert!(body(response, 4096).await.is_empty());
let _ = std::fs::remove_file(epoch_file);
}
#[tokio::test]
async fn terms_rejects_accidental_non_get_handler() {
let handler_runs = Arc::new(AtomicUsize::new(0));
let post_runs = handler_runs.clone();
let terms = kcode_k1_terms::endpoint().post(move || {
let post_runs = post_runs.clone();
async move {
post_runs.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
});
let authenticated = Router::new().route("/unused", get(|| async { StatusCode::OK }));
let (router, epoch_file) = local_router(
"terms-method",
post(|| async { StatusCode::OK }),
terms,
authenticated,
)
.await;
let response = send(router, Method::POST, "/api/terms", "example.test").await;
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(response.headers().get(ALLOW).expect("Allow"), "GET, HEAD");
assert_eq!(handler_runs.load(Ordering::SeqCst), 0);
let _ = std::fs::remove_file(epoch_file);
}
#[tokio::test]
async fn terms_wrong_host_precedes_handler() {
let handler_runs = Arc::new(AtomicUsize::new(0));
let get_runs = handler_runs.clone();
let terms = get(move || {
let get_runs = get_runs.clone();
async move {
get_runs.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
});
let authenticated = Router::new().route("/unused", get(|| async { StatusCode::OK }));
let (router, epoch_file) = local_router(
"terms-host",
post(|| async { StatusCode::OK }),
terms,
authenticated,
)
.await;
let response = send(router, Method::GET, "/api/terms", "wrong.example").await;
assert_eq!(response.status(), StatusCode::MISDIRECTED_REQUEST);
assert_eq!(handler_runs.load(Ordering::SeqCst), 0);
assert_eq!(
body(response, 1024).await.as_ref(),
br#"{"error":"invalid_request_authority"}"#
);
let _ = std::fs::remove_file(epoch_file);
}
#[tokio::test]
async fn authenticated_fallback_requires_authentication() {
let handler_runs = Arc::new(AtomicUsize::new(0));
let fallback_runs = handler_runs.clone();
let authenticated = Router::new()
.route("/unused", get(|| async { StatusCode::OK }))
.fallback(move || {
let fallback_runs = fallback_runs.clone();
async move {
fallback_runs.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
});
let (router, epoch_file) = local_router(
"authenticated-fallback",
post(|| async { StatusCode::OK }),
kcode_k1_terms::endpoint(),
authenticated,
)
.await;
let response = send(router, Method::GET, "/api/missing", "example.test").await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(handler_runs.load(Ordering::SeqCst), 0);
let _ = std::fs::remove_file(epoch_file);
}
#[tokio::test]
async fn registration_rejects_accidental_non_post_handler() {
let handler_runs = Arc::new(AtomicUsize::new(0));
let get_runs = handler_runs.clone();
let registration = get(move || {
let get_runs = get_runs.clone();
async move {
get_runs.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
});
let authenticated = Router::new().route("/unused", get(|| async { StatusCode::OK }));
let (router, epoch_file) = local_router(
"registration-method",
registration,
kcode_k1_terms::endpoint(),
authenticated,
)
.await;
let response = send(router, Method::GET, "/api/register", "example.test").await;
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(response.headers().get(ALLOW).expect("Allow"), "POST");
assert_eq!(handler_runs.load(Ordering::SeqCst), 0);
let _ = std::fs::remove_file(epoch_file);
}
#[tokio::test]
async fn host_validation_precedes_authentication() {
let authenticated = Router::new().route("/whoami", get(|| async { StatusCode::OK }));
let (router, epoch_file) = local_router(
"host-precedence",
post(|| async { StatusCode::OK }),
kcode_k1_terms::endpoint(),
authenticated,
)
.await;
let response = send(router, Method::GET, "/api/whoami", "wrong.example").await;
assert_eq!(response.status(), StatusCode::MISDIRECTED_REQUEST);
assert_eq!(
body(response, 1024).await.as_ref(),
br#"{"error":"invalid_request_authority"}"#
);
let _ = std::fs::remove_file(epoch_file);
}