chore: import upstream snapshot with attribution
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:57 +08:00
commit d718c5a372
986 changed files with 74597 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
use actix_web::{
body::MessageBody,
http::{self},
test,
};
use crate::common::req_path;
#[actix_web::test]
async fn test_index_ok() {
let resp = req_path("/").await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("It works !"));
assert!(!body.contains("error"));
}
#[actix_web::test]
async fn test_access_config_forbidden() {
let resp_result = req_path("/sqlpage/sqlpage.json").await;
assert!(
resp_result.is_err(),
"Accessing the config file should be forbidden, but we received a response: {resp_result:?}"
);
let resp = resp_result.unwrap_err().error_response();
assert_eq!(resp.status(), http::StatusCode::FORBIDDEN);
assert!(
String::from_utf8_lossy(&resp.into_body().try_into_bytes().unwrap())
.to_lowercase()
.contains("forbidden"),
);
}
#[actix_web::test]
async fn test_static_files() {
let resp = req_path("/tests/it_works.txt").await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let body = test::read_body(resp).await;
assert_eq!(&body, &b"It works !"[..]);
}
#[actix_web::test]
async fn test_spaces_in_file_names() {
let resp = req_path("/tests/core/spaces%20in%20file%20name.sql")
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("It works !"), "{body_str}");
}
+171
View File
@@ -0,0 +1,171 @@
use std::time::Duration;
use actix_web::{
App, HttpResponse, HttpServer,
dev::{ServiceRequest, fn_service},
http::header,
http::header::ContentType,
test::{self, TestRequest},
web,
web::Data,
};
use sqlpage::{
AppState,
app_config::{AppConfig, test_database_url},
telemetry,
webserver::http::{form_config, main_handler, payload_config},
};
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
pub async fn get_request_to_with_data(
path: &str,
data: Data<AppState>,
) -> actix_web::Result<TestRequest> {
Ok(test::TestRequest::get()
.uri(path)
.insert_header(ContentType::plaintext())
.insert_header(header::Accept::html())
.app_data(payload_config(&data))
.app_data(form_config(&data))
.app_data(data))
}
pub async fn get_request_to(path: &str) -> actix_web::Result<TestRequest> {
let data = make_app_data().await;
get_request_to_with_data(path, data).await
}
pub async fn make_app_data_from_config(config: AppConfig) -> Data<AppState> {
let state = AppState::init(&config).await.unwrap();
Data::new(state)
}
pub async fn make_app_data() -> Data<AppState> {
init_log();
let config = test_config();
make_app_data_from_config(config).await
}
pub async fn req_path(
path: impl AsRef<str>,
) -> Result<actix_web::dev::ServiceResponse, actix_web::Error> {
let req = get_request_to(path.as_ref()).await?.to_srv_request();
main_handler(req).await
}
const REQ_TIMEOUT: Duration = Duration::from_secs(8);
pub async fn req_path_with_app_data(
path: impl AsRef<str>,
app_data: Data<AppState>,
) -> anyhow::Result<actix_web::dev::ServiceResponse> {
req_path_with_app_data_and_accept(path, app_data, header::Accept::html()).await
}
pub async fn req_path_with_app_data_json(
path: impl AsRef<str>,
app_data: Data<AppState>,
) -> anyhow::Result<actix_web::dev::ServiceResponse> {
req_path_with_app_data_and_accept(path, app_data, header::Accept::json()).await
}
async fn req_path_with_app_data_and_accept(
path: impl AsRef<str>,
app_data: Data<AppState>,
accept: header::Accept,
) -> anyhow::Result<actix_web::dev::ServiceResponse> {
let path = path.as_ref();
let req = test::TestRequest::get()
.uri(path)
.app_data(app_data)
.insert_header(("cookie", "test_cook=123"))
.insert_header(("authorization", "Basic dGVzdDp0ZXN0"))
.insert_header(accept)
.to_srv_request();
let resp = tokio::time::timeout(REQ_TIMEOUT, main_handler(req))
.await
.map_err(|e| anyhow::anyhow!("Request to {path} timed out: {e}"))?
.map_err(|e| {
anyhow::anyhow!(
"Request to {path} failed with status {}: {e:#}",
e.as_response_error().status_code()
)
})?;
Ok(resp)
}
pub fn test_config() -> AppConfig {
let db_url = test_database_url();
serde_json::from_str::<AppConfig>(&format!(
r#"{{
"database_url": "{db_url}",
"max_database_pool_connections": 1,
"database_connection_retries": 3,
"database_connection_acquire_timeout_seconds": 15,
"allow_exec": true,
"max_uploaded_file_size": 123456,
"listen_on": "111.111.111.111:1",
"system_root_ca_certificates" : false
}}"#
))
.unwrap()
}
pub fn init_log() {
telemetry::init_test_logging();
}
fn format_request_line_and_headers(req: &ServiceRequest) -> String {
let mut out = format!("{} {}", req.method(), req.uri());
let mut headers: Vec<_> = req.headers().iter().collect();
headers.sort_by_key(|(k, _)| k.as_str());
for (k, v) in headers {
if k.as_str().eq_ignore_ascii_case("date") {
continue;
}
out.push_str(&format!("|{k}: {}", v.to_str().unwrap_or("?")));
}
out
}
async fn format_body(req: &mut ServiceRequest) -> Vec<u8> {
req.extract::<web::Bytes>()
.await
.map(|b| b.to_vec())
.unwrap_or_default()
}
fn build_echo_response(body: Vec<u8>, meta: String) -> HttpResponse {
let mut resp = meta.into_bytes();
resp.push(b'|');
resp.extend_from_slice(&body);
HttpResponse::Ok()
.insert_header((header::DATE, "Mon, 24 Feb 2025 12:00:00 GMT"))
.insert_header((header::CONTENT_TYPE, "text/plain"))
.body(resp)
}
pub fn start_echo_server(shutdown: oneshot::Receiver<()>) -> (JoinHandle<()>, u16) {
let listener = std::net::TcpListener::bind("localhost:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = HttpServer::new(|| {
App::new().default_service(fn_service(|mut req: ServiceRequest| async move {
let meta = format_request_line_and_headers(&req);
let body = format_body(&mut req).await;
let resp = build_echo_response(body, meta);
Ok(req.into_response(resp))
}))
})
.workers(1)
.listen(listener)
.unwrap()
.shutdown_timeout(1)
.run();
let handle = tokio::spawn(async move {
tokio::select! {
_ = server => {},
_ = shutdown => {},
}
});
(handle, port)
}
+6
View File
@@ -0,0 +1,6 @@
select $component as component;
select
'It works !' as title,
'It works !' as description;
select 'divider' as component, 'the end' as contents;
+4
View File
@@ -0,0 +1,4 @@
-- This test checks that the size of the form field can successfully roundtrip,
-- from POST variable to sqlpage variable to handlebars, back to the client
set x = :x;
select 'text' as component, $x as contents;
+1
View File
@@ -0,0 +1 @@
select 'html' as component, $html as html;
+1
View File
@@ -0,0 +1 @@
select 'text' as component, 'This is a hidden file that should not be accessible' as contents;
+257
View File
@@ -0,0 +1,257 @@
use actix_web::{http::StatusCode, test};
use sqlpage::{
AppState,
webserver::{self, make_placeholder},
};
use sqlx::Executor as _;
use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config};
#[actix_web::test]
async fn test_concurrent_requests() {
let components = [
"table", "form", "card", "datagrid", "hero", "list", "timeline",
];
let app_data = make_app_data_from_config(test_config()).await;
let reqs = (0..64)
.map(|i| {
let component = components[i % components.len()];
req_path_with_app_data(
format!("/tests/components/any_component.sql?component={component}"),
app_data.clone(),
)
})
.collect::<Vec<_>>();
let results = futures_util::future::join_all(reqs).await;
for result in results.into_iter() {
let resp = result.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
assert!(
body.starts_with(b"<!DOCTYPE html>"),
"Expected html doctype"
);
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(
body.contains("It works !"),
"Expected to contain: It works !, but got: {body}"
);
assert!(!body.contains("error"));
}
}
#[actix_web::test]
async fn test_routing_with_db_fs() {
let mut config = test_config();
if config.database_url.contains("memory") {
return;
}
config.site_prefix = "/prefix/".to_string();
let state = AppState::init(&config).await.unwrap();
if matches!(
state.db.info.database_type,
sqlpage::webserver::database::SupportedDatabase::Oracle
) {
return;
}
let drop_sql = "DROP TABLE IF EXISTS sqlpage_files";
state.db.connection.execute(drop_sql).await.unwrap();
let create_table_sql =
sqlpage::filesystem::DbFsQueries::get_create_table_sql(state.db.info.database_type);
state.db.connection.execute(create_table_sql).await.unwrap();
let insert_sql = format!(
"INSERT INTO sqlpage_files(path, contents) VALUES ('on_db.sql', {})",
make_placeholder(state.db.info.kind, 1)
);
sqlx::query(&insert_sql)
.bind("select ''text'' as component, ''Hi from db !'' AS contents;".as_bytes())
.execute(&state.db.connection)
.await
.unwrap();
let state = AppState::init(&config).await.unwrap();
let app_data = actix_web::web::Data::new(state);
let resp = req_path_with_app_data("/prefix/on_db.sql", app_data.clone())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("Hi from db !"),
"{body_str}\nexpected to contain: Hi from db !"
);
}
#[cfg(unix)]
#[actix_web::test]
async fn test_non_unicode_static_path_returns_bad_request_with_db_fs() {
let mut config = test_config();
if !config.database_url.starts_with("sqlite") {
return;
}
config.database_url =
"sqlite://file:test_non_unicode_static_path?mode=memory&cache=shared".to_string();
let state = AppState::init(&config).await.unwrap();
let expected_db_path = "\u{FFFD}.txt";
let mut conn = state.db.connection.acquire().await.unwrap();
(&mut *conn)
.execute(sqlpage::filesystem::DbFsQueries::get_create_table_sql(
sqlpage::webserver::database::SupportedDatabase::Sqlite,
))
.await
.unwrap();
let insert_sql = format!(
"INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})",
make_placeholder(state.db.info.kind, 1),
make_placeholder(state.db.info.kind, 2)
);
sqlx::query(&insert_sql)
.bind(expected_db_path)
.bind("file from db fs".as_bytes())
.execute(&mut *conn)
.await
.unwrap();
drop(conn);
let state = AppState::init(&config).await.unwrap();
let app_data = actix_web::web::Data::new(state);
let req = test::TestRequest::get()
.uri("/%FF.txt")
.app_data(app_data)
.to_srv_request();
let err = sqlpage::webserver::http::main_handler(req)
.await
.expect_err("non-unicode path should not panic and must return bad request");
assert_eq!(
err.as_response_error().status_code(),
StatusCode::BAD_REQUEST
);
}
#[actix_web::test]
async fn test_routing_with_prefix() {
let mut config = test_config();
config.site_prefix = "/prefix/".to_string();
let state = AppState::init(&config).await.unwrap();
let app_data = actix_web::web::Data::new(state);
let resp = req_path_with_app_data(
"/prefix/tests/sql_test_files/component_rendering/simple.sql",
app_data.clone(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("It works !"),
"{body_str}\nexpected to contain: It works !"
);
assert!(
body_str.contains("href=\"/prefix/"),
"{body_str}\nexpected to contain links with site prefix"
);
let resp = req_path_with_app_data("/prefix/nonexistent.sql", app_data.clone())
.await
.expect("should handle 404");
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("404"),
"Response should contain \"404\", but got:\n{body_str}"
);
let resp = req_path_with_app_data("/prefix/sqlpage/migrations/0001_init.sql", app_data.clone())
.await
.expect_err("Expected forbidden error")
.to_string();
assert!(resp.to_lowercase().contains("forbidden"), "{resp}");
let resp = req_path_with_app_data(
"/tests/sql_test_files/component_rendering/simple.sql",
app_data,
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
let location = resp
.headers()
.get("location")
.expect("location header should be present");
assert_eq!(location.to_str().unwrap(), "/prefix/");
}
#[actix_web::test]
async fn test_hidden_files() {
let resp_result = req_path("/tests/core/.hidden.sql").await;
assert!(
resp_result.is_err(),
"Accessing a hidden file should be forbidden, but received success: {resp_result:?}"
);
let resp = resp_result.unwrap_err().error_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let srv_resp = actix_web::test::TestRequest::default().to_srv_response(resp);
let body = test::read_body(srv_resp).await;
assert!(
String::from_utf8_lossy(&body)
.to_lowercase()
.contains("forbidden"),
);
}
#[actix_web::test]
async fn test_official_website_documentation() {
let app_data = make_app_data_for_official_website().await;
let resp = req_path_with_app_data("/component.sql?component=button", app_data)
.await
.unwrap_or_else(|e| {
panic!("Failed to get response for /component.sql?component=button: {e}")
});
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains(r#"<button type="submit" form="poem" formaction="?action"#),
"{body_str}\nexpected to contain a button with formaction"
);
}
#[actix_web::test]
async fn test_official_website_basic_auth_example() {
let resp = req_path_with_app_data(
"/examples/authentication/basic_auth.sql",
make_app_data_for_official_website().await,
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("Unauthorized"),
"{body_str}\nexpected to contain Unauthorized"
);
}
async fn make_app_data_for_official_website() -> actix_web::web::Data<AppState> {
crate::common::init_log();
let config_path = std::path::Path::new("examples/official-site/sqlpage");
let mut app_config = sqlpage::app_config::load_from_directory(config_path).unwrap();
app_config.web_root = std::path::PathBuf::from("examples/official-site");
app_config.database_url = "sqlite::memory:".to_string();
let app_state = make_app_data_from_config(app_config.clone()).await;
webserver::database::migrations::apply(&app_config, &app_state.db)
.await
.unwrap();
app_state
}
+2
View File
@@ -0,0 +1,2 @@
-- see tests/sql_test_files/component_rendering/temp_table_accessible_in_run_sql_nomssql.sql
select 'text' as component, x as contents from temp_t;
+1
View File
@@ -0,0 +1 @@
select 'text' as component, 'It works !' AS contents;
+11
View File
@@ -0,0 +1,11 @@
select
'csv' as component,
';' as separator;
select
0 as id,
'Hello World !' as msg
union all
select
1 as id,
'Tu gères '';'' et ''"'' ?' as msg;
+3
View File
@@ -0,0 +1,3 @@
select 'csv' as component;
select 0 as id, 'before the error' as msg;
select * from definitely_missing_table_xyz;
+4
View File
@@ -0,0 +1,4 @@
select 'csv' as component;
-- Error before any data row: the CSV header is never written, so columns is
-- empty when handle_error runs.
select * from definitely_missing_table_xyz;
@@ -0,0 +1,5 @@
select
'csv' as component,
'report.csv; filename*=UTF-8''''evil.html' as filename;
select 1 as a;
+14
View File
@@ -0,0 +1,14 @@
select
'columns' as component;
select
'Pro Plan' as title,
'€40' as value,
'rocket' as icon,
'For growing projects needing enhanced features' as description,
JSON (
'{"icon":"database","color":"blue","description":"1GB Database"}'
) as item,
JSON (
'{"icon":"headset","color":"green","description":"Priority Support"}'
) as item;
+3
View File
@@ -0,0 +1,3 @@
select 'json' as component;
select 'It works!' as message;
select 'cool' as cool;
+3
View File
@@ -0,0 +1,3 @@
select 'json' as component;
select 'before the error' as message;
select * from definitely_missing_table_xyz;
+353
View File
@@ -0,0 +1,353 @@
use actix_web::{
http::{StatusCode, header},
test::{self, TestRequest},
};
use sqlpage::webserver::http::main_handler;
use crate::common::{get_request_to, make_app_data};
async fn req_with_accept(
path: &str,
accept: &str,
) -> actix_web::Result<actix_web::dev::ServiceResponse> {
let app_data = make_app_data().await;
let req = TestRequest::get()
.uri(path)
.insert_header((header::ACCEPT, accept))
.app_data(app_data)
.to_srv_request();
main_handler(req).await
}
#[actix_web::test]
async fn test_json_body() -> actix_web::Result<()> {
let req = get_request_to("/tests/data_formats/json_data.sql")
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"application/json"
);
let body_json: serde_json::Value = test::read_body_json(resp).await;
assert_eq!(
body_json,
serde_json::json!([{"message": "It works!"}, {"cool": "cool"}])
);
Ok(())
}
#[actix_web::test]
async fn test_csv_body() -> actix_web::Result<()> {
let app_data = make_app_data().await;
if matches!(
app_data.db.info.database_type,
sqlpage::webserver::database::SupportedDatabase::Oracle
) {
return Ok(());
}
let req = crate::common::get_request_to_with_data("/tests/data_formats/csv_data.sql", app_data)
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"text/csv; charset=utf-8"
);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(
body_str,
"id;msg\n0;Hello World !\n1;\"Tu gères ';' et '\"\"' ?\"\n"
);
Ok(())
}
#[actix_web::test]
async fn test_csv_filename_header_injection() -> actix_web::Result<()> {
use actix_web::http::header::ContentDisposition;
// The csv `filename` is `report.csv; filename*=UTF-8''evil.html`, which
// tries to smuggle an extra `filename*` parameter into the
// Content-Disposition header. The attacker-supplied value must NOT create a
// second, agent-preferred parameter; it has to stay inside a single,
// properly quoted `filename` value.
let resp = crate::common::req_path("/tests/data_formats/csv_filename_injection.sql")
.await
.expect("request failed");
assert_eq!(resp.status(), StatusCode::OK);
let raw = resp
.headers()
.get(header::CONTENT_DISPOSITION)
.expect("missing Content-Disposition header")
.clone();
// Parse the header the same way a compliant user agent would, so that
// `;` and `=` inside a quoted value are treated as literal data, not as
// parameter separators.
let disposition = ContentDisposition::from_raw(&raw)
.unwrap_or_else(|e| panic!("invalid Content-Disposition {raw:?}: {e}"));
// No extended `filename*` parameter must have been injected.
assert!(
disposition.get_filename_ext().is_none(),
"attacker injected a separate filename* parameter: {raw:?}"
);
// The whole attacker payload must remain a single, inert `filename` value.
assert_eq!(
disposition.get_filename(),
Some("report.csv; filename*=UTF-8''evil.html"),
"the attacker payload should stay inside a single filename value: {raw:?}"
);
Ok(())
}
#[actix_web::test]
async fn test_json_columns() {
let app_data = crate::common::make_app_data().await;
if !matches!(
app_data.db.to_string().to_lowercase().as_str(),
"postgres" | "sqlite"
) {
log::info!("Skipping test_json_columns on database {}", app_data.db);
return;
}
let resp_result = crate::common::req_path("/tests/data_formats/json_columns.sql").await;
let resp = resp_result.expect("Failed to request /tests/data_formats/json_columns.sql");
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
let body_html_escaped = body_str.replace("&quot;", "\"");
assert!(
!body_html_escaped.contains("error"),
"the request should not have failed, in: {body_html_escaped}"
);
assert!(body_html_escaped.contains("1GB Database"));
assert!(body_html_escaped.contains("Priority Support"));
assert!(
!body_html_escaped.contains("\"description\""),
"the json should have been parsed, not returned as a string, in: {body_html_escaped}"
);
assert!(
!body_html_escaped.contains("{"),
"the json should have been parsed, not returned as a string, in: {body_html_escaped}"
);
}
#[actix_web::test]
async fn test_accept_json_returns_json_array() -> actix_web::Result<()> {
let resp = req_with_accept(
"/tests/sql_test_files/component_rendering/simple.sql",
"application/json",
)
.await?;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"application/json"
);
let body_json: serde_json::Value = test::read_body_json(resp).await;
assert!(body_json.is_array());
let arr = body_json.as_array().unwrap();
assert!(arr.len() >= 2);
assert_eq!(arr[0]["component"], "shell");
assert_eq!(arr[1]["component"], "text");
Ok(())
}
#[actix_web::test]
async fn test_accept_ndjson_returns_jsonlines() -> actix_web::Result<()> {
let resp = req_with_accept(
"/tests/sql_test_files/component_rendering/simple.sql",
"application/x-ndjson",
)
.await?;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"application/x-ndjson"
);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
let lines: Vec<&str> = body_str.trim().lines().collect();
assert!(lines.len() >= 2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(lines[0]).unwrap()["component"],
"shell"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(lines[1]).unwrap()["component"],
"text"
);
Ok(())
}
#[actix_web::test]
async fn test_accept_html_returns_html() -> actix_web::Result<()> {
let resp = req_with_accept(
"/tests/sql_test_files/component_rendering/simple.sql",
"text/html",
)
.await?;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
Ok(())
}
#[actix_web::test]
async fn test_accept_wildcard_returns_html() -> actix_web::Result<()> {
let resp = req_with_accept(
"/tests/sql_test_files/component_rendering/simple.sql",
"*/*",
)
.await?;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
Ok(())
}
#[actix_web::test]
async fn test_accept_json_redirect_still_works() -> actix_web::Result<()> {
let resp =
req_with_accept("/tests/server_timing/redirect_test.sql", "application/json").await?;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"/destination.sql"
);
Ok(())
}
/// Builds an `AppState` running in production mode.
async fn make_prod_app_data() -> actix_web::web::Data<sqlpage::AppState> {
crate::common::init_log();
let mut config = crate::common::test_config();
config.environment = sqlpage::app_config::DevOrProd::Production;
crate::common::make_app_data_from_config(config).await
}
async fn req_prod_with_accept(path: &str, accept: &str) -> String {
let app_data = make_prod_app_data().await;
let req = TestRequest::get()
.uri(path)
.insert_header((header::ACCEPT, accept))
.app_data(app_data)
.to_srv_request();
let resp = main_handler(req)
.await
.expect("request should not fail at the handler level");
let body = test::read_body(resp).await;
String::from_utf8(body.to_vec()).unwrap()
}
/// In production, a SQL error that happens mid-stream must not leak the SQL
/// statement, the source file path, or the raw database error text.
fn assert_no_sql_leak(body: &str, context: &str) {
for needle in [
"definitely_missing_table_xyz",
"The SQL statement sent by SQLPage",
"error_leak.sql",
".sql\"",
] {
assert!(
!body.contains(needle),
"production error response leaked {needle:?} in {context}: {body}"
);
}
assert!(
body.to_lowercase().contains("administrator"),
"production error response should contain a generic message in {context}: {body}"
);
}
#[actix_web::test]
async fn test_prod_json_error_does_not_leak_sql() {
let body = req_prod_with_accept(
"/tests/data_formats/json_error_leak.sql",
"application/json",
)
.await;
assert!(
body.contains("before the error"),
"the good row should still be streamed: {body}"
);
assert_no_sql_leak(&body, "json error");
}
#[actix_web::test]
async fn test_prod_csv_error_does_not_leak_sql() {
let app_data = make_prod_app_data().await;
if matches!(
app_data.db.info.database_type,
sqlpage::webserver::database::SupportedDatabase::Oracle
) {
return;
}
let req = TestRequest::get()
.uri("/tests/data_formats/csv_error_leak.sql")
.insert_header((header::ACCEPT, "text/csv"))
.app_data(app_data)
.to_srv_request();
let resp = main_handler(req).await.expect("handler should not fail");
let body = test::read_body(resp).await;
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(
body.contains("before the error"),
"the good row should still be streamed: {body}"
);
assert_no_sql_leak(&body, "csv error");
}
/// A CSV page can hit an error before its first data row (so no header has been
/// written and `columns` is empty). The generic error message must still be
/// emitted instead of an empty record.
#[actix_web::test]
async fn test_prod_csv_error_before_any_row_still_reports() {
let app_data = make_prod_app_data().await;
if matches!(
app_data.db.info.database_type,
sqlpage::webserver::database::SupportedDatabase::Oracle
) {
return;
}
let req = TestRequest::get()
.uri("/tests/data_formats/csv_error_no_rows.sql")
.insert_header((header::ACCEPT, "text/csv"))
.app_data(app_data)
.to_srv_request();
let resp = main_handler(req).await.expect("handler should not fail");
let body = test::read_body(resp).await;
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(
body.to_lowercase().contains("administrator"),
"csv error before the first row must still emit the generic error message: {body:?}"
);
assert_no_sql_leak(&body, "csv error before any row");
}
/// An author may only intend a page to be served as HTML, but a client can
/// request it with `Accept: application/json` and pick the JSON renderer.
/// In production that path must not leak SQL text either.
#[actix_web::test]
async fn test_prod_html_page_requested_as_json_does_not_leak_sql() {
let body = req_prod_with_accept(
"/tests/data_formats/text_error_leak.sql",
"application/json",
)
.await;
assert_no_sql_leak(&body, "text page requested as json");
}
+2
View File
@@ -0,0 +1,2 @@
select 'text' as component, 'before the error' as contents;
select * from definitely_missing_table_xyz;
+5
View File
@@ -0,0 +1,5 @@
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+386
View File
@@ -0,0 +1,386 @@
import { expect, type Page, test } from "@playwright/test";
const BASE = "http://localhost:8080/";
test("Open documentation", async ({ page }) => {
await page.goto(BASE);
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/SQLPage.*/);
// open the submenu
await page.getByText("Documentation", { exact: true }).first().click();
const components = ["form", "map", "chart", "button"];
for (const component of components) {
await expect(
page.getByRole("link", { name: component }).first(),
).toBeVisible();
}
});
test("chart", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
await expect(page.getByText("Loading...")).not.toBeVisible();
await expect(page.locator(".apexcharts-canvas").first()).toBeVisible();
});
test("chart supports hiding legend", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=chart#component`);
const expensesChart = page.locator(".card", {
has: page.getByRole("heading", { name: "Expenses" }),
});
await expect(expensesChart.locator(".apexcharts-canvas")).toBeVisible();
await expect(expensesChart.locator(".apexcharts-legend")).toBeHidden();
});
test("map", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=map#component`);
await expect(page.getByText("Loading...")).not.toBeVisible();
await expect(page.locator(".leaflet-marker-icon").first()).toBeVisible();
});
test("form example", async ({ page }) => {
await page.goto(`${BASE}/examples/multistep-form`);
// Single selection matching the value or label
await page.getByLabel("From").selectOption("Paris");
await page.getByText("Next").click();
await page.getByLabel(/\bTo\b/).selectOption("Mexico");
await page.getByText("Next").click();
await page.getByLabel("Number of Adults").fill("1");
await page.getByText("Next").click();
await page.getByLabel("Passenger 1 (adult)").fill("John Doe");
await page.getByText("Book the flight").click();
await expect(page.getByText("John Doe").first()).toBeVisible();
});
test("File upload", async ({ page }) => {
await page.goto(`${BASE}/your-first-sql-website`);
await page.getByRole("button", { name: "Examples", exact: true }).click();
await page.getByText("File uploads").click();
const my_svg = '<svg><text y="20">Hello World</text></svg>';
// @ts-ignore
const buffer = Buffer.from(my_svg);
await page.getByLabel("Picture").setInputFiles({
name: "small.svg",
mimeType: "image/svg+xml",
buffer,
});
await page.getByRole("button", { name: "Upload picture" }).click();
await expect(
page.locator("img[src^=data]").first().getAttribute("src"),
).resolves.toBe(`data:image/svg+xml;base64,${buffer.toString("base64")}`);
});
test("Authentication example", async ({ page }) => {
await page.goto(`${BASE}/examples/authentication/login.sql`);
await expect(page.locator("h1", { hasText: "Authentication" })).toBeVisible();
const usernameInput = page.getByLabel("Username");
const passwordInput = page.getByLabel("Password");
const loginButton = page.getByRole("button", { name: "Log in" });
await expect(usernameInput).toBeVisible();
await expect(passwordInput).toBeVisible();
await expect(loginButton).toBeVisible();
await usernameInput.fill("admin");
await passwordInput.fill("admin");
await loginButton.click();
await expect(page.getByText("You are logged in as admin")).toBeVisible();
});
test("table filtering", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=table`);
const tableSection = page.locator(".card", {
has: page.getByRole("cell", { name: "Chart", exact: true }),
});
const searchInput = tableSection.getByPlaceholder("Search…");
await searchInput.fill("chart");
const chartCell = tableSection.getByRole("cell", { name: "Chart" });
await expect(chartCell).toBeVisible();
await expect(chartCell).toHaveClass(/\b_col_name\b/);
await expect(chartCell).toHaveCSS("vertical-align", "middle");
await expect(
tableSection.getByRole("cell", { name: "Table" }),
).not.toBeVisible();
});
test("table sorting", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=table`);
const tableSection = page.locator(".table-responsive", {
has: page.getByRole("cell", { name: "31456" }),
});
// Test numeric sorting on id column
await tableSection.getByRole("button", { name: "id" }).click();
let ids = await tableSection.locator("td.id").allInnerTexts();
let numericIds = ids.map((id) => Number.parseInt(id));
const sortedIds = [...numericIds].sort((a, b) => a - b);
expect(numericIds).toEqual(sortedIds);
// Test reverse sorting
await tableSection.getByRole("button", { name: "id" }).click();
ids = await tableSection.locator("td.id").allInnerTexts();
numericIds = ids.map((id) => Number.parseInt(id));
const reverseSortedIds = [...numericIds].sort((a, b) => b - a);
expect(numericIds).toEqual(reverseSortedIds);
// Test amount in stock column sorting
await tableSection.getByRole("button", { name: "Amount in stock" }).click();
const amounts = await tableSection.locator("td.Amount").allInnerTexts();
const numericAmounts = amounts.map((amount) =>
Number.parseInt(amount.replace(/[^0-9]/g, "")),
);
const sortedAmounts = [...numericAmounts].sort((a, b) => a - b);
expect(numericAmounts).toEqual(sortedAmounts);
});
async function checkNoConsoleErrors(page: Page, component: string) {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
await page.goto(`${BASE}/documentation.sql?component=${component}`);
await page.waitForLoadState();
expect(errors).toHaveLength(0);
}
test("no console errors on table page", async ({ page }) => {
await checkNoConsoleErrors(page, "table");
});
test("no console errors on chart page", async ({ page }) => {
await checkNoConsoleErrors(page, "chart");
});
test("no console errors on map page", async ({ page }) => {
await checkNoConsoleErrors(page, "map");
});
test("no console errors on card page", async ({ page }) => {
await checkNoConsoleErrors(page, "card");
});
test("CSP issues unique nonces per request", async ({ page }) => {
const csp1 = await (await page.goto(BASE)).headerValue(
"content-security-policy",
);
const csp2 = await (await page.reload()).headerValue(
"content-security-policy",
);
expect(csp1, `check if ${csp1} != ${csp2}`).not.toEqual(csp2);
});
test("form component documentation", async ({ page }) => {
await page.goto(`${BASE}/component.sql?component=form`);
// Find the form that contains radio buttons for component selection
const componentForm = page.locator("form", {
has: page.getByRole("radio", { name: "Chart" }),
});
// the form should be visible
await expect(componentForm).toBeVisible();
// Check that "form" is the first and default selected option
const mapRadio = componentForm.getByRole("radio", { name: "Map" });
await expect(mapRadio).toHaveValue("map");
await expect(mapRadio).toBeChecked();
// Select "Chart" option and submit
await componentForm.getByLabel("Chart").click({ force: true });
await componentForm.getByRole("button", { name: "Submit" }).click();
// Verify we're on the chart documentation page
await expect(
page.getByRole("heading", { name: /chart/i, level: 1 }),
).toBeVisible();
});
test("form select combines initial options with remote search results", async ({
page,
}) => {
await page.goto(`${BASE}/component.sql?component=form`);
const select = page
.locator(
'select[data-options_source="examples/from_component_options_source.sql"]',
)
.first();
await expect(select).toBeAttached();
await page.waitForFunction(
() =>
!!document.querySelector<HTMLSelectElement>(
'select[data-options_source="examples/from_component_options_source.sql"]',
)?.tomselect,
);
const initialState = await select.evaluate((element) => {
const tomselect = element.tomselect;
return {
value: tomselect.getValue(),
labels: Object.fromEntries(
Object.entries(tomselect.options).map(([value, option]) => [
value,
option.label,
]),
),
};
});
expect(initialState).toEqual({
value: "form",
labels: { form: "Form" },
});
await select.evaluate((element) => element.tomselect.focus());
await page.keyboard.type("form");
await page.waitForResponse((response) =>
response
.url()
.includes("examples/from_component_options_source.sql?search=form"),
);
await expect
.poll(async () =>
select.evaluate((element) => ({
value: element.tomselect.getValue(),
formLabel: element.tomselect.options.form?.label,
})),
)
.toEqual({
value: "form",
formLabel: "form",
});
await select.evaluate((element) => element.tomselect.setTextboxValue(""));
await page.keyboard.type("map");
await page.waitForResponse((response) =>
response
.url()
.includes("examples/from_component_options_source.sql?search=map"),
);
await expect
.poll(async () =>
select.evaluate((element) => ({
value: element.tomselect.getValue(),
formLabel: element.tomselect.options.form?.label,
mapLabel: element.tomselect.options.map?.label,
})),
)
.toEqual({
value: "form",
formLabel: "form",
mapLabel: "map",
});
});
test("modal", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=modal#component`);
// get the button that opens the modal
const openButton = page.getByRole("button", { name: "Open a simple modal" });
await openButton.click();
const modal = page.getByRole("dialog", { label: "A modal box" });
await expect(modal).toBeVisible();
// close the modal
await page.keyboard.press("Escape");
await expect(modal).not.toBeVisible();
await openButton.click();
await expect(modal).toBeVisible();
await modal.getByRole("button", { label: "Close" }).first().click();
await expect(modal).not.toBeVisible();
});
test("table action buttons - edit_url and delete_url", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=table`);
const tableSection = page.locator(".table-responsive", {
has: page.getByRole("cell", { name: "PharmaCo" }),
});
const editButton = tableSection.getByTitle("Edit").first();
await expect(editButton).toBeVisible();
await expect(editButton).toHaveAttribute("href", /action=edit&update_id=\d+/);
const deleteButton = tableSection.getByTitle("Delete").first();
await expect(deleteButton).toBeVisible();
await expect(deleteButton).toHaveAttribute(
"href",
/action=delete&delete_id=\d+/,
);
});
test("table action buttons - custom_actions", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=table`);
const tableSection = page.locator(".table-responsive", {
has: page.getByRole("cell", { name: "PharmaCo" }),
});
const historyButton = tableSection
.getByTitle("View Standard History")
.first();
await expect(historyButton).toBeVisible();
await expect(historyButton).toHaveAttribute(
"href",
/action=history&standard_id=\d+/,
);
});
test("table action buttons - _sqlpage_actions", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=table`);
const tableSection = page.locator(".table-responsive", {
has: page.getByRole("cell", { name: "PharmaCo" }),
});
const pdfButtons = tableSection.getByTitle("View Presentation");
await expect(pdfButtons.first()).toBeVisible();
await expect(pdfButtons).toHaveCount(3);
const firstPdfButton = pdfButtons.first();
await expect(firstPdfButton).toHaveAttribute(
"href",
"https://sql-page.com/pgconf/2024-sqlpage-badass.pdf",
);
const setInUseButton = tableSection.getByTitle("Set In Use");
await expect(setInUseButton).toBeVisible();
await expect(setInUseButton).toHaveAttribute(
"href",
/action=set_in_use&standard_id=32/,
);
const retireButton = tableSection.getByTitle("Retire Standard");
await expect(retireButton).toBeVisible();
await expect(retireButton).toHaveAttribute(
"href",
/action=retire&standard_id=33/,
);
});
test("table action buttons - disabled action", async ({ page }) => {
await page.goto(`${BASE}/documentation.sql?component=table`);
const tableSection = page.locator(".table-responsive", {
has: page.getByRole("cell", { name: "PharmaCo" }),
});
const viewPresentationButtons = tableSection.getByTitle("View Presentation");
await expect(viewPresentationButtons).toHaveCount(3);
const actionColumnButtons = tableSection.locator(
"td._col_Action a[data-action='Action']",
);
await expect(actionColumnButtons).toHaveCount(3);
const emptyActionButton = actionColumnButtons.last();
await expect(emptyActionButton).toHaveAttribute("href", "null");
await expect(emptyActionButton).toHaveAttribute("title", "Action");
});
+97
View File
@@ -0,0 +1,97 @@
{
"name": "end-to-end",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "end-to-end",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^25.9.1"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "end-to-end",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "playwright test --reporter=line",
"gui": "playwright test --ui"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^25.9.1"
}
}
+78
View File
@@ -0,0 +1,78 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./.",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});
+3
View File
@@ -0,0 +1,3 @@
SELECT 'alert' AS component,
'We almost got an oopsie' AS title,
'But the `404.sql` file saved the day!' AS description_md;
+43
View File
@@ -0,0 +1,43 @@
use crate::common::{get_request_to, req_path};
use actix_web::{http::StatusCode, test};
use sqlpage::webserver::http::main_handler;
#[actix_web::test]
async fn test_basic_auth_not_provided() {
let resp_result = req_path("/tests/errors/basic_auth.sql").await;
let resp = resp_result.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers().get("www-authenticate").unwrap(),
"Basic realm=\"Authentication required\", charset=\"UTF-8\""
);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("Unauthorized"),
"{body_str}\nexpected to contain Unauthorized"
);
assert!(
!body_str.contains("Success!"),
"{body_str}\nexpected not to contain Success!"
);
}
#[actix_web::test]
async fn test_basic_auth_with_credentials() {
let req = get_request_to("/tests/errors/basic_auth.sql")
.await
.unwrap() // log in with credentials "user:password"
.append_header(("Authorization", "Basic dXNlcjpwYXNzd29yZA=="))
.to_srv_request();
let resp = main_handler(req)
.await
.expect("req with credentials should succeed");
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("Success!"),
"{body_str}\nexpected to contain Success"
);
}
+5
View File
@@ -0,0 +1,5 @@
SELECT 'authentication' AS component,
'$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$oKBq5E8XFTHO2w' AS password_hash, -- this is a hash of the password 'password'
sqlpage.basic_auth_password() AS password; -- this is the password that the user entered in the browser popup
SELECT 'text' AS component, 'Success!' AS contents;
+108
View File
@@ -0,0 +1,108 @@
use crate::common::req_path;
use actix_web::{http::StatusCode, test};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde_json::{Value, json};
struct InvalidHeaderCase {
name: &'static str,
properties: Value,
}
async fn assert_invalid_header_response(case: &InvalidHeaderCase) {
let properties = serde_json::to_string(&case.properties).unwrap();
let properties = utf8_percent_encode(&properties, NON_ALPHANUMERIC).to_string();
let path = format!("/tests/errors/invalid_header.sql?properties={properties}");
let resp = req_path(&path).await.unwrap_or_else(|err| {
panic!(
"{} should return an error response instead of failing the request: {err:#}",
case.name
)
});
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"{} should return a 500 response",
case.name
);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.to_lowercase().contains("error"),
"{} should render an error response body, got:\n{body_str}",
case.name
);
}
#[actix_web::test]
async fn test_invalid_header_components_return_an_error_response() {
let cases = vec![
InvalidHeaderCase {
name: "cookie domain with newline",
properties: json!({
"component": "cookie",
"name": "boom",
"value": "x",
"domain": "\n",
}),
},
InvalidHeaderCase {
name: "cookie path with DEL",
properties: json!({
"component": "cookie",
"name": "boom",
"value": "x",
"path": "\u{007f}",
}),
},
InvalidHeaderCase {
name: "http_header value with carriage return",
properties: json!({
"component": "http_header",
"X-Test": "\r",
}),
},
InvalidHeaderCase {
name: "redirect link with NUL",
properties: json!({
"component": "redirect",
"link": "\u{0000}",
}),
},
InvalidHeaderCase {
name: "authentication link with unit separator",
properties: json!({
"component": "authentication",
"link": "\u{001f}",
}),
},
InvalidHeaderCase {
name: "download filename with newline",
properties: json!({
"component": "download",
"data_url": "data:text/plain,ok",
"filename": "\n",
}),
},
InvalidHeaderCase {
name: "csv filename with carriage return",
properties: json!({
"component": "csv",
"filename": "\r",
}),
},
InvalidHeaderCase {
name: "csv title with NUL",
properties: json!({
"component": "csv",
"title": "\u{0000}",
}),
},
];
for case in &cases {
assert_invalid_header_response(case).await;
}
}
+1
View File
@@ -0,0 +1 @@
select 'dynamic' as component, $properties as properties;
+175
View File
@@ -0,0 +1,175 @@
use actix_web::{
http::{self, StatusCode},
test,
};
use sqlpage::{AppState, webserver::http::main_handler};
use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config};
mod basic_auth;
mod invalid_header;
/// Sends a direct unprivileged GET request through the main handler and returns the
/// resulting HTTP status, whether the handler returned an `Ok` response or an `Err`.
async fn direct_request_status(path: &str, app_data: actix_web::web::Data<AppState>) -> StatusCode {
let req = test::TestRequest::get()
.uri(path)
.app_data(app_data)
.insert_header(actix_web::http::header::Accept::html())
.to_srv_request();
match main_handler(req).await {
Ok(resp) => resp.status(),
Err(e) => e.error_response().status(),
}
}
/// Regression test for a cache privilege-escalation bug.
///
/// `sqlpage.run_sql(...)` loads include files with privilege, so it is allowed to
/// load reserved files under the `sqlpage/` prefix and stores their parsed form in
/// the shared `sql_file_cache`. A subsequent *direct* unprivileged HTTP request for
/// that same reserved path must still be rejected with 403, even while the cache
/// entry is fresh. Before the fix, the fresh cache hit short-circuited the
/// unprivileged path guard and the private SQL was executed and served.
#[actix_web::test]
async fn test_private_path_not_accessible_after_privileged_cache_priming() {
// Keep cache entries "fresh" so the bug (skipping the path guard on fresh hits) is exercised.
let mut config = test_config();
config.cache_stale_duration_ms = Some(60_000);
let app_data = make_app_data_from_config(config).await;
// 1. A trusted page primes the cache by loading the reserved file with privilege.
let prime = req_path_with_app_data("/tests/errors/prime_private_cache.sql", app_data.clone())
.await
.expect("priming page should render");
assert_eq!(prime.status(), StatusCode::OK);
let prime_body = String::from_utf8(test::read_body(prime).await.to_vec()).unwrap();
assert!(
prime_body.contains("private cache bypass secret"),
"priming page should have executed the private file via run_sql, got: {prime_body}"
);
// 2. A direct unprivileged HTTP request for the reserved path must stay forbidden.
for path in [
"/sqlpage/private_cache_bypass_test.sql",
// Extensionless alias: routing appends .sql and finds the fresh cache entry.
"/sqlpage/private_cache_bypass_test",
] {
let status = direct_request_status(path, app_data.clone()).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{path} must be forbidden even after privileged cache priming, got {status}"
);
}
}
#[actix_web::test]
async fn test_privileged_paths_are_not_accessible() {
let resp_result = req_path("/sqlpage/migrations/0001_init.sql").await;
assert!(
resp_result.is_err(),
"Accessing a migration file should be forbidden, but received success: {resp_result:?}"
);
let resp = resp_result.unwrap_err().error_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let srv_resp = actix_web::test::TestRequest::default().to_srv_response(resp);
let body = test::read_body(srv_resp).await;
assert!(
String::from_utf8_lossy(&body)
.to_lowercase()
.contains("forbidden"),
);
}
#[actix_web::test]
async fn test_404_fallback() {
for f in [
"/tests/errors/does_not_exist.sql",
"/tests/errors/does_not_exist.html",
"/tests/errors/does_not_exist/",
] {
let resp_result = req_path(f).await;
let resp = resp_result.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK, "{f} isnt 200");
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("But the "));
assert!(body.contains("404.sql"));
assert!(body.contains("file saved the day!"));
assert!(!body.contains("error"));
}
}
#[actix_web::test]
async fn test_default_404() {
for f in [
"/i-do-not-exist.html",
"/i-do-not-exist.sql",
"/i-do-not-exist/",
] {
let resp_result = req_path(f).await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"{f} should return 404"
);
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
let msg = "The page you were looking for does not exist";
assert!(
body.contains(msg),
"{f} should contain '{msg}' but got:\n{body}"
);
assert!(!body.contains("error"));
}
}
#[actix_web::test]
async fn test_default_404_with_redirect() {
let resp_result = req_path("/i-do-not-exist").await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"/i-do-not-exist should return 404"
);
let resp_result = req_path("/i-do-not-exist/").await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"/i-do-not-exist/ should return 404"
);
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
let msg = "The page you were looking for does not exist";
assert!(
body.contains(msg),
"/i-do-not-exist/ should contain '{msg}' but got:\n{body}"
);
assert!(!body.contains("error"));
}
#[actix_web::test]
async fn test_default_404_when_request_path_descends_into_file() {
let resp_result = req_path("/tests/it_works.txt/site/wp-includes/wlwmanifest.xml").await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"descending into a file path should behave like a missing resource"
);
let body = test::read_body(resp).await;
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("The page you were looking for does not exist"));
assert!(!body.contains("error"));
}
+4
View File
@@ -0,0 +1,4 @@
-- Trusted page: loads a reserved (sqlpage/) file via the privileged run_sql,
-- which populates the shared sql_file_cache with the parsed private file.
select 'dynamic' as component,
sqlpage.run_sql('sqlpage/private_cache_bypass_test.sql') as properties;
+1
View File
@@ -0,0 +1 @@
select sqlpage.exec($exec_program, $exec_arg1, $exec_arg2, 'It', $thisisnull, 'works', '!') as actual;
+29
View File
@@ -0,0 +1,29 @@
use actix_web::{http::header, test::TestRequest};
use sqlpage::webserver::http::main_handler;
#[actix_web::test]
async fn test_exec() {
let app_data = crate::common::make_app_data().await;
let req = TestRequest::get()
.uri(exec_test_uri())
.app_data(app_data)
.insert_header(header::Accept::json())
.to_srv_request();
let resp = main_handler(req).await.unwrap();
let body = actix_web::test::read_body(resp).await;
let rows: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
let actual = rows[0]["actual"].as_str().unwrap();
assert!(actual.contains("It works !"), "actual: {actual:?}");
}
#[cfg(windows)]
fn exec_test_uri() -> &'static str {
"/tests/exec/exec.sql?exec_program=cmd.exe&exec_arg1=/C&exec_arg2=echo"
}
#[cfg(not(windows))]
fn exec_test_uri() -> &'static str {
"/tests/exec/exec.sql?exec_program=echo"
}
+1
View File
@@ -0,0 +1 @@
It works !
+12
View File
@@ -0,0 +1,12 @@
mod basic;
mod common;
mod core;
mod data_formats;
mod errors;
mod exec;
mod oidc;
mod requests;
mod server_timing;
pub mod sql_test_files;
mod transactions;
mod uploads;
+840
View File
@@ -0,0 +1,840 @@
use actix_web::{
App, HttpResponse, HttpServer, Responder,
cookie::Cookie,
http::{StatusCode, header},
test,
web::{self, Data},
};
use base64::Engine;
use openidconnect::url::Url;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlpage::webserver::http::create_app;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio_util::sync::{CancellationToken, DropGuard};
fn base64url_encode(data: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
pub fn make_jwt(claims: &serde_json::Value, secret: &str) -> String {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let header = json!({
"alg": "HS256",
"typ": "JWT",
"kid": "test"
});
let header_b64 = base64url_encode(header.to_string().as_bytes());
let payload_b64 = base64url_encode(claims.to_string().as_bytes());
let message = format!("{}.{}", header_b64, payload_b64);
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size");
mac.update(message.as_bytes());
let signature = mac.finalize().into_bytes();
let signature_b64 = base64url_encode(&signature);
format!("{}.{}.{}", header_b64, payload_b64, signature_b64)
}
type JwtCustomizer<'a> = dyn Fn(serde_json::Value, &str) -> String + Send + Sync + 'a;
struct ProviderState<'a> {
secret: String,
issuer_url: String,
client_id: String,
auth_codes: HashMap<String, String>, // code -> nonce
jwt_customizer: Option<Box<JwtCustomizer<'a>>>,
token_endpoint_delay: Duration,
discovery_count: usize,
}
type ProviderStateWithLifetime<'a> = ProviderState<'a>;
type SharedProviderState = Arc<Mutex<ProviderStateWithLifetime<'static>>>;
#[derive(Serialize, Deserialize)]
struct DiscoveryResponse {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
response_types_supported: Vec<String>,
subject_types_supported: Vec<String>,
id_token_signing_alg_values_supported: Vec<String>,
end_session_endpoint: String,
}
#[derive(Serialize)]
struct JwksResponse {
keys: Vec<serde_json::Value>,
}
#[derive(Serialize)]
struct TokenResponse {
access_token: String,
token_type: String,
id_token: String,
expires_in: i64,
}
async fn discovery_endpoint(state: Data<SharedProviderState>) -> impl Responder {
let mut state = state.lock().unwrap();
state.discovery_count += 1;
let discovery = DiscoveryResponse {
issuer: state.issuer_url.clone(),
authorization_endpoint: format!("{}/auth", state.issuer_url),
token_endpoint: format!("{}/token", state.issuer_url),
jwks_uri: format!("{}/jwks", state.issuer_url),
response_types_supported: vec!["code".to_string()],
subject_types_supported: vec!["public".to_string()],
id_token_signing_alg_values_supported: vec!["HS256".to_string()],
end_session_endpoint: format!("{}/logout", state.issuer_url),
};
drop(state);
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/json"))
.json(discovery)
}
async fn jwks_endpoint(state: Data<SharedProviderState>) -> impl Responder {
let state = state.lock().unwrap();
let jwks = JwksResponse {
keys: vec![json!({
"kty": "oct",
"kid": "test",
"use": "sig",
"alg": "HS256",
"k": base64url_encode(state.secret.as_bytes())
})],
};
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/json"))
.json(jwks)
}
async fn token_endpoint(
state: Data<SharedProviderState>,
req: web::Form<HashMap<String, String>>,
) -> impl Responder {
let mut state = state.lock().unwrap();
let Some(code) = req.get("code") else {
return HttpResponse::BadRequest().body("Missing code");
};
let nonce = state.auth_codes.get(code).cloned().unwrap_or_default();
if nonce.is_empty() {
return HttpResponse::BadRequest().body("Unknown code");
}
let now = chrono::Utc::now().timestamp();
let claims = json!({
"iss": state.issuer_url.as_str(),
"sub": "test_user",
"aud": state.client_id.as_str(),
"exp": now + 3600,
"iat": now,
"nonce": nonce,
});
let id_token = state
.jwt_customizer
.take()
.map(|customizer| customizer(claims.clone(), &state.secret))
.unwrap_or_else(|| make_jwt(&claims, &state.secret));
let delay = state.token_endpoint_delay;
drop(state);
let response = TokenResponse {
access_token: "test_access_token".to_string(),
token_type: "Bearer".to_string(),
id_token,
expires_in: 3600,
};
let json_bytes = serde_json::to_vec(&response).unwrap();
let body = futures_util::stream::once(async move {
tokio::time::sleep(delay).await;
Ok::<web::Bytes, actix_web::Error>(web::Bytes::from(json_bytes))
});
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/json"))
.streaming(body)
}
pub struct FakeOidcProvider {
pub issuer_url: String,
pub client_id: String,
pub client_secret: String,
state: SharedProviderState,
_stop_on_drop: DropGuard,
}
fn extract_set_cookies(headers: &header::HeaderMap) -> Vec<Cookie<'static>> {
headers
.get_all(header::SET_COOKIE)
.filter_map(|h| h.to_str().ok())
.filter_map(|s| Cookie::parse(s).ok())
.map(Cookie::into_owned)
.collect()
}
impl FakeOidcProvider {
pub async fn new() -> Self {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let issuer_url = format!("http://127.0.0.1:{}", port);
let client_id = "test_client".to_string();
let client_secret = "test_secret".to_string();
let state: SharedProviderState = Arc::new(Mutex::new(ProviderState {
secret: client_secret.clone(),
issuer_url: issuer_url.clone(),
client_id: client_id.clone(),
auth_codes: HashMap::new(),
jwt_customizer: None,
token_endpoint_delay: Duration::ZERO,
discovery_count: 0,
}));
let state_for_server = Arc::clone(&state);
let server_stop = CancellationToken::new();
let stop_on_drop = server_stop.clone().drop_guard();
let server = HttpServer::new(move || {
let state = Data::new(Arc::clone(&state_for_server));
App::new()
.app_data(state.clone())
.route(
"/.well-known/openid-configuration",
web::get().to(discovery_endpoint),
)
.route("/jwks", web::get().to(jwks_endpoint))
.route("/token", web::post().to(token_endpoint))
})
.workers(1)
.listen(listener)
.unwrap()
.shutdown_timeout(1)
.shutdown_signal(server_stop.cancelled_owned())
.run();
tokio::spawn(server);
Self {
issuer_url,
client_id,
client_secret,
state,
_stop_on_drop: stop_on_drop,
}
}
fn with_state_mut<R>(&self, f: impl FnOnce(&mut ProviderState) -> R) -> R {
let mut state = self.state.lock().unwrap();
f(&mut state)
}
pub fn set_token_endpoint_delay(&self, delay: Duration) {
self.with_state_mut(|s| s.token_endpoint_delay = delay);
}
pub fn discovery_count(&self) -> usize {
self.state.lock().unwrap().discovery_count
}
pub fn store_auth_code(&self, code: String, nonce: String) {
self.with_state_mut(|s| {
s.auth_codes.insert(code, nonce);
});
}
}
fn get_query_param(url: &Url, name: &str) -> String {
url.query_pairs()
.find(|(k, _)| k == name)
.unwrap()
.1
.to_string()
}
fn permits_storage_after_proxy_adds_freshness(headers: &header::HeaderMap) -> bool {
// Reproduces https://github.com/sqlpage/SQLPage/issues/1341, where an
// intermediary added `Cache-Control: max-age=86400` to OIDC 303 responses.
// `no-store` must still prevent browser storage when both directives exist.
!headers
.get_all(header::CACHE_CONTROL)
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|directive| directive.trim().eq_ignore_ascii_case("no-store"))
}
macro_rules! request_with_cookies {
($app:expr, $req:expr, $cookies:expr) => {{
let mut req = $req;
for cookie in $cookies.iter() {
req = req.cookie(cookie.clone());
}
let resp = test::call_service(&$app, req.to_request()).await;
for new_cookie in extract_set_cookies(resp.headers()) {
$cookies.retain(|c: &Cookie| c.name() != new_cookie.name());
if !new_cookie.value().is_empty() {
$cookies.push(new_cookie);
}
}
resp
}};
}
async fn setup_oidc_test(
provider_mutator: impl FnOnce(&mut ProviderState),
) -> (
impl actix_web::dev::Service<
actix_http::Request,
Response = actix_web::dev::ServiceResponse<impl actix_web::body::MessageBody>,
Error = actix_web::Error,
>,
FakeOidcProvider,
) {
use sqlpage::{
AppState,
app_config::{AppConfig, test_database_url},
};
crate::common::init_log();
let provider = FakeOidcProvider::new().await;
provider.with_state_mut(provider_mutator);
let db_url = test_database_url();
let config_json = format!(
r#"{{
"database_url": "{db_url}",
"max_database_pool_connections": 1,
"database_connection_retries": 3,
"database_connection_acquire_timeout_seconds": 15,
"allow_exec": true,
"max_uploaded_file_size": 123456,
"listen_on": "127.0.0.1:0",
"system_root_ca_certificates": false,
"oidc_issuer_url": "{}",
"oidc_client_id": "{}",
"oidc_client_secret": "{}",
"oidc_protected_paths": ["/"],
"host": "localhost:1"
}}"#,
provider.issuer_url, provider.client_id, provider.client_secret
);
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
let app_state = AppState::init(&config).await.unwrap();
let app = test::init_service(create_app(Data::new(app_state))).await;
(app, provider)
}
#[actix_web::test]
async fn test_oidc_cached_authorization_redirect_cannot_replay_consumed_state() {
let (app, provider) = setup_oidc_test(|_| {}).await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
// Reproduces https://github.com/sqlpage/SQLPage/issues/1341: Chrome's
// private cache can replay a cached 303 without reapplying Set-Cookie
// headers, causing the browser to retry an already-consumed OIDC state.
let initial_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
let initial_auth_url = Url::parse(
initial_response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
)
.unwrap();
let cached_authorization_url =
permits_storage_after_proxy_adds_freshness(initial_response.headers())
.then_some(initial_auth_url.clone());
let state = get_query_param(&initial_auth_url, "state");
let nonce = get_query_param(&initial_auth_url, "nonce");
let redirect_uri = get_query_param(&initial_auth_url, "redirect_uri");
let callback_path = Url::parse(&redirect_uri).unwrap().path().to_owned();
provider.store_auth_code("first-code".to_string(), nonce.clone());
let callback_uri = format!("{callback_path}?code=first-code&state={state}");
let callback_response =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
if let Some(stale_authorization_url) = cached_authorization_url {
// A fresh Entra code is returned for the authorization URL cached by
// Chrome, but its state is the state that the successful callback just
// consumed. Before this fix, SQLPage restarted OIDC here, creating the
// observed redirect loop.
let stale_state = get_query_param(&stale_authorization_url, "state");
provider.store_auth_code("stale-code".to_string(), nonce);
let stale_callback_uri = format!("{callback_path}?code=stale-code&state={stale_state}");
let stale_callback_response = request_with_cookies!(
app,
test::TestRequest::get().uri(&stale_callback_uri),
cookies
);
panic!(
"a cacheable authorization redirect replays a consumed state; stale callback redirected to {}",
stale_callback_response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
);
}
// With `no-store`, Chrome re-requests `/` after login and sends its new
// auth cookie instead of following the original authorization redirect.
let final_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(final_response.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_oidc_happy_path() {
let (app, provider) = setup_oidc_test(|_| {}).await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store",
"the authorization redirect contains a one-time state and must not be cached"
);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
let state = get_query_param(&auth_url, "state");
let nonce = get_query_param(&auth_url, "nonce");
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
provider.store_auth_code("test_auth_code".to_string(), nonce);
let callback_uri = format!(
"{}?code=test_auth_code&state={}",
Url::parse(&redirect_uri).unwrap().path(),
state
);
let callback_resp =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
callback_resp.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store",
"the post-login redirect must not re-enter a cached authorization redirect"
);
let final_resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(final_resp.status(), StatusCode::OK);
}
async fn assert_oidc_login_fails(
provider_mutator: impl FnOnce(&mut ProviderState),
state_override: Option<String>,
) {
let (app, provider) = setup_oidc_test(provider_mutator).await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
let state = get_query_param(&auth_url, "state");
let nonce = get_query_param(&auth_url, "nonce");
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
provider.store_auth_code("test_auth_code".to_string(), nonce);
let callback_state = state_override.unwrap_or(state);
let callback_uri = format!(
"{}?code=test_auth_code&state={}",
Url::parse(&redirect_uri).unwrap().path(),
callback_state
);
let callback_resp =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
let location = callback_resp
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap();
assert!(
location.starts_with(&provider.issuer_url),
"Expected redirect to OIDC provider, but got {location}"
);
let auth_cookie_present = cookies.iter().any(|c| c.name() == "sqlpage_auth");
assert!(
!auth_cookie_present,
"Authentication cookie should not be set on failure"
);
}
async fn assert_oidc_callback_fails_with_bad_jwt(
mutate_jwt_claims: impl FnMut(&mut serde_json::Value) + Send + Sync + 'static,
) {
let mutate_jwt_claims = Mutex::new(mutate_jwt_claims);
assert_oidc_login_fails(
|state| {
state.jwt_customizer = Some(Box::new(move |mut claims, secret| {
mutate_jwt_claims.lock().unwrap()(&mut claims);
make_jwt(&claims, secret)
}));
},
None,
)
.await;
}
#[actix_web::test]
async fn test_oidc_csrf_state_mismatch_is_rejected() {
assert_oidc_login_fails(|_| {}, Some("wrong_state".to_string())).await;
}
#[actix_web::test]
async fn test_oidc_nonce_mismatch_is_rejected() {
assert_oidc_callback_fails_with_bad_jwt(|claims| {
claims["nonce"] = json!("wrong_nonce");
})
.await;
}
#[actix_web::test]
async fn test_oidc_bad_signature_is_rejected() {
assert_oidc_login_fails(
|state| {
state.jwt_customizer = Some(Box::new(|claims, _| make_jwt(&claims, "wrong_secret")));
},
None,
)
.await;
}
#[actix_web::test]
async fn test_oidc_wrong_audience_is_rejected() {
assert_oidc_callback_fails_with_bad_jwt(|claims| {
claims["aud"] = json!("wrong_client");
})
.await;
}
#[actix_web::test]
async fn test_oidc_wrong_issuer_is_rejected() {
assert_oidc_callback_fails_with_bad_jwt(|claims| {
claims["iss"] = json!("https://wrong-issuer.com");
})
.await;
}
#[actix_web::test]
async fn test_oidc_expired_token_is_rejected() {
assert_oidc_callback_fails_with_bad_jwt(|claims| {
let current_exp = claims["exp"].as_i64().unwrap();
claims["exp"] = json!(current_exp - 7200);
})
.await;
}
async fn setup_oidc_test_with_prefix(
provider_mutator: impl FnOnce(&mut ProviderState),
site_prefix: &str,
) -> (
impl actix_web::dev::Service<
actix_http::Request,
Response = actix_web::dev::ServiceResponse<impl actix_web::body::MessageBody>,
Error = actix_web::Error,
>,
FakeOidcProvider,
) {
use sqlpage::{
AppState,
app_config::{AppConfig, test_database_url},
};
crate::common::init_log();
let provider = FakeOidcProvider::new().await;
provider.with_state_mut(provider_mutator);
let db_url = test_database_url();
let config_json = format!(
r#"{{
"database_url": "{db_url}",
"oidc_issuer_url": "{}",
"oidc_client_id": "{}",
"oidc_client_secret": "{}",
"oidc_protected_paths": ["/"],
"site_prefix": "{site_prefix}"
}}"#,
provider.issuer_url, provider.client_id, provider.client_secret
);
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
let app_state = AppState::init(&config).await.unwrap();
let app = test::init_service(create_app(Data::new(app_state))).await;
(app, provider)
}
#[actix_web::test]
async fn test_oidc_with_site_prefix() {
let (app, _provider) = setup_oidc_test_with_prefix(|_| {}, "/my-app/").await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
// Access the app with the prefix
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/my-app/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
// Check if the redirect_uri parameter in the auth URL contains the site prefix
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
assert!(
redirect_uri.contains("/my-app/sqlpage/oidc_callback"),
"Redirect URI should contain site prefix. Got: {}",
redirect_uri
);
}
#[actix_web::test]
async fn test_oidc_logout_uses_correct_scheme() {
use sqlpage::{
AppState,
app_config::{AppConfig, test_database_url},
};
crate::common::init_log();
let provider = FakeOidcProvider::new().await;
let db_url = test_database_url();
let config_json = format!(
r#"{{
"database_url": "{db_url}",
"oidc_issuer_url": "{}",
"oidc_client_id": "{}",
"oidc_client_secret": "{}",
"https_domain": "example.com"
}}"#,
provider.issuer_url, provider.client_id, provider.client_secret
);
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
let app_state = AppState::init(&config).await.unwrap();
let logout_path = app_state
.oidc_state
.as_ref()
.unwrap()
.config
.create_logout_url("/logged_out", None);
let app = test::init_service(create_app(Data::new(app_state))).await;
// make sure the logout path includes the configured domain
assert!(logout_path.starts_with("/sqlpage/oidc_logout"));
let req = test::TestRequest::get().uri(&logout_path).to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
let location_url = Url::parse(location).unwrap();
assert_eq!(location_url.path(), "/logout");
let params: HashMap<String, String> = location_url.query_pairs().into_owned().collect();
let post_logout = params.get("post_logout_redirect_uri").unwrap();
assert_eq!(post_logout, "https://example.com/logged_out");
}
/// An OIDC provider metadata refresh must not block authenticated requests.
/// The refresh should happen in the background while existing requests are
/// served using the current (possibly stale) OIDC client.
#[actix_web::test]
async fn test_slow_discovery_does_not_block_authenticated_requests() {
let (app, provider) = setup_oidc_test(|_| {}).await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
// Complete a full login to get auth cookies
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
let state_param = get_query_param(&auth_url, "state");
let nonce = get_query_param(&auth_url, "nonce");
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
provider.store_auth_code("test_auth_code".to_string(), nonce);
let callback_uri = format!(
"{}?code=test_auth_code&state={}",
Url::parse(&redirect_uri).unwrap().path(),
state_param
);
let callback_resp =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
// Advance time so the OIDC snapshot appears stale.
// The next request triggers a background refresh.
let count_before = provider.discovery_count();
tokio::time::pause();
tokio::time::advance(Duration::from_secs(3601)).await;
// Resume real time so the DB pool and background refresh work normally.
tokio::time::resume();
// An authenticated request must succeed immediately, even though
// it triggers a background refresh.
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::OK);
// Let the background refresh task complete.
tokio::task::yield_now().await;
assert!(
provider.discovery_count() > count_before,
"OIDC provider metadata was not refreshed"
);
}
/// A slow OIDC token endpoint must not freeze the server.
/// The body-read timeout fires and the request completes with a redirect.
#[actix_web::test]
async fn test_slow_token_endpoint_does_not_freeze_server() {
let (app, provider) = setup_oidc_test(|_| {}).await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
let state_param = get_query_param(&auth_url, "state");
let nonce = get_query_param(&auth_url, "nonce");
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
provider.store_auth_code("test_auth_code".to_string(), nonce);
provider.set_token_endpoint_delay(Duration::from_secs(999));
let callback_uri = format!(
"{}?code=test_auth_code&state={}",
Url::parse(&redirect_uri).unwrap().path(),
state_param
);
let handle = tokio::task::spawn_local(async move {
let mut req = test::TestRequest::get().uri(&callback_uri);
for cookie in cookies.iter() {
req = req.cookie(cookie.clone());
}
test::call_service(&app, req.to_request()).await
});
// Let the TCP round-trip complete so awc reads HTTP headers,
// then advance past the body-read timeout.
tokio::task::yield_now().await;
tokio::time::pause();
tokio::time::advance(Duration::from_secs(60)).await;
let resp = tokio::time::timeout(Duration::from_secs(1), handle)
.await
.expect("OIDC callback hung on a slow token endpoint")
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
}
/// A logout URL is bound to the session it was issued for. A logout URL
/// generated for one session must NOT clear a different browser's auth cookie
/// (forced-logout CSRF), while the legitimate logout of the issuing session
/// must keep working.
#[actix_web::test]
async fn test_oidc_logout_is_session_bound() {
use sqlpage::{
AppState,
app_config::{AppConfig, test_database_url},
};
crate::common::init_log();
let provider = FakeOidcProvider::new().await;
let db_url = test_database_url();
let config_json = format!(
r#"{{
"database_url": "{db_url}",
"oidc_issuer_url": "{}",
"oidc_client_id": "{}",
"oidc_client_secret": "{}",
"oidc_protected_paths": ["/"],
"host": "localhost:1"
}}"#,
provider.issuer_url, provider.client_id, provider.client_secret
);
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
let app_state = AppState::init(&config).await.unwrap();
let oidc_state = app_state.oidc_state.clone().unwrap();
let app = test::init_service(create_app(Data::new(app_state))).await;
// Complete a full login as the victim.
let mut cookies: Vec<Cookie<'static>> = Vec::new();
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
let state = get_query_param(&auth_url, "state");
let nonce = get_query_param(&auth_url, "nonce");
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
provider.store_auth_code("test_auth_code".to_string(), nonce);
let callback_uri = format!(
"{}?code=test_auth_code&state={}",
Url::parse(&redirect_uri).unwrap().path(),
state
);
let callback_resp =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
let victim_auth = cookies
.iter()
.find(|c| c.name() == "sqlpage_auth")
.expect("victim should be authenticated")
.value()
.to_string();
assert!(!victim_auth.is_empty());
// A logout URL bound to a DIFFERENT session must not clear the victim's
// cookie when the victim's browser follows it.
let foreign_logout_url = oidc_state
.config
.create_logout_url("/", Some("some-other-sessions-token"));
let mut req = test::TestRequest::get().uri(&foreign_logout_url);
for cookie in &cookies {
req = req.cookie(cookie.clone());
}
let resp = test::call_service(&app, req.to_request()).await;
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a logout URL bound to another session must be rejected"
);
let cleared = extract_set_cookies(resp.headers())
.iter()
.any(|c| c.name() == "sqlpage_auth" && c.value().is_empty());
assert!(
!cleared,
"the victim's auth cookie must not be cleared by a foreign logout URL"
);
// The victim's own logout URL (bound to the victim's session) must work.
let own_logout_url = oidc_state.config.create_logout_url("/", Some(&victim_auth));
let mut req = test::TestRequest::get().uri(&own_logout_url);
for cookie in &cookies {
req = req.cookie(cookie.clone());
}
let resp = test::call_service(&app, req.to_request()).await;
assert_eq!(
resp.status(),
StatusCode::SEE_OTHER,
"the user's own logout must keep working"
);
let cleared = extract_set_cookies(resp.headers())
.iter()
.any(|c| c.name() == "sqlpage_auth" && c.value().is_empty());
assert!(
cleared,
"the user's own logout must clear their auth cookie"
);
}
+250
View File
@@ -0,0 +1,250 @@
use actix_web::{http::StatusCode, test};
use serde_json::json;
use sqlpage::webserver::http::main_handler;
use crate::common::get_request_to;
#[actix_web::test]
async fn test_request_body() -> actix_web::Result<()> {
let req = get_request_to("/tests/requests/request_body_test.sql")
.await?
.insert_header(("content-type", "text/plain"))
.set_payload("Hello, world!")
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("Hello, world!"),
"{body_str}\nexpected to contain: Hello, world!"
);
// Test with form data - should return NULL
let req = get_request_to("/tests/requests/request_body_test.sql")
.await?
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("key=value")
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("NULL"),
"{body_str}\nexpected NULL for form data"
);
Ok(())
}
#[actix_web::test]
async fn test_request_body_base64() -> actix_web::Result<()> {
let binary_data = (0u8..=255u8).collect::<Vec<_>>();
let expected_base64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &binary_data);
let req = get_request_to("/tests/requests/request_body_base64_test.sql")
.await?
.insert_header(("content-type", "application/octet-stream"))
.set_payload(binary_data)
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains(&expected_base64),
"{body_str}\nexpected to contain base64: {expected_base64}"
);
// Test with form data - should return NULL
let req = get_request_to("/tests/requests/request_body_base64_test.sql")
.await?
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("key=value")
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
body_str.contains("NULL"),
"{body_str}\nexpected NULL for form data"
);
Ok(())
}
#[actix_web::test]
async fn test_download_data_url() -> actix_web::Result<()> {
let req = get_request_to("/tests/requests/request_download_test.sql")
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get("content-type").unwrap();
assert_eq!(ct, "text/plain");
let content_disposition = resp.headers().get("content-disposition").unwrap();
assert_eq!(
content_disposition,
"attachment; filename=\"my text file.txt\""
);
let body = test::read_body(resp).await;
assert_eq!(&body, &b"Hello download!"[..]);
Ok(())
}
#[actix_web::test]
async fn test_large_form_field_roundtrip() -> actix_web::Result<()> {
let long_string = "a".repeat(123454);
let req = get_request_to("/tests/components/display_form_field.sql")
.await?
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload(["x=", &long_string].concat()) // total size is 123454 + 2 = 123456 bytes
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(
!body_str.contains("error"),
"{body_str}\nshouldn't have errors"
);
assert!(
body_str.contains(&long_string),
"{body_str}\nexpected to contain long string submitted"
);
Ok(())
}
#[actix_web::test]
async fn test_variables_function() -> actix_web::Result<()> {
let url = "/tests/requests/variables.sql?common=get_value&get_only=get_val";
let req_body = "common=post_value&post_only=post_val";
let req = get_request_to(url)
.await?
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.insert_header(("accept", "application/json"))
.set_payload(req_body)
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let body_json: serde_json::Value = test::read_body_json(resp).await;
let expected = [
[
(
"all_vars",
json!({"get_only": "get_val", "common": "get_value", "post_only": "post_val", "common": "post_value"}),
),
(
"get_vars",
json!({"get_only": "get_val", "common": "get_value"}),
),
(
"post_vars",
json!({"post_only": "post_val", "common": "post_value"}),
),
("set_vars", json!({})),
],
[
(
"all_vars",
json!({"get_only": "get_val", "common": "set_common_value", "post_only": "post_val", "my_set_var": "set_value"}),
),
(
"get_vars",
json!({"get_only": "get_val", "common": "get_value"}),
),
(
"post_vars",
json!({"post_only": "post_val", "common": "post_value"}),
),
(
"set_vars",
json!({"common": "set_common_value", "my_set_var": "set_value"}),
),
],
];
let actual_array = body_json.as_array().expect("response is nota json array");
for (i, expected_step) in expected.into_iter().enumerate() {
let actual = &actual_array[i];
for (key, expected_value) in expected_step {
let actual_decoded: serde_json::Value =
serde_json::from_str(actual[key].as_str().unwrap()).unwrap();
assert_eq!(
actual_decoded, expected_value,
"step {i}: {key} mismatch: {actual_decoded:#} != {expected_value:#}"
)
}
}
Ok(())
}
#[actix_web::test]
async fn test_invalid_utf8_multipart_text_field_returns_bad_request() -> actix_web::Result<()> {
let req = get_request_to("/tests/requests/variables.sql")
.await?
.insert_header(("content-type", "multipart/form-data; boundary=1234567890"))
.set_payload(
b"--1234567890\r\n\
Content-Disposition: form-data; name=\"x\"\r\n\
Content-Type: text/plain\r\n\
\r\n\
\xff\r\n\
--1234567890--\r\n"
.as_slice(),
)
.to_srv_request();
let status = match main_handler(req).await {
Ok(resp) => resp.status(),
Err(err) => err.as_response_error().status_code(),
};
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"assertion error, expected 400 bad request on invalid utf8 payload, got {}",
status
);
Ok(())
}
#[actix_web::test]
async fn test_missing_multipart_content_disposition_returns_bad_request() -> actix_web::Result<()> {
let req = get_request_to("/tests/requests/variables.sql")
.await?
.insert_header(("content-type", "multipart/form-data; boundary=1234567890"))
.set_payload(
b"--1234567890\r\n\
Content-Type: text/plain\r\n\
\r\n\
hello\r\n\
--1234567890--\r\n"
.as_slice(),
)
.to_srv_request();
let status = match main_handler(req).await {
Ok(resp) => resp.status(),
Err(err) => err.as_response_error().status_code(),
};
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"expected 400 bad request on malformed multipart payload, got {}",
status
);
Ok(())
}
mod webhook_hmac;
@@ -0,0 +1,2 @@
select 'shell-empty' as component,
coalesce(sqlpage.request_body_base64(), 'NULL') as html;
+2
View File
@@ -0,0 +1,2 @@
select 'shell-empty' as component,
coalesce(sqlpage.request_body(), 'NULL') as html;
+6
View File
@@ -0,0 +1,6 @@
select 'download' as component,
'data:text/plain;base64,SGVsbG8gZG93bmxvYWQh' as data_url,
'my text file.txt' as filename;
+14
View File
@@ -0,0 +1,14 @@
select
sqlpage.variables() as all_vars,
sqlpage.variables('get') as get_vars,
sqlpage.variables('post') as post_vars,
sqlpage.variables('set') as set_vars;
set my_set_var = 'set_value';
set common = 'set_common_value';
select
sqlpage.variables() as all_vars,
sqlpage.variables('get') as get_vars,
sqlpage.variables('post') as post_vars,
sqlpage.variables('set') as set_vars;
+99
View File
@@ -0,0 +1,99 @@
use actix_web::{http::StatusCode, test};
use sqlpage::webserver::http::main_handler;
use crate::common::get_request_to;
#[actix_web::test]
async fn test_webhook_hmac_invalid_signature() -> actix_web::Result<()> {
// Set up environment variable for webhook secret
unsafe {
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
}
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
let invalid_signature = "96a5f6f65c85a2d4d1f3a37813ab2c0b44041bdc17691fbb0884e3eb52b7c54b";
let req = get_request_to("/tests/webhook_hmac_validation.sql")
.await?
.insert_header(("content-type", "application/json"))
.insert_header(("X-Webhook-Signature", invalid_signature))
.set_payload(webhook_body)
.to_srv_request();
let resp = main_handler(req).await?;
// Should redirect to error page when signature is invalid
assert!(
resp.status() == StatusCode::FOUND || resp.status() == StatusCode::SEE_OTHER,
"Expected redirect (302 or 303) for invalid signature, got: {}",
resp.status()
);
let location = resp
.headers()
.get("location")
.expect("Should have Location header")
.to_str()
.unwrap();
assert_eq!(location, "/error.sql?err=bad_webhook_signature");
Ok(())
}
#[actix_web::test]
async fn test_webhook_hmac_valid_signature() -> actix_web::Result<()> {
// Set up environment variable for webhook secret
unsafe {
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
}
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
let valid_signature = "260b3b5ead84843645588af82d5d2c3fe24c598a950d36c45438c3a5f5bb941c";
let req = get_request_to("/tests/webhook_hmac_validation.sql")
.await?
.insert_header(("content-type", "application/json"))
.insert_header(("X-Webhook-Signature", valid_signature))
.set_payload(webhook_body)
.to_srv_request();
let resp = main_handler(req).await?;
// Should return success when signature is valid
assert_eq!(resp.status(), StatusCode::OK, "200 resp for signed req");
assert!(!resp.headers().contains_key("location"), "no redirect");
assert_eq!(
test::read_body_json::<serde_json::Value, _>(resp).await,
serde_json::json! ({"msg": "Webhook signature is valid !"})
);
Ok(())
}
#[actix_web::test]
async fn test_webhook_hmac_missing_signature() -> actix_web::Result<()> {
// Set up environment variable for webhook secret
unsafe {
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
}
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
// Don't include the X-Webhook-Signature header
let req = get_request_to("/tests/webhook_hmac_validation.sql")
.await?
.insert_header(("content-type", "application/json"))
.set_payload(webhook_body)
.to_srv_request();
let resp = main_handler(req).await?;
let location = resp
.headers()
.get("location")
.expect("Should have Location header")
.to_str()
.unwrap();
assert_eq!(location, "/error.sql?err=bad_webhook_signature");
Ok(())
}
+139
View File
@@ -0,0 +1,139 @@
use actix_web::http::StatusCode;
use sqlpage::webserver::http::main_handler;
use crate::common::{get_request_to, make_app_data_from_config, test_config};
#[actix_web::test]
async fn test_server_timing_disabled_in_production() -> actix_web::Result<()> {
let mut config = test_config();
config.environment = sqlpage::app_config::DevOrProd::Production;
let app_data = make_app_data_from_config(config).await;
let req = crate::common::get_request_to_with_data(
"/tests/sql_test_files/component_rendering/simple.sql",
app_data,
)
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
assert!(
resp.headers().get("Server-Timing").is_none(),
"Server-Timing header should not be present in production mode"
);
Ok(())
}
#[actix_web::test]
async fn test_server_timing_enabled_in_development() -> actix_web::Result<()> {
let mut config = test_config();
config.environment = sqlpage::app_config::DevOrProd::Development;
let app_data = make_app_data_from_config(config).await;
let req = crate::common::get_request_to_with_data(
"/tests/sql_test_files/data/postgres_cast_syntax.sql",
app_data,
)
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let server_timing_header = resp
.headers()
.get("Server-Timing")
.expect("Server-Timing header should be present in development mode");
let header_value = server_timing_header.to_str().unwrap();
assert!(
header_value.contains("sql_file;dur="),
"Should contain sql_file timing: {header_value}"
);
assert!(
header_value.contains("parse_req;dur="),
"Should contain parse_req timing: {header_value}"
);
assert!(
header_value.contains("bind_params;dur="),
"Should contain bind_params timing: {header_value}"
);
assert!(
header_value.contains("db_conn;dur="),
"Should contain db_conn timing: {header_value}"
);
assert!(
header_value.contains("row;dur="),
"Should contain row timing: {header_value}"
);
Ok(())
}
#[actix_web::test]
async fn test_server_timing_format() -> actix_web::Result<()> {
let req = get_request_to("/tests/sql_test_files/data/postgres_cast_syntax.sql")
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(resp.status(), StatusCode::OK);
let server_timing_header = resp.headers().get("Server-Timing").unwrap();
let header_value = server_timing_header.to_str().unwrap();
let parts: Vec<&str> = header_value.split(", ").collect();
assert!(parts.len() >= 5, "Should have at least 5 timing events");
for part in parts {
assert!(
part.contains(";dur="),
"Each part should have name;dur= format: {part}"
);
let dur_parts: Vec<&str> = part.split(";dur=").collect();
assert_eq!(dur_parts.len(), 2, "Should have name and duration: {part}");
let duration: f64 = dur_parts[1]
.parse()
.expect("Duration should be a valid number");
assert!(
duration >= 0.0,
"Duration should be non-negative: {duration}"
);
}
Ok(())
}
#[actix_web::test]
async fn test_server_timing_in_redirect() -> actix_web::Result<()> {
let mut config = test_config();
config.environment = sqlpage::app_config::DevOrProd::Development;
let app_data = make_app_data_from_config(config).await;
let req =
crate::common::get_request_to_with_data("/tests/server_timing/redirect_test.sql", app_data)
.await?
.to_srv_request();
let resp = main_handler(req).await?;
assert_eq!(
resp.status(),
StatusCode::FOUND,
"Response should be a redirect"
);
let server_timing_header = resp
.headers()
.get("Server-Timing")
.expect("Server-Timing header should be present in redirect responses");
let header_value = server_timing_header.to_str().unwrap();
assert!(
!header_value.is_empty(),
"Server-Timing header should not be empty: {header_value}"
);
assert!(
header_value.contains(";dur="),
"Server-Timing header should contain timing events: {header_value}"
);
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
select 'redirect' as component, '/destination.sql' as link;
+18
View File
@@ -0,0 +1,18 @@
The sql files in this folder are all tested automatically. They are organized in
two subdirectories:
## `component_rendering/`
Files that depend on SQLPage's HTML rendering (components, shells, redirects,
etc.). Every file that does not start with `error_` must render a page that
contains the text "It works !" and no occurrence of the word "error" (case
insensitive). `error_` files should return a page containing the word "error"
and the rest of the file name. Files may include `nosqlite`, `nomssql`,
`nopostgres` or `nomysql` in their name to skip incompatible backends.
## `data/`
Files that only validate data-processing functions should live here. They must
return rows with an `actual` column plus either `expected` (exact match) or
`expected_contains` (substring match). Tests in this directory are fetched as
JSON and validated row by row.
@@ -0,0 +1,6 @@
-- this test is skipped on MSSQL and postgres because their json_object function has a different syntax
select 'columns' as component;
select
JSON_OBJECT('description', 'It works !') as item,
JSON_OBJECT('description', 'It works !') as item
;
@@ -0,0 +1,129 @@
select 'dynamic' as component,'{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"dynamic","properties":
{"component":"text", "contents": "It works ! (but it shouldn''t)"}
}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
' as properties;
@@ -0,0 +1 @@
select 'yes' as "It works !";
@@ -0,0 +1,2 @@
-- "<!DOCTYPE html><body>It works !</body>" in base64
select 'download' as component, 'data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGJvZHk+SXQgd29ya3MgITwvYm9keT4K' as data_url;
@@ -0,0 +1,2 @@
-- "<!DOCTYPE html><body>It works !</body>" percent encoded
select 'download' as component, 'data:text/html,%3C!DOCTYPE%20html%3E%3Cbody%3EIt%20works%20!%3C%2Fbody%3E' as data_url;
@@ -0,0 +1,8 @@
-- Checks that we can have a page with a single dynamic component containing multiple children
select 'dynamic' as component,
'[
{"component":"dynamic", "properties": [
{"component":"text"},
{"contents":"It works !", "bold":true}
]}
]' as properties;
@@ -0,0 +1,6 @@
-- Checks that we can have a page with a single dynamic component containing multiple children
select 'dynamic' as component,
'{"component":"text"}' as properties,
'{"contents":"Hello, ", "bold":true}' as properties,
'{"component":"text"}' as properties,
'{"contents":"It works !", "bold":true}' as properties;
@@ -0,0 +1,2 @@
select 'dynamic' as component,
sqlpage.run_sql('tests/sql_test_files/component_rendering/dynamic_shell.sql') as properties;
@@ -0,0 +1,7 @@
-- Checks that we can have a page with a single dynamic component containing multiple children
select 'dynamic' as component,
'[
{"component":"shell", "title":"It works !"},
{"component":"text"},
{"contents":"Yes it does !", "bold":true}
]' as properties;
@@ -0,0 +1,8 @@
-- Checks that we can have a page with a single dynamic component containing multiple children
select 'dynamic' as component,
'[
{"component":"text"},
{"contents":"Hello, ", "bold":true},
{"component":"text"},
{"contents":"It works !", "bold":true}
]' as properties;
@@ -0,0 +1,5 @@
select 'text' as component;
select 'hello world' as contents;
select 'cookie' as component,
'username' as name,
'John Doe' as value;
@@ -0,0 +1,2 @@
-- https://github.com/sqlpage/SQLPage/issues/788
copy this_table_does_not_exist (csv) from 'recon_csv_file_input' DELIMITER '*' CSV;
@@ -0,0 +1,2 @@
select 'dynamic' as component,
'{ "this object": "has a forbidden comma" , }' as properties; -- this is invalid JSON, and should cause an error
@@ -0,0 +1,4 @@
select 'shell' as component,
lower(sqlpage.url_encode(lower('HELLO'))) as title;
-- this is invalid, because the sqlpage pseudo-function is sandwiched between two native SQL functions.
-- It can't be executed neither before nor after the query is executed.
@@ -0,0 +1,2 @@
select 'shell' as component, 'hello' as title;
select 'shell' as component, 'world' as title;
@@ -0,0 +1 @@
select 'dynamic' as component; -- missing "properties" column
@@ -0,0 +1,4 @@
select 'debug' as component,
sqlpage.run_sql(
'tests/sql_test_files/component_rendering/error_too_many_nested_inclusions.sql'
) as contents;
@@ -0,0 +1,4 @@
select sqlpage.fetch('{
"url": "https://example.com",
"invalid_field": "should fail"
}')
@@ -0,0 +1,6 @@
select 'log' as component,
'Hello, World!' as message,
'info' as level;
select 'text' as component,
'It works !' as contents;
@@ -0,0 +1,6 @@
-- https://github.com/sqlpage/SQLPage/discussions/600
select 'table' as component, 'Link' AS markdown; -- uppercase Link
select '[It works !](https://example.com)
[comment]: <> (This is a comment. If this is visible, there is an error in markdown rendering)' as link; -- lowercase "link".
-- If the markdown is not rendered, the page will contain the string "error" and trigger a test failure.
@@ -0,0 +1,2 @@
select 'dynamic' as component,
sqlpage.run_sql('tests/sql_test_files/component_rendering/simple.sql') as properties;
@@ -0,0 +1,14 @@
select
'dynamic' as component,
sqlpage.run_sql (
'tests/components/display_text.sql',
CONCAT ('{"html":"', html, '"}') -- UNSAFE. Don't do that with untrusted data. We do it here because we can't use json_object (the syntax is not the same in all supported databases)
) as properties
from
(
select
'It ' as html
union all
select
'works !'
) t1;
@@ -0,0 +1,4 @@
set html = 'It ';
select 'dynamic' as component, sqlpage.run_sql('tests/components/display_text.sql') as properties;
set html = 'works !';
select 'dynamic' as component, sqlpage.run_sql('tests/components/display_text.sql') as properties;
@@ -0,0 +1,3 @@
select 'shell' as component,
'/' as search_target,
'It works !' as search_value;
@@ -0,0 +1,2 @@
select 'shell' as component, 'Hello world !' AS title;
select 'text' as component, 'It works !' AS contents;
@@ -0,0 +1,4 @@
-- Doesnt work on mssql because it does not support "create temporary table"
create temporary table temp_t(x text);
insert into temp_t(x) values ('It works !');
select 'dynamic' as component, sqlpage.run_sql('tests/sql_test_files/select_temp_t.sql') AS properties;
@@ -0,0 +1,2 @@
select 'text' as component,
'### It works !' AS contents_md;
@@ -0,0 +1,2 @@
select 'text' as component,
'<span>It works !</span>' AS unsafe_contents_md;
@@ -0,0 +1,2 @@
select 'shell-empty' as component;
select 'text' as component, '<!DOCTYPE html><html><head><title>My page</title></head><body><h1>It works !</h1></body></html>' as html;
@@ -0,0 +1 @@
select 'test' as expected, sqlpage.basic_auth_password() as actual;
@@ -0,0 +1 @@
select 'test' as expected, sqlpage.basic_auth_username() as actual;
@@ -0,0 +1,10 @@
-- https://github.com/sqlpage/SQLPage/issues/818
set success = 'It works !';
set failure = 'You should never see this';
select 'It works !' as expected,
case $success
when $success then $success
when $failure then $failure
end as actual;
+2
View File
@@ -0,0 +1,2 @@
select NULL as expected,
sqlpage.client_ip() as actual;
@@ -0,0 +1 @@
select 'https://example.com' as expected, sqlpage.link(coalesce($i_do_not_exist, 'https://example.com')) as actual;
@@ -0,0 +1,3 @@
select '%2F1' as expected, sqlpage.url_encode('/' || $x) as actual;
select '%2F1' as expected, sqlpage.url_encode(CONCAT('/', $x)) as actual;
select NULL as expected, sqlpage.url_encode(CONCAT('/', $thisisnull)) as actual;
+1
View File
@@ -0,0 +1 @@
select '123' as expected, sqlpage.cookie('test_cook') as actual;
@@ -0,0 +1,6 @@
drop table if exists my_tmp_store;
create table my_tmp_store(x varchar(100));
insert into my_tmp_store(x) values ('It works !');
select 'It works !' as expected, x as actual from my_tmp_store;
+2
View File
@@ -0,0 +1,2 @@
set result = CAST(0.47 AS DECIMAL(3,2));
select '0.47' as expected, $result as actual;
@@ -0,0 +1,6 @@
drop table if exists files_to_read;
create table files_to_read(filepath varchar(100));
insert into files_to_read(filepath) values ('tests/it_works.txt');
select 'It works !' as expected,
sqlpage.read_file_as_text(filepath) as actual
from files_to_read;
@@ -0,0 +1,2 @@
select NULL as expected,
sqlpage.environment_variable('I_DO_NOT_EXIST') as actual;
@@ -0,0 +1,6 @@
set res = sqlpage.fetch('{
"url": "http://localhost:' || $echo_port || '/hello_world",
"response_encoding": "base64"
}');
select 'R0VUIC9oZWxsb193b3Js' as expected_contains,
$res as actual;
+6
View File
@@ -0,0 +1,6 @@
set res = sqlpage.fetch('{
"url": "http://localhost:' || $echo_port || '/hello_world",
"response_encoding": "hex"
}');
select '474554202f68656c6c6f5f776f726c64' as expected_contains,
$res as actual;
@@ -0,0 +1,10 @@
set url = 'http://localhost:' || $echo_port || '/post';
set res = sqlpage.fetch(json_object(
'method', 'POST',
'url', $url,
'headers', json_object('x-custom', '1'),
'body', json_array('hello', 'world')
));
set expected = 'POST /post|accept-encoding: br, gzip, deflate, zstd|content-length: 17|content-type: application/json|host: localhost:' || $echo_port || '|user-agent: sqlpage|x-custom: 1|["hello","world"]';
select $expected as expected,
$res as actual;
+1
View File
@@ -0,0 +1 @@
select null as expected, sqlpage.fetch(null) as actual;
+6
View File
@@ -0,0 +1,6 @@
set url = 'http://localhost:' || $echo_port || '/post';
set fetch_request = '{"method": "POST", "url": "' || $url || '", "headers": {"x-custom": "1"}, "body": {"hello": "world"}}';
set res = sqlpage.fetch($fetch_request);
set expected = 'POST /post|accept-encoding: br, gzip, deflate, zstd|content-length: 18|content-type: application/json|host: localhost:' || $echo_port || '|user-agent: sqlpage|x-custom: 1|{"hello": "world"}';
select $expected as expected,
$res as actual;
@@ -0,0 +1,4 @@
set url = 'http://localhost:' || $echo_port || '/hello_world';
set res = sqlpage.fetch($url);
select 'GET /hello_world' as expected_contains,
$res as actual;
@@ -0,0 +1,3 @@
set res = sqlpage.fetch_with_meta('http://not-a-real-url');
select '"error":"Request failed' as expected_contains, $res as actual;
@@ -0,0 +1 @@
select null as expected, sqlpage.fetch_with_meta(null) as actual;
@@ -0,0 +1,14 @@
set url = 'http://localhost:' || $echo_port || '/hello_world';
set fetch_req = '{
"method": "PUT",
"url": "' || $url || '",
"headers": {
"user-agent": "myself"
}
}';
set res = sqlpage.fetch_with_meta($fetch_req);
select '"status":200' as expected_contains,
'"headers":{' as expected_contains,
'"body":"' as expected_contains,
$res as actual;
@@ -0,0 +1,2 @@
select '$argon2id$' as expected_contains,
coalesce(sqlpage.hash_password($x), 'NULL') as actual;
@@ -0,0 +1 @@
select NULL as expected, sqlpage.hash_password(null) as actual;
+1
View File
@@ -0,0 +1 @@
select 'test_cook=123' as expected, sqlpage.header('cookie') as actual;
+1
View File
@@ -0,0 +1 @@
select '"cookie":"test_cook=123"' as expected_contains, sqlpage.headers() as actual;
@@ -0,0 +1,2 @@
select '97yD9DBThCSxMpjmqm+xQ+9NWaFJRhdZl0edvC0aPNg=' as expected,
sqlpage.hmac('The quick brown fox jumps over the lazy dog', 'key', 'sha256-base64') as actual;
@@ -0,0 +1,2 @@
select sqlpage.hmac('test data', 'test key', 'sha256') as expected,
sqlpage.hmac('test data', 'test key') as actual;
@@ -0,0 +1,2 @@
select 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8' as expected,
sqlpage.hmac('The quick brown fox jumps over the lazy dog', 'key', 'sha256') as actual;

Some files were not shown because too many files have changed in this diff Show More