use async_trait::async_trait;
use axum::body::{Body, Bytes, to_bytes};
use axum::extract::OriginalUri;
use axum::http::{Method, Request, StatusCode};
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::sync::Arc;
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 register(Extension(principal): Extension<RegistrationPrincipal>) -> String {
principal.username().as_str().to_owned()
}
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 prepare(fixture: Fixture) -> Result<K1Http, 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())?;
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())
}
#[async_trait]
impl Candidate for Adapter {
async fn open(&self, fixture: Fixture) -> Result<Router, String> {
let app = prepare(fixture).await?;
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))
}
}
#[async_trait]
impl kcode_k1_http_integration_testkit::Candidate for Adapter {
async fn compose(
&self,
fixture: Fixture,
registration: MethodRouter,
terms: MethodRouter,
authenticated: Router,
) -> Result<Router, String> {
Ok(prepare(fixture)
.await?
.router(registration, terms, authenticated))
}
}
async fn configuration_router() -> Router {
let epoch_file = std::env::temp_dir().join(format!(
"kcode-k1-http-config-{}.epoch",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time is before the Unix epoch")
.as_nanos()
));
let replay = ReplayWindow::open(ReplayConfig {
epoch_file,
max_nonces_per_epoch: 16,
})
.await
.expect("replay opens");
K1Http::new(
Config {
server_id: "server-for-config-test".to_owned(),
public_origin: "http://k1.test".to_owned(),
max_body_bytes: 1024,
},
replay,
Arc::new(Provider {
identities: Vec::new(),
blocked: None,
}),
)
.expect("configuration is valid")
.router(
post(|| async { "registered" }),
get(|| async { "terms" }),
Router::new().route("/protected", get(|| async { "protected" })),
)
}
fn config_request(method: Method) -> Request<Body> {
Request::builder()
.method(method)
.uri("/api/config.json")
.header("host", "k1.test")
.header("origin", "http://localhost:4321")
.body(Body::empty())
.expect("request is valid")
}
#[tokio::test]
async fn public_configuration_has_exact_json_cors_and_api_decoration() {
let response = configuration_router()
.await
.oneshot(config_request(Method::GET))
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["access-control-allow-origin"], "*");
assert_eq!(
response.headers()["access-control-expose-headers"],
"k1-epoch"
);
assert_eq!(response.headers()["cache-control"], "no-store");
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert!(response.headers().contains_key("k1-epoch"));
assert_eq!(
to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body is readable"),
"{\"protocol\":\"K1-HTTP-1\",\"server_id\":\"server-for-config-test\",\"public_origin\":\"http://k1.test\"}"
);
}
#[tokio::test]
async fn public_configuration_supports_head_and_rejects_other_methods_without_fallback() {
let app = configuration_router().await;
let head = app
.clone()
.oneshot(config_request(Method::HEAD))
.await
.expect("router responds");
assert_eq!(head.status(), StatusCode::OK);
assert_eq!(
to_bytes(head.into_body(), usize::MAX)
.await
.expect("head response body is readable"),
Bytes::new()
);
let post = app
.oneshot(config_request(Method::POST))
.await
.expect("router responds");
assert_eq!(post.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn configuration_remains_behind_the_existing_authority_gate() {
let invalid_authority = Request::builder()
.uri("/api/config.json")
.header("host", "other.test")
.body(Body::empty())
.expect("request is valid");
let response = configuration_router()
.await
.oneshot(invalid_authority)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::MISDIRECTED_REQUEST);
assert_eq!(
to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body is readable"),
"{\"error\":\"invalid_request_authority\"}"
);
}
#[tokio::test]
async fn integration() {
kcode_k1_http_integration_testkit::verify(&Adapter).await;
}