use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use webx::*;
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct DynamicOnlyRequest;
#[post("/api/webx18/meta/dynamic-only")]
#[authorize(dynamic)]
impl IRequest<String> for DynamicOnlyRequest {}
#[derive(Default)]
struct DynamicOnlyHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<DynamicOnlyRequest, String> for DynamicOnlyHandler {
async fn handle(&mut self, _req: DynamicOnlyRequest) -> ActionResult<String> {
Ok(String::new())
}
}
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct DynamicCombinedRequest;
#[post("/api/webx18/meta/dynamic-combined")]
#[authorize(dynamic, permission = "orders:delete")]
impl IRequest<String> for DynamicCombinedRequest {}
#[derive(Default)]
struct DynamicCombinedHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<DynamicCombinedRequest, String> for DynamicCombinedHandler {
async fn handle(&mut self, _req: DynamicCombinedRequest) -> ActionResult<String> {
Ok(String::new())
}
}
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct DynamicMixedRequest;
#[post("/api/webx18/meta/dynamic-mixed")]
#[authorize(role = "admin")]
#[authorize(dynamic, permission = "reports:export")]
impl IRequest<String> for DynamicMixedRequest {}
#[derive(Default)]
struct DynamicMixedHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<DynamicMixedRequest, String> for DynamicMixedHandler {
async fn handle(&mut self, _req: DynamicMixedRequest) -> ActionResult<String> {
Ok(String::new())
}
}
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct StaticMetaRequest;
#[post("/api/webx18/meta/static")]
#[authorize(permission = "orders:delete")]
impl IRequest<String> for StaticMetaRequest {}
#[derive(Default)]
struct StaticMetaHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<StaticMetaRequest, String> for StaticMetaHandler {
async fn handle(&mut self, _req: StaticMetaRequest) -> ActionResult<String> {
Ok(String::new())
}
}
fn route_entry(path: &str) -> &'static RouteEntry {
inventory::iter::<RouteEntry>()
.find(|entry| entry.path == path)
.unwrap_or_else(|| panic!("route {path} missing from inventory"))
}
#[test]
fn dynamic_marker_is_collected_from_the_attribute() {
let entry = route_entry("/api/webx18/meta/dynamic-only");
assert!(
entry.authorization_dynamic,
"`#[authorize(dynamic)]` must set the endpoint-level dynamic flag"
);
assert_eq!(entry.authorization.len(), 1);
assert!(entry.authorization[0].roles.is_empty());
assert!(entry.authorization[0].permissions.is_empty());
}
#[test]
fn dynamic_combines_with_role_and_permission_in_one_attribute() {
let entry = route_entry("/api/webx18/meta/dynamic-combined");
assert!(entry.authorization_dynamic);
assert_eq!(entry.authorization.len(), 1);
assert_eq!(entry.authorization[0].permissions, &["orders:delete"]);
}
#[test]
fn one_dynamic_attribute_makes_the_whole_endpoint_dynamic() {
let entry = route_entry("/api/webx18/meta/dynamic-mixed");
assert!(
entry.authorization_dynamic,
"mixing `#[authorize(dynamic, ..)]` with static attributes must make the whole endpoint dynamic"
);
let rules: Vec<(&[&str], &[&str])> = entry
.authorization
.iter()
.map(|rule| (rule.roles, rule.permissions))
.collect();
assert_eq!(
rules,
vec![
(&["admin"][..], &[][..]),
(&[][..], &["reports:export"][..])
],
"the declared rules stay intact (AND structure, declaration order)"
);
}
#[test]
fn static_routes_keep_the_dynamic_flag_off() {
let entry = route_entry("/api/webx18/meta/static");
assert!(
!entry.authorization_dynamic,
"a plain `#[authorize(permission = ..)]` must stay on the static path"
);
assert_eq!(entry.authorization[0].permissions, &["orders:delete"]);
}
const TEST_SECRET: &str = "webx18-dynamic-test-secret-key-32+";
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct AllowedRequest;
#[delete("/api/webx18/dynamic-allow")]
#[authorize(dynamic, permission = "orders:delete")]
impl IRequest<String> for AllowedRequest {}
#[derive(Default)]
struct AllowedHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<AllowedRequest, String> for AllowedHandler {
async fn handle(&mut self, _req: AllowedRequest) -> ActionResult<String> {
Ok("dynamic-allowed".to_string())
}
}
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct DeniedRequest;
#[delete("/api/webx18/dynamic-deny")]
#[authorize(dynamic, permission = "orders:delete")]
impl IRequest<String> for DeniedRequest {}
#[derive(Default)]
struct DeniedHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<DeniedRequest, String> for DeniedHandler {
async fn handle(&mut self, _req: DeniedRequest) -> ActionResult<String> {
Ok("must-not-run".to_string())
}
}
#[derive(Default, Serialize, Deserialize, WebxRequestMeta)]
struct StaticRequest;
#[delete("/api/webx18/static")]
#[authorize(permission = "orders:delete")]
impl IRequest<String> for StaticRequest {}
#[derive(Default)]
struct StaticHandler;
#[handler]
#[async_trait::async_trait]
impl IRequestHandler<StaticRequest, String> for StaticHandler {
async fn handle(&mut self, _req: StaticRequest) -> ActionResult<String> {
Ok("static".to_string())
}
}
#[derive(Default)]
struct RecordingAuthorizer {
calls: Mutex<Vec<String>>,
}
impl RecordingAuthorizer {
fn routes(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl IDynamicAuthorizer for RecordingAuthorizer {
async fn authorize(
&self,
_claims: &dyn IClaims,
route_pattern: &str,
_method: &str,
) -> Result<()> {
self.calls.lock().unwrap().push(route_pattern.to_string());
if route_pattern.ends_with("/dynamic-deny") {
Err(Error::Forbidden(
"dynamic authorizer denied the request".to_string(),
))
} else {
Ok(())
}
}
}
fn free_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
fn bearer_token(permissions: &[&str]) -> String {
#[derive(Serialize)]
struct TestClaims {
sub: String,
roles: Vec<String>,
permissions: Vec<String>,
exp: usize,
}
let exp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as usize
+ 3600;
let claims = TestClaims {
sub: "user-1".to_string(),
roles: vec!["user".to_string()],
permissions: permissions.iter().map(|p| p.to_string()).collect(),
exp,
};
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.unwrap()
}
async fn spawn_dynamic_host(port: u16, authorizer: Arc<RecordingAuthorizer>) {
let addr = format!("127.0.0.1:{port}");
let host = Host::builder()
.mode(AppMode::Development)
.no_spa()
.add_authentication()
.configure(|builder| {
builder.useOptions(|options| {
options.jwt.secret = TEST_SECRET.to_string();
});
})
.register(move |svc| {
let authorizer = Arc::clone(&authorizer);
svc.singleton::<dyn IDynamicAuthorizer>(move |_| authorizer.clone())
})
.build();
tokio::spawn(async move { host.run_at(&addr).await.unwrap() });
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
#[tokio::test]
async fn dynamic_routes_delegate_to_the_authorizer_while_static_routes_do_not() {
let port = free_port();
let authorizer = Arc::new(RecordingAuthorizer::default());
spawn_dynamic_host(port, Arc::clone(&authorizer)).await;
let client = reqwest::Client::new();
let token = bearer_token(&[]);
let url = |path: &str| format!("http://127.0.0.1:{port}{path}");
let allowed = client
.delete(url("/api/webx18/dynamic-allow"))
.header("authorization", format!("Bearer {token}"))
.send()
.await
.unwrap();
assert_eq!(allowed.status().as_u16(), 200);
let body: serde_json::Value = allowed.json().await.unwrap();
assert_eq!(body, "dynamic-allowed");
let denied = client
.delete(url("/api/webx18/dynamic-deny"))
.header("authorization", format!("Bearer {token}"))
.send()
.await
.unwrap();
assert_eq!(denied.status().as_u16(), 403);
let body: serde_json::Value = denied.json().await.unwrap();
assert_eq!(body["status"], 403);
let static_resp = client
.delete(url("/api/webx18/static"))
.header("authorization", format!("Bearer {token}"))
.send()
.await
.unwrap();
assert_eq!(static_resp.status().as_u16(), 403);
let body: serde_json::Value = static_resp.json().await.unwrap();
assert_eq!(
body["required_permissions"],
serde_json::json!(["orders:delete"])
);
let anonymous = client
.delete(url("/api/webx18/dynamic-allow"))
.send()
.await
.unwrap();
assert_eq!(anonymous.status().as_u16(), 401);
let routes = authorizer.routes();
assert!(
routes
.iter()
.any(|route| route == "/api/webx18/dynamic-allow"),
"dynamic allow route must reach the authorizer, got {routes:?}"
);
assert!(
routes
.iter()
.any(|route| route == "/api/webx18/dynamic-deny"),
"dynamic deny route must reach the authorizer, got {routes:?}"
);
assert!(
!routes.iter().any(|route| route == "/api/webx18/static"),
"the static gate must short-circuit before dynamic authorization, got {routes:?}"
);
}