Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ jobs:
container: mssql
db_url: "mssql://root:Password123!@127.0.0.1/sqlpage"
- database: oracle
test_args: --test-threads=2
container: oracle
db_url: "Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!"
- database: duckdb
Expand All @@ -122,17 +123,17 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install -y unixodbc unzip
sudo scripts/install-duckdb-odbc.sh
- name: Cache Oracle Instant Client archives
if: matrix.database == 'oracle'
uses: actions/cache@v6
with:
path: ${{ runner.temp }}/sqlpage-oracle-instantclient-archives
key: oracle-instantclient-linux-x64-21.21.0.0.0-v1
- name: Install Oracle ODBC driver
if: matrix.database == 'oracle'
run: |
sudo apt-get install -y alien libaio1t64 libodbcinst2 unixodbc
sudo rpm --import https://yum.oracle.com/RPM-GPG-KEY-oracle-ol8
wget https://yum.oracle.com/repo/OracleLinux/OL8/oracle/instantclient21/x86_64/getPackage/oracle-instantclient-{basic,odbc}-21.21.0.0.0-1.el8.x86_64.rpm
sudo alien -i oracle-instantclient-basic-21.21.0.0.0-1.el8.x86_64.rpm
sudo alien -i oracle-instantclient-odbc-21.21.0.0.0-1.el8.x86_64.rpm
sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/libaio.so.1
sudo /usr/lib/oracle/21/client64/bin/odbc_update_ini.sh / /usr/lib/oracle/21/client64/lib
echo "LD_LIBRARY_PATH=/usr/lib/oracle/21/client64/lib:$LD_LIBRARY_PATH" >> "$GITHUB_ENV"
sudo apt-get install -y libaio1t64 libodbcinst2 unixodbc unzip
scripts/install-oracle-odbc.sh
- name: Start database container
if: matrix.container != ''
run: docker compose up --wait ${{ matrix.container }}
Expand All @@ -141,7 +142,7 @@ jobs:
run: docker compose logs ${{ matrix.container }}
- name: Run tests against ${{ matrix.database }}
timeout-minutes: 5
run: scripts/run-test-binaries.sh
run: scripts/run-test-binaries.sh ${{ matrix.test_args }}
env:
DATABASE_URL: ${{ matrix.db_url }}
MALLOC_CHECK_: 3
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export DATABASE_URL=mssql://root:Password123!@localhost/sqlpage
cargo test
```

Integration tests that create application state should use `common::make_app_state_from_config` or
`common::make_app_data_from_config` and `#[actix_web::rt::test(system = "crate::common::TestSystem")]`.
The shared test runtime clears prepared statements and closes database pools before shutdown,
including after a panic. This prevents Oracle's ODBC driver from hanging at process exit.
When testing Oracle locally, use `cargo test -- --test-threads=2` to avoid overwhelming the listener.

### End-to-End Tests

We use Playwright for end-to-end testing of dynamic frontend features.
Expand Down
3 changes: 2 additions & 1 deletion scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ These scripts pass configuration between build stages through temporary files in
## CI runs these

- **`package-test-binaries.sh`**: Compiles the Rust test harnesses once and tars them, so the database matrix runs the same executables instead of recompiling SQLPage six times.
- **`run-test-binaries.sh`**: Runs SQLPage compiled executables against whatever `DATABASE_URL` names.
- **`install-oracle-odbc.sh`**: Verifies and extracts the pinned Oracle Instant Client ZIPs, registers a private ODBC driver, and writes its environment to `GITHUB_ENV`. CI caches the archives under `RUNNER_TEMP`. Requires curl, unzip, unixODBC, and libaio.
- **`run-test-binaries.sh`**: Runs SQLPage compiled executables against whatever `DATABASE_URL` names. Additional arguments are forwarded to each Rust harness; Oracle CI uses `--test-threads=2` to avoid exhausting its listener/process capacity.
- **`test-examples-hurl.sh`**: Starts an example's containers and runs its `test.hurl` suite. Takes an example path to filter on.
50 changes: 50 additions & 0 deletions scripts/install-oracle-odbc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail

