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
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:
@@ -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}");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
select 'html' as component, $html as html;
|
||||
@@ -0,0 +1 @@
|
||||
select 'text' as component, 'This is a hidden file that should not be accessible' as contents;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
select 'text' as component, 'It works !' AS contents;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
select 'csv' as component;
|
||||
select 0 as id, 'before the error' as msg;
|
||||
select * from definitely_missing_table_xyz;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
select 'json' as component;
|
||||
select 'It works!' as message;
|
||||
select 'cool' as cool;
|
||||
@@ -0,0 +1,3 @@
|
||||
select 'json' as component;
|
||||
select 'before the error' as message;
|
||||
select * from definitely_missing_table_xyz;
|
||||
@@ -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(""", "\"");
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
select 'text' as component, 'before the error' as contents;
|
||||
select * from definitely_missing_table_xyz;
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
@@ -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");
|
||||
});
|
||||
Generated
+97
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// },
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
select 'dynamic' as component, $properties as properties;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
select sqlpage.exec($exec_program, $exec_arg1, $exec_arg2, 'It', $thisisnull, 'works', '!') as actual;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
It works !
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
select 'shell-empty' as component,
|
||||
coalesce(sqlpage.request_body(), 'NULL') as html;
|
||||
@@ -0,0 +1,6 @@
|
||||
select 'download' as component,
|
||||
'data:text/plain;base64,SGVsbG8gZG93bmxvYWQh' as data_url,
|
||||
'my text file.txt' as filename;
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
select 'redirect' as component, '/destination.sql' as link;
|
||||
|
||||
@@ -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.
|
||||
+6
@@ -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;
|
||||
+5
@@ -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;
|
||||
+1
@@ -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;
|
||||
+4
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
select null as expected, sqlpage.fetch(null) as actual;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
select 'test_cook=123' as expected, sqlpage.header('cookie') as actual;
|
||||
@@ -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
Reference in New Issue
Block a user