use super::*;
fn ping() -> BoxedFunction {
test_function("ping", Visibility::Public, |_host, _hook, _input| {
Ok(json!({ "pong": true }).to_string())
})
}
#[ntex::test]
async fn a_rate_limit_refuses_the_request_after_the_allowance_and_says_when_to_return() {
let db = TempDatabase::create("ratelimit").await;
let root = temp_dir("ratelimit");
write_files(
&root,
&[
(
"main.toml",
&format!(
r#"
[server]
base_path = "/api"
[database]
url = "{}"
[rate_limit]
default = "3/1m"
"#,
db.url
),
),
(
"models/note.toml",
r#"
[resource]
name = "note"
scope = "global"
[permissions]
list = "public"
read = "public"
create = "public"
[fields.title]
type = "string"
required = true
# Narrower than the app-wide rule for one action, and lifted entirely for
# another.
[rate_limit]
create = "1/1m"
list = "off"
"#,
),
],
);
let state = load_state_configured(
&root,
vec![(ping(), json!({ "rate_limit": "1/1m" }).to_string())],
)
.await;
let app = init_http_app!(state);
let post_note = || req_json("POST", "/api/note", json!({ "title": "hello" }));
let get = |uri: &'static str| test::TestRequest::get().uri(uri).to_request();
let call_ping = || req_json("POST", "/api/functions/ping", json!({}));
assert_eq!(
test::call_service(&app, post_note())
.await
.status()
.as_u16(),
201
);
let refused = test::call_service(&app, post_note()).await;
assert_eq!(refused.status().as_u16(), 429);
let headers = refused.headers().clone();
assert_eq!(headers.get("x-ratelimit-limit").unwrap(), "1");
assert_eq!(headers.get("x-ratelimit-remaining").unwrap(), "0");
assert!(headers.contains_key("retry-after"));
assert!(headers.contains_key("x-ratelimit-reset"));
let body = read_json(refused).await;
assert!(
body["error"].as_str().unwrap().contains("rate limit"),
"unhelpful body: {body}"
);
for _ in 0..6 {
let response = test::call_service(&app, get("/api/note")).await;
assert_eq!(response.status().as_u16(), 200);
assert!(!response.headers().contains_key("x-ratelimit-limit"));
}
assert_eq!(
test::call_service(&app, call_ping())
.await
.status()
.as_u16(),
200
);
assert_eq!(
test::call_service(&app, call_ping())
.await
.status()
.as_u16(),
429
);
for remaining in [2, 1, 0] {
let response = test::call_service(&app, get("/api/_health")).await;
assert_eq!(response.status().as_u16(), 200);
assert_eq!(
response.headers().get("x-ratelimit-remaining").unwrap(),
remaining.to_string().as_str()
);
}
assert_eq!(
test::call_service(&app, get("/api/_health"))
.await
.status()
.as_u16(),
429
);
assert_eq!(
test::call_service(&app, get("/api/note"))
.await
.status()
.as_u16(),
200
);
fs::remove_dir_all(root).unwrap();
db.cleanup().await;
}
#[ntex::test]
async fn an_app_that_sets_no_rate_limit_is_limited_nowhere() {
let db = TempDatabase::create("ratelimitoff").await;
let root = temp_dir("ratelimitoff");
write_files(
&root,
&[(
"main.toml",
&format!(
"\n[server]\nbase_path = \"/api\"\n\n[database]\nurl = \"{}\"\n",
db.url
),
)],
);
let state = load_state(&root).await;
assert!(
!state.rate_limit.is_active(),
"a rate limit nobody asked for is one that starts refusing traffic on an upgrade"
);
let app = init_http_app!(state);
for _ in 0..20 {
let response = test::call_service(
&app,
test::TestRequest::get().uri("/api/_health").to_request(),
)
.await;
assert_eq!(response.status().as_u16(), 200);
}
fs::remove_dir_all(root).unwrap();
db.cleanup().await;
}
#[ntex::test]
async fn switching_rate_limiting_off_drops_the_overrides_too() {
let db = TempDatabase::create("ratelimitkill").await;
let root = temp_dir("ratelimitkill");
write_files(
&root,
&[
(
"main.toml",
&format!(
r#"
[server]
base_path = "/api"
[database]
url = "{}"
[rate_limit]
enabled = false
default = "1/1h"
"#,
db.url
),
),
(
"models/note.toml",
r#"
[resource]
name = "note"
scope = "global"
[permissions]
list = "public"
[fields.title]
type = "string"
[rate_limit]
list = "1/1h"
"#,
),
],
);
let state = load_state(&root).await;
assert!(!state.rate_limit.is_active());
let app = init_http_app!(state);
for _ in 0..5 {
let response =
test::call_service(&app, test::TestRequest::get().uri("/api/note").to_request()).await;
assert_eq!(response.status().as_u16(), 200);
}
fs::remove_dir_all(root).unwrap();
db.cleanup().await;
}