: "${GITHUB_ENV:?Set GITHUB_ENV to the file that should receive the driver environment}"

cd "$(dirname "${BASH_SOURCE[0]}")/.."

# Keep the same client version as the RPM installation, without converting RPMs
# to Debian packages. CI caches the archives; verify them even on cache hits.
archive_dir="${RUNNER_TEMP:-/tmp}/sqlpage-oracle-instantclient-archives"
install_dir="${RUNNER_TEMP:-/tmp}/sqlpage-oracle-instantclient"
mkdir -p "$archive_dir" "$install_dir"

for package in basic odbc; do
archive="instantclient-${package}-linux.x64-21.21.0.0.0dbru.zip"
if [[ ! -f "$archive_dir/$archive" ]]; then
curl --fail --location --remove-on-error --retry 3 --connect-timeout 15 \
--max-time 180 --output "$archive_dir/$archive" \
"https://download.oracle.com/otn_software/linux/instantclient/2121000/$archive"
fi
done

(
cd "$archive_dir"
sha256sum --check <<'CHECKSUMS'
9cd0d5d5619ddaac43aa2214bab48e84155ca7e057d937634d1909b298125e8a instantclient-basic-linux.x64-21.21.0.0.0dbru.zip
37e4326ac14b08d9130d499fe5c1ba58f8ad72e8196f5618c0dc5880a24d6a23 instantclient-odbc-linux.x64-21.21.0.0.0dbru.zip
CHECKSUMS
)

for package in basic odbc; do
unzip -oq "$archive_dir/instantclient-${package}-linux.x64-21.21.0.0.0dbru.zip" -d "$install_dir"
done
client_dir="$install_dir/instantclient_21_21"

# Ubuntu 24.04's libaio package uses a different SONAME from Oracle's client.
libaio_path="$(ldconfig -p | awk '$1 ~ /^libaio\.so\.1(t64)?$/ { path = $NF } END { print path }')"
test -n "$libaio_path"
ln -sf "$libaio_path" "$client_dir/libaio.so.1"

cat > "$install_dir/odbcinst.ini" <<EOF
[Oracle 21 ODBC driver]
Description=Oracle ODBC driver for Oracle 21
Driver=$client_dir/libsqora.so.21.1
EOF

{
echo "LD_LIBRARY_PATH=$client_dir:${LD_LIBRARY_PATH:-}"
echo "ODBCSYSINI=$install_dir"
} >> "$GITHUB_ENV"
2 changes: 1 addition & 1 deletion scripts/run-test-binaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ fi

for test_binary in "${test_binaries[@]}"; do
echo "::group::$(basename "$test_binary")"
"$test_binary" --quiet
"$test_binary" --quiet "$@"
echo "::endgroup::"
done
8 changes: 4 additions & 4 deletions tests/basic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use actix_web::{

use crate::common::req_path;

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn test_index_ok() {
let resp = req_path("/").await.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
Expand All @@ -17,7 +17,7 @@ async fn test_index_ok() {
assert!(!body.contains("error"));
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn test_access_config_forbidden() {
let resp_result = req_path("/sqlpage/sqlpage.json").await;
assert!(
Expand All @@ -33,15 +33,15 @@ async fn test_access_config_forbidden() {
);
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
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]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn test_spaces_in_file_names() {
let resp = req_path("/tests/core/spaces%20in%20file%20name.sql")
.await
Expand Down
69 changes: 68 additions & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,75 @@ pub(crate) async fn get_request_to(path: &str) -> actix_web::Result<TestRequest>
get_request_to_with_data(path, data).await
}

// Pools must close while the test runtime is still running. Cancelling their
// background work at runtime teardown can leave Oracle statements/connections
// open and deadlock the ODBC driver's process destructor.
thread_local! {
static TEST_POOLS: std::cell::RefCell<Vec<sqlx::any::AnyPool>> = const { std::cell::RefCell::new(Vec::new()) };
}

pub(crate) struct TestSystem(actix_web::rt::SystemRunner);

impl TestSystem {
pub(crate) fn new() -> Self {
Self(actix_web::rt::System::new())
}

pub(crate) fn block_on<F: Future>(&self, future: F) -> F::Output {
use futures_util::FutureExt as _;

self.0.block_on(async {
let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
let pools = TEST_POOLS.with(std::cell::RefCell::take);
for pool in pools {
use sqlx::connection::Connection as _;

// Release prepared statements before disconnecting. Oracle can
// retain native resources if cached statements outlive their connection.
let mut connections = Vec::new();
for _ in 0..pool.size() {
let mut connection = pool.acquire().await.unwrap();
connection.clear_cached_statements().await.unwrap();
connections.push(connection);
}
drop(connections);
pool.close().await;
}
match result {
Ok(output) => output,
Err(panic) => std::panic::resume_unwind(panic),
}
})
}
}

pub(crate) async fn make_app_state_from_config(config: &AppConfig) -> anyhow::Result<AppState> {
let state = AppState::init(config).await?;
TEST_POOLS.with(|pools| pools.borrow_mut().push(state.db.connection.clone()));
Ok(state)
}

#[test]
fn test_system_closes_pools_on_success_and_panic() {
for panic_in_test in [false, true] {
let system = TestSystem::new();
let mut pool = None;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
system.block_on(async {
let mut config = test_config();
config.database_url = "sqlite::memory:".to_owned();
let state = make_app_state_from_config(&config).await.unwrap();
pool = Some(state.db.connection.clone());
assert!(!panic_in_test, "intentional test panic");
});
}));
assert_eq!(result.is_err(), panic_in_test);
assert!(pool.unwrap().is_closed());
}
}

pub(crate) async fn make_app_data_from_config(config: AppConfig) -> Data<AppState> {
let state = AppState::init(&config).await.unwrap();
let state = make_app_state_from_config(&config).await.unwrap();
Data::new(state)
}

Expand Down
14 changes: 7 additions & 7 deletions tests/cookies/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async fn set_cookie_header(path: &str) -> String {
.to_owned()
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn cookies_are_http_only_secure_and_same_site_strict_by_default() {
let header = set_cookie_header("/tests/cookies/set_cookie_defaults.sql").await;
assert!(header.starts_with("session=abc123"), "{header}");
Expand All @@ -22,7 +22,7 @@ async fn cookies_are_http_only_secure_and_same_site_strict_by_default() {
assert!(header.contains("Path=/"), "{header}");
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn zero_turns_off_a_cookie_protection() {
let header = set_cookie_header("/tests/cookies/set_cookie_opt_out.sql").await;
assert!(!header.contains("HttpOnly"), "{header}");
Expand All @@ -31,7 +31,7 @@ async fn zero_turns_off_a_cookie_protection() {
assert!(header.contains("Path=/admin"), "{header}");
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn a_page_made_only_of_header_components_still_sends_them() {
let resp = req_path("/tests/cookies/header_only_response.sql")
.await
Expand All @@ -40,20 +40,20 @@ async fn a_page_made_only_of_header_components_still_sends_them() {
assert!(resp.headers().contains_key(SET_COOKIE));
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn a_log_row_does_not_end_the_header_phase() {
let header = set_cookie_header("/tests/cookies/log_before_cookie.sql").await;
assert!(header.starts_with("session=abc123"), "{header}");
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn removing_a_cookie_expires_it() {
let header = set_cookie_header("/tests/cookies/remove_cookie.sql").await;
assert!(header.starts_with("session=;"), "{header}");
assert!(header.contains("Max-Age=0"), "{header}");
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn an_rfc_3339_expires_date_becomes_an_http_date() {
let header = set_cookie_header("/tests/cookies/cookie_expires_rfc3339.sql").await;
assert!(
Expand All @@ -62,7 +62,7 @@ async fn an_rfc_3339_expires_date_becomes_an_http_date() {
);
}

#[actix_web::test]
#[actix_web::rt::test(system = "crate::common::TestSystem")]
async fn a_unix_timestamp_expires_date_becomes_an_http_date() {
let header = set_cookie_header("/tests/cookies/cookie_expires_timestamp.sql").await;
assert!(
Expand Down
Loading
Loading