diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e049d4..200efbab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **OPE (Order-Preserving Encryption) index**: New `ope` index type alongside the existing `ore` for range and ordering queries. OPE ciphertexts compare under standard lexicographic byte ordering, so they're a drop-in alternative to ORE for range and `ORDER BY`. Configure with `SELECT eql_v2.add_search_config('table', 'column', 'ope', 'int')` (any cast type that `ore` supports). Requires EQL with OPE support and a CipherStash client/config build that includes `IndexType::Ope`. + ## [2.2.0-alpha.1] - 2026-03-25 ### Changed diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 4e35f3bf..2d229dda 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -201,7 +201,7 @@ This statement adds a `unique` index for the `email` column in the `users` table `unique` indexes are used to find records with columns with unique values, like with the `=` operator. -There are two other types of encrypted indexes you can use on `text` data: +There are other types of encrypted indexes you can use on `text` data: ```sql SELECT eql_v2.add_search_config( @@ -217,10 +217,18 @@ SELECT eql_v2.add_search_config( 'ore', 'text' ); + +SELECT eql_v2.add_search_config( + 'users', + 'email', + 'ope', + 'text' +); ``` The first SQL statement adds a `match` index, which is used for partial matches with `LIKE`. -The second SQL statement adds an `ore` index, which is used for ordering with `ORDER BY`. +The second SQL statement adds an `ore` index, which is used for ordering with `ORDER BY` and range comparisons (`<`, `<=`, `>`, `>=`). +The third SQL statement adds an `ope` index, which supports the same range and ordering operators as `ore` and is a drop-in alternative — pick `ope` or `ore` per column, not both. > ![IMPORTANT] diff --git a/docs/reference/searchable-json.md b/docs/reference/searchable-json.md index 5f1329d8..c45589fb 100644 --- a/docs/reference/searchable-json.md +++ b/docs/reference/searchable-json.md @@ -127,7 +127,7 @@ Encrypted literals cannot be passed as arguments to SQL functions. Encrypted col Examples: - `AVG()` cannot be used on encrypted numeric values ❌ -- `MIN()` and `MAX()` can be used on encrypted values with ORE index ✅ +- `MIN()` and `MAX()` can be used on encrypted values with an `ore` or `ope` index ✅ - `LOWER()` cannot be used on encrypted text (operates only on plaintext) ❌ ⚠️ **CAST Operations**: CAST operations cannot work on encrypted data because casting would require decryption within the database, which is impossible. EQL's `ste_vec` configuration enables direct comparison and ordering operations on encrypted values without requiring CAST. diff --git a/docs/sql/schema-example.sql b/docs/sql/schema-example.sql index 9244e79e..c89bb1b7 100644 --- a/docs/sql/schema-example.sql +++ b/docs/sql/schema-example.sql @@ -23,6 +23,8 @@ SELECT eql_v2.add_search_config( 'text' ); +-- 'ore' supports ordering and range comparisons. 'ope' is a drop-in +-- alternative with the same operator support — choose one per column. SELECT eql_v2.add_search_config( 'users', 'encrypted_email', diff --git a/packages/cipherstash-proxy-integration/src/common.rs b/packages/cipherstash-proxy-integration/src/common.rs index 63002e2c..4e4157b1 100644 --- a/packages/cipherstash-proxy-integration/src/common.rs +++ b/packages/cipherstash-proxy-integration/src/common.rs @@ -88,6 +88,18 @@ pub async fn clear_with_client(client: &Client) { client.simple_query(sql).await.unwrap(); } +/// Truncate a single table by name. Useful for tests that own a dedicated +/// fixture table (e.g. the per-test ORE/OPE tables) and don't need to wipe +/// the shared `encrypted`/`plaintext` tables. +pub async fn clear_table_with_client(client: &Client, table: &str) { + let sql = format!("TRUNCATE {}", table); + client.simple_query(&sql).await.unwrap(); +} + +pub async fn clear_table(table: &str) { + clear_table_with_client(&connect_with_tls(PROXY).await, table).await; +} + pub async fn reset_schema() { let port = std::env::var("CS_DATABASE__PORT") .map(|s| s.parse().unwrap()) diff --git a/packages/cipherstash-proxy-integration/src/lib.rs b/packages/cipherstash-proxy-integration/src/lib.rs index 8ab28cc5..8050045d 100644 --- a/packages/cipherstash-proxy-integration/src/lib.rs +++ b/packages/cipherstash-proxy-integration/src/lib.rs @@ -12,6 +12,8 @@ mod map_concat; mod map_literals; mod map_match_index; mod map_nulls; +mod map_ope_index_order; +mod map_ope_index_where; mod map_ore_index_order; mod map_ore_index_where; mod map_params; diff --git a/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs b/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs new file mode 100644 index 00000000..0933e384 --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs @@ -0,0 +1,162 @@ +#[cfg(test)] +mod tests { + use crate::common::{ + clear_table, connect_with_tls, interleaved_indices, random_id, trace, PROXY, + }; + + #[tokio::test] + async fn map_ope_order_text_asc() { + trace(); + let table = "encrypted_ope_order_text_asc"; + clear_table(table).await; + let client = connect_with_tls(PROXY).await; + + let values = ["aardvark", "aplomb", "chimera", "chrysalis", "zephyr"]; + + let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)"); + for idx in interleaved_indices(values.len()) { + client + .query(&insert, &[&random_id(), &values[idx]]) + .await + .unwrap(); + } + + let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text"); + let rows = client.query(&select, &[]).await.unwrap(); + + let actual: Vec = rows.iter().map(|r| r.get(0)).collect(); + let expected: Vec = values.iter().map(|s| s.to_string()).collect(); + + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn map_ope_order_text_desc() { + trace(); + let table = "encrypted_ope_order_text_desc"; + clear_table(table).await; + let client = connect_with_tls(PROXY).await; + + let values = ["aardvark", "aplomb", "chimera", "chrysalis", "zephyr"]; + + let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)"); + for idx in interleaved_indices(values.len()) { + client + .query(&insert, &[&random_id(), &values[idx]]) + .await + .unwrap(); + } + + let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text DESC"); + let rows = client.query(&select, &[]).await.unwrap(); + + let actual: Vec = rows.iter().map(|r| r.get(0)).collect(); + let expected: Vec = values.iter().rev().map(|s| s.to_string()).collect(); + + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn map_ope_order_int4_asc() { + trace(); + let table = "encrypted_ope_order_int4_asc"; + clear_table(table).await; + let client = connect_with_tls(PROXY).await; + + let values: Vec = vec![-100, -1, 0, 1, 42, 1000, i32::MAX]; + + let insert = format!("INSERT INTO {table} (id, encrypted_int4) VALUES ($1, $2)"); + for idx in interleaved_indices(values.len()) { + client + .query(&insert, &[&random_id(), &values[idx]]) + .await + .unwrap(); + } + + let select = format!("SELECT encrypted_int4 FROM {table} ORDER BY encrypted_int4"); + let rows = client.query(&select, &[]).await.unwrap(); + + let actual: Vec = rows.iter().map(|r| r.get(0)).collect(); + assert_eq!(actual, values); + } + + #[tokio::test] + async fn map_ope_order_int4_desc() { + trace(); + let table = "encrypted_ope_order_int4_desc"; + clear_table(table).await; + let client = connect_with_tls(PROXY).await; + + let values: Vec = vec![-100, -1, 0, 1, 42, 1000, i32::MAX]; + + let insert = format!("INSERT INTO {table} (id, encrypted_int4) VALUES ($1, $2)"); + for idx in interleaved_indices(values.len()) { + client + .query(&insert, &[&random_id(), &values[idx]]) + .await + .unwrap(); + } + + let select = format!("SELECT encrypted_int4 FROM {table} ORDER BY encrypted_int4 DESC"); + let rows = client.query(&select, &[]).await.unwrap(); + + let actual: Vec = rows.iter().map(|r| r.get(0)).collect(); + let expected: Vec = values.into_iter().rev().collect(); + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn map_ope_order_nulls_last_by_default() { + trace(); + let table = "encrypted_ope_order_nulls_last"; + clear_table(table).await; + let client = connect_with_tls(PROXY).await; + + let null_insert = format!("INSERT INTO {table} (id) VALUES ($1)"); + client.query(&null_insert, &[&random_id()]).await.unwrap(); + + let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4)"); + client + .query(&insert, &[&random_id(), &"a", &random_id(), &"b"]) + .await + .unwrap(); + + let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text"); + let rows = client.query(&select, &[]).await.unwrap(); + + let actual: Vec> = rows.iter().map(|r| r.get(0)).collect(); + assert_eq!( + actual, + vec![Some("a".into()), Some("b".into()), None], + "NULLs should sort last by default" + ); + } + + #[tokio::test] + async fn map_ope_order_nulls_first() { + trace(); + let table = "encrypted_ope_order_nulls_first"; + clear_table(table).await; + let client = connect_with_tls(PROXY).await; + + let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4)"); + client + .query(&insert, &[&random_id(), &"a", &random_id(), &"b"]) + .await + .unwrap(); + + let null_insert = format!("INSERT INTO {table} (id) VALUES ($1)"); + client.query(&null_insert, &[&random_id()]).await.unwrap(); + + let select = + format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text NULLS FIRST"); + let rows = client.query(&select, &[]).await.unwrap(); + + let actual: Vec> = rows.iter().map(|r| r.get(0)).collect(); + assert_eq!( + actual, + vec![None, Some("a".into()), Some("b".into())], + "NULLS FIRST should explicitly sort NULLs first" + ); + } +} diff --git a/packages/cipherstash-proxy-integration/src/map_ope_index_where.rs b/packages/cipherstash-proxy-integration/src/map_ope_index_where.rs new file mode 100644 index 00000000..68e761dd --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/map_ope_index_where.rs @@ -0,0 +1,184 @@ +#[cfg(test)] +mod tests { + use crate::common::{clear_table, connect_with_tls, random_id, trace, PROXY}; + use chrono::NaiveDate; + use tokio_postgres::types::{FromSql, ToSql}; + use tokio_postgres::Client; + + #[tokio::test] + async fn map_ope_where_generic_int2() { + map_ope_where_generic("encrypted_ope_where_int2", "encrypted_int2", 40i16, 99i16).await; + } + + #[tokio::test] + async fn map_ope_where_generic_int4() { + map_ope_where_generic("encrypted_ope_where_int4", "encrypted_int4", 40i32, 99i32).await; + } + + #[tokio::test] + async fn map_ope_where_generic_int8() { + map_ope_where_generic("encrypted_ope_where_int8", "encrypted_int8", 40i64, 99i64).await; + } + + #[tokio::test] + async fn map_ope_where_generic_float8() { + map_ope_where_generic( + "encrypted_ope_where_float8", + "encrypted_float8", + 40.0f64, + 99.0f64, + ) + .await; + } + + #[tokio::test] + async fn map_ope_where_generic_date() { + let low = NaiveDate::parse_from_str("2024-01-01", "%Y-%m-%d").unwrap(); + let high = NaiveDate::parse_from_str("2027-01-01", "%Y-%m-%d").unwrap(); + map_ope_where_generic("encrypted_ope_where_date", "encrypted_date", low, high).await; + } + + #[tokio::test] + async fn map_ope_where_generic_text() { + map_ope_where_generic( + "encrypted_ope_where_text", + "encrypted_text", + "ABC".to_string(), + "BCD".to_string(), + ) + .await; + } + + #[tokio::test] + async fn map_ope_where_generic_bool() { + map_ope_where_generic("encrypted_ope_where_bool", "encrypted_bool", false, true).await; + } + + /// Tests OPE operations against a per-test fixture table. + /// Mirrors `map_ore_where_generic` but targets the OPE-indexed mirror tables. + async fn map_ope_where_generic(table: &str, col_name: &str, low: T, high: T) + where + for<'a> T: Clone + ToSql + PartialEq + Sync + FromSql<'a> + PartialOrd, + { + trace(); + + clear_table(table).await; + + let client = connect_with_tls(PROXY).await; + + // Insert test data + let sql = format!("INSERT INTO {table} (id, {col_name}) VALUES ($1, $2)"); + for val in [low.clone(), high.clone()] { + client + .query(&sql, &[&random_id(), &val]) + .await + .expect("insert failed"); + } + + // NULL record + let sql = format!("INSERT INTO {table} (id, {col_name}) VALUES ($1, null)"); + client + .query(&sql, &[&random_id()]) + .await + .expect("insert failed"); + + // GT: given [1, 3], `> 1` returns [3] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} > $1"); + test_ope_op( + &client, + col_name, + &sql, + &[&low], + std::slice::from_ref(&high), + ) + .await; + + // GT 2nd case: given [1, 3], `> 3` returns [] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} > $1"); + test_ope_op::(&client, col_name, &sql, &[&high], &[]).await; + + // LT: given [1, 3], `< 3` returns [1] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} < $1"); + test_ope_op( + &client, + col_name, + &sql, + &[&high], + std::slice::from_ref(&low), + ) + .await; + + // LT 2nd case: given [1, 3], `< 1` returns [] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} < $1"); + test_ope_op(&client, col_name, &sql, &[&low], &[] as &[T]).await; + + // GT && LT: given [1, 3], `> 1 and < 3` returns [] + let sql = + format!("SELECT {col_name} FROM {table} WHERE {col_name} > $1 AND {col_name} < $2"); + test_ope_op(&client, col_name, &sql, &[&low, &high], &[] as &[T]).await; + + // LTEQ: given [1, 3], `<= 3` returns [1, 3] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} <= $1"); + test_ope_op( + &client, + col_name, + &sql, + &[&high], + &[low.clone(), high.clone()], + ) + .await; + + // GTEQ: given [1, 3], `>= 1` returns [1, 3] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} >= $1"); + test_ope_op( + &client, + col_name, + &sql, + &[&low], + &[low.clone(), high.clone()], + ) + .await; + + // EQ: given [1, 3], `= 1` returns [1] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} = $1"); + test_ope_op(&client, col_name, &sql, &[&low], std::slice::from_ref(&low)).await; + + // NEQ: given [1, 3], `<> 3` returns [1] + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} <> $1"); + test_ope_op( + &client, + col_name, + &sql, + &[&high], + std::slice::from_ref(&low), + ) + .await; + } + + /// Runs the query and checks the returned results match the expected results. + /// Sorts after the query (separate tests cover ordering). + async fn test_ope_op( + client: &Client, + col_name: &str, + sql: &str, + params: &[&(dyn ToSql + Sync)], + expected: &[T], + ) where + for<'a> T: Clone + ToSql + PartialEq + Sync + FromSql<'a> + PartialOrd, + { + let rows = client.query(sql, params).await.expect("query failed"); + let mut actual: Vec = rows.iter().map(|r| r.get(0)).collect(); + actual.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mut expected: Vec = expected.to_vec(); + expected.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + assert_eq!( + actual.len(), + expected.len(), + "wrong row count for {col_name} via {sql}" + ); + for (a, e) in actual.iter().zip(expected.iter()) { + assert!(a == e, "value mismatch for {col_name} via {sql}"); + } + } +} diff --git a/packages/cipherstash-proxy-integration/src/map_ore_index_order.rs b/packages/cipherstash-proxy-integration/src/map_ore_index_order.rs index 08118b65..3a1ac6cc 100644 --- a/packages/cipherstash-proxy-integration/src/map_ore_index_order.rs +++ b/packages/cipherstash-proxy-integration/src/map_ore_index_order.rs @@ -1,120 +1,126 @@ #[cfg(test)] mod tests { - use crate::common::{clear, connect_with_tls, trace, PROXY}; + use crate::common::{clear_table, connect_with_tls, trace, PROXY}; use crate::ore_order_helpers; use crate::ore_order_helpers::SortDirection; - use serial_test::serial; #[tokio::test] - #[serial] async fn map_ore_order_text() { trace(); - clear().await; + let table = "encrypted_ore_order_text"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_text(&client).await; + ore_order_helpers::ore_order_text(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_text_desc() { trace(); - clear().await; + let table = "encrypted_ore_order_text_desc"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_text_desc(&client).await; + ore_order_helpers::ore_order_text_desc(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_nulls_last_by_default() { trace(); - clear().await; + let table = "encrypted_ore_order_nulls_last"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_nulls_last_by_default(&client).await; + ore_order_helpers::ore_order_nulls_last_by_default(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_nulls_first() { trace(); - clear().await; + let table = "encrypted_ore_order_nulls_first"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_nulls_first(&client).await; + ore_order_helpers::ore_order_nulls_first(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_qualified_column() { trace(); - clear().await; + let table = "encrypted_ore_order_qualified"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_qualified_column(&client).await; + ore_order_helpers::ore_order_qualified_column(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_qualified_column_with_alias() { trace(); - clear().await; + let table = "encrypted_ore_order_qualified_alias"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_qualified_column_with_alias(&client).await; + ore_order_helpers::ore_order_qualified_column_with_alias(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_no_eql_column_in_select_projection() { trace(); - clear().await; + let table = "encrypted_ore_order_no_select_projection"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_no_eql_column_in_select_projection(&client).await; + ore_order_helpers::ore_order_no_eql_column_in_select_projection(&client, table).await; } #[tokio::test] - #[serial] async fn can_order_by_plaintext_column() { trace(); - clear().await; + let table = "encrypted_ore_order_plaintext_column"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_plaintext_column(&client).await; + ore_order_helpers::ore_order_plaintext_column(&client, table).await; } #[tokio::test] - #[serial] async fn can_order_by_plaintext_and_eql_columns() { trace(); - clear().await; + let table = "encrypted_ore_order_plaintext_and_eql"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_plaintext_and_eql_columns(&client).await; + ore_order_helpers::ore_order_plaintext_and_eql_columns(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_simple_protocol() { trace(); - clear().await; + let table = "encrypted_ore_order_simple_protocol"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; - ore_order_helpers::ore_order_simple_protocol(&client).await; + ore_order_helpers::ore_order_simple_protocol(&client, table).await; } #[tokio::test] - #[serial] async fn map_ore_order_int2() { trace(); - clear().await; + let table = "encrypted_ore_order_int2"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![-100, -10, -1, 0, 1, 5, 10, 20, 100, 200]; - ore_order_helpers::ore_order_generic(&client, "encrypted_int2", values, SortDirection::Asc) - .await; + ore_order_helpers::ore_order_generic( + &client, + table, + "encrypted_int2", + values, + SortDirection::Asc, + ) + .await; } #[tokio::test] - #[serial] async fn map_ore_order_int2_desc() { trace(); - clear().await; + let table = "encrypted_ore_order_int2_desc"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![-100, -10, -1, 0, 1, 5, 10, 20, 100, 200]; ore_order_helpers::ore_order_generic( &client, + table, "encrypted_int2", values, SortDirection::Desc, @@ -123,29 +129,36 @@ mod tests { } #[tokio::test] - #[serial] async fn map_ore_order_int4() { trace(); - clear().await; + let table = "encrypted_ore_order_int4"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![ -50_000, -1_000, -1, 0, 1, 42, 1_000, 10_000, 50_000, 100_000, ]; - ore_order_helpers::ore_order_generic(&client, "encrypted_int4", values, SortDirection::Asc) - .await; + ore_order_helpers::ore_order_generic( + &client, + table, + "encrypted_int4", + values, + SortDirection::Asc, + ) + .await; } #[tokio::test] - #[serial] async fn map_ore_order_int4_desc() { trace(); - clear().await; + let table = "encrypted_ore_order_int4_desc"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![ -50_000, -1_000, -1, 0, 1, 42, 1_000, 10_000, 50_000, 100_000, ]; ore_order_helpers::ore_order_generic( &client, + table, "encrypted_int4", values, SortDirection::Desc, @@ -154,29 +167,36 @@ mod tests { } #[tokio::test] - #[serial] async fn map_ore_order_int8() { trace(); - clear().await; + let table = "encrypted_ore_order_int8"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![ -1_000_000, -10_000, -1, 0, 1, 42, 10_000, 100_000, 1_000_000, 9_999_999, ]; - ore_order_helpers::ore_order_generic(&client, "encrypted_int8", values, SortDirection::Asc) - .await; + ore_order_helpers::ore_order_generic( + &client, + table, + "encrypted_int8", + values, + SortDirection::Asc, + ) + .await; } #[tokio::test] - #[serial] async fn map_ore_order_int8_desc() { trace(); - clear().await; + let table = "encrypted_ore_order_int8_desc"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![ -1_000_000, -10_000, -1, 0, 1, 42, 10_000, 100_000, 1_000_000, 9_999_999, ]; ore_order_helpers::ore_order_generic( &client, + table, "encrypted_int8", values, SortDirection::Desc, @@ -185,16 +205,17 @@ mod tests { } #[tokio::test] - #[serial] async fn map_ore_order_float8() { trace(); - clear().await; + let table = "encrypted_ore_order_float8"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![ -99.9, -1.5, -0.001, 0.0, 0.001, 1.5, 3.25, 42.0, 99.9, 1000.5, ]; ore_order_helpers::ore_order_generic( &client, + table, "encrypted_float8", values, SortDirection::Asc, @@ -203,16 +224,17 @@ mod tests { } #[tokio::test] - #[serial] async fn map_ore_order_float8_desc() { trace(); - clear().await; + let table = "encrypted_ore_order_float8_desc"; + clear_table(table).await; let client = connect_with_tls(PROXY).await; let values: Vec = vec![ -99.9, -1.5, -0.001, 0.0, 0.001, 1.5, 3.25, 42.0, 99.9, 1000.5, ]; ore_order_helpers::ore_order_generic( &client, + table, "encrypted_float8", values, SortDirection::Desc, diff --git a/packages/cipherstash-proxy-integration/src/map_ore_index_where.rs b/packages/cipherstash-proxy-integration/src/map_ore_index_where.rs index d70e389c..a5f7285c 100644 --- a/packages/cipherstash-proxy-integration/src/map_ore_index_where.rs +++ b/packages/cipherstash-proxy-integration/src/map_ore_index_where.rs @@ -1,62 +1,73 @@ #[cfg(test)] mod tests { - use crate::common::{clear, connect_with_tls, random_id, trace, PROXY}; + use crate::common::{clear_table, connect_with_tls, random_id, trace, PROXY}; use chrono::NaiveDate; use tokio_postgres::types::{FromSql, ToSql}; use tokio_postgres::Client; #[tokio::test] async fn map_ore_where_generic_int2() { - map_ore_where_generic("encrypted_int2", 40i16, 99i16).await; + map_ore_where_generic("encrypted_ore_where_int2", "encrypted_int2", 40i16, 99i16).await; } #[tokio::test] async fn map_ore_where_generic_int4() { - map_ore_where_generic("encrypted_int4", 40i32, 99i32).await; + map_ore_where_generic("encrypted_ore_where_int4", "encrypted_int4", 40i32, 99i32).await; } #[tokio::test] async fn map_ore_where_generic_int8() { - map_ore_where_generic("encrypted_int8", 40i64, 99i64).await; + map_ore_where_generic("encrypted_ore_where_int8", "encrypted_int8", 40i64, 99i64).await; } #[tokio::test] async fn map_ore_where_generic_float8() { - map_ore_where_generic("encrypted_float8", 40.0f64, 99.0f64).await; + map_ore_where_generic( + "encrypted_ore_where_float8", + "encrypted_float8", + 40.0f64, + 99.0f64, + ) + .await; } #[tokio::test] async fn map_ore_where_generic_date() { let low = NaiveDate::parse_from_str("2024-01-01", "%Y-%m-%d").unwrap(); let high = NaiveDate::parse_from_str("2027-01-01", "%Y-%m-%d").unwrap(); - map_ore_where_generic("encrypted_date", low, high).await; + map_ore_where_generic("encrypted_ore_where_date", "encrypted_date", low, high).await; } #[tokio::test] async fn map_ore_where_generic_text() { - map_ore_where_generic("encrypted_text", "ABC".to_string(), "BCD".to_string()).await; + map_ore_where_generic( + "encrypted_ore_where_text", + "encrypted_text", + "ABC".to_string(), + "BCD".to_string(), + ) + .await; } #[tokio::test] async fn map_ore_where_generic_bool() { - map_ore_where_generic("encrypted_bool", false, true).await; + map_ore_where_generic("encrypted_ore_where_bool", "encrypted_bool", false, true).await; } - /// Tests ORE operations with 2 values - high & low. - /// The type of column identified by col_name must match the parameters - /// such as INT2 and i16, FLOAT8 and f64 - async fn map_ore_where_generic(col_name: &str, low: T, high: T) + /// Tests ORE operations with 2 values - high & low - against a per-test + /// fixture table. `table` and `col_name` must match. + async fn map_ore_where_generic(table: &str, col_name: &str, low: T, high: T) where for<'a> T: Clone + PartialEq + ToSql + Sync + FromSql<'a> + PartialOrd, { trace(); - clear().await; + clear_table(table).await; let client = connect_with_tls(PROXY).await; // Insert test data - let sql = format!("INSERT INTO encrypted (id, {col_name}) VALUES ($1, $2)"); + let sql = format!("INSERT INTO {table} (id, {col_name}) VALUES ($1, $2)"); for val in [low.clone(), high.clone()] { client .query(&sql, &[&random_id(), &val]) @@ -65,14 +76,14 @@ mod tests { } // NULL record - let sql = format!("INSERT INTO encrypted (id, {col_name}) VALUES ($1, null)"); + let sql = format!("INSERT INTO {table} (id, {col_name}) VALUES ($1, null)"); client .query(&sql, &[&random_id()]) .await .expect("insert failed"); // GT: given [1, 3], `> 1` returns [3] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} > $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} > $1"); test_ore_op( &client, @@ -84,11 +95,11 @@ mod tests { .await; // GT 2nd case: given [1, 3], `> 3` returns [] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} > $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} > $1"); test_ore_op::(&client, col_name, &sql, &[&high], &[]).await; // LT: given [1, 3], `< 3` returns [1] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} < $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} < $1"); test_ore_op( &client, col_name, @@ -99,16 +110,16 @@ mod tests { .await; // LT 2nd case: given [1, 3], `< 3` returns [] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} < $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} < $1"); test_ore_op(&client, col_name, &sql, &[&low], &[] as &[T]).await; // GT && LT: given [1, 3], `> 1 and < 3` returns [] let sql = - format!("SELECT {col_name} FROM encrypted WHERE {col_name} > $1 AND {col_name} < $2"); + format!("SELECT {col_name} FROM {table} WHERE {col_name} > $1 AND {col_name} < $2"); test_ore_op(&client, col_name, &sql, &[&low, &high], &[] as &[T]).await; // LTEQ: given [1, 3], `<= 3` returns [1, 3] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} <= $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} <= $1"); test_ore_op( &client, col_name, @@ -119,7 +130,7 @@ mod tests { .await; // GTEQ: given [1, 3], `>= 1` returns [1, 3] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} >= $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} >= $1"); test_ore_op( &client, col_name, @@ -130,11 +141,11 @@ mod tests { .await; // EQ: given [1, 3], `= 1` returns [1] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} = $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} = $1"); test_ore_op(&client, col_name, &sql, &[&low], std::slice::from_ref(&low)).await; // NEQ: given [1, 3], `<> 3` returns [1] - let sql = format!("SELECT {col_name} FROM encrypted WHERE {col_name} <> $1"); + let sql = format!("SELECT {col_name} FROM {table} WHERE {col_name} <> $1"); test_ore_op( &client, col_name, diff --git a/packages/cipherstash-proxy-integration/src/multitenant/ore_order.rs b/packages/cipherstash-proxy-integration/src/multitenant/ore_order.rs index 4e71d8ee..0a8dcbe4 100644 --- a/packages/cipherstash-proxy-integration/src/multitenant/ore_order.rs +++ b/packages/cipherstash-proxy-integration/src/multitenant/ore_order.rs @@ -36,13 +36,18 @@ mod tests { use super::*; use serial_test::serial; + // Multitenant tests share the `encrypted` table — isolation comes + // from the per-tenant keyset, and `#[serial]` keeps the shared + // table from racing across tests. + const TABLE: &str = "encrypted"; + #[tokio::test] #[serial] async fn multitenant_ore_order_text() { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_text(&client).await; + ore_order_helpers::ore_order_text(&client, TABLE).await; } #[tokio::test] @@ -51,7 +56,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_text_desc(&client).await; + ore_order_helpers::ore_order_text_desc(&client, TABLE).await; } #[tokio::test] @@ -60,7 +65,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_nulls_last_by_default(&client).await; + ore_order_helpers::ore_order_nulls_last_by_default(&client, TABLE).await; } #[tokio::test] @@ -69,7 +74,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_nulls_first(&client).await; + ore_order_helpers::ore_order_nulls_first(&client, TABLE).await; } #[tokio::test] @@ -78,7 +83,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_qualified_column(&client).await; + ore_order_helpers::ore_order_qualified_column(&client, TABLE).await; } #[tokio::test] @@ -87,7 +92,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_qualified_column_with_alias(&client).await; + ore_order_helpers::ore_order_qualified_column_with_alias(&client, TABLE).await; } #[tokio::test] @@ -96,7 +101,8 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_no_eql_column_in_select_projection(&client).await; + ore_order_helpers::ore_order_no_eql_column_in_select_projection(&client, TABLE) + .await; } #[tokio::test] @@ -105,7 +111,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_plaintext_column(&client).await; + ore_order_helpers::ore_order_plaintext_column(&client, TABLE).await; } #[tokio::test] @@ -114,7 +120,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_plaintext_and_eql_columns(&client).await; + ore_order_helpers::ore_order_plaintext_and_eql_columns(&client, TABLE).await; } #[tokio::test] @@ -123,7 +129,7 @@ mod tests { trace(); clear().await; let client = connect_as_tenant(&keyset_id($env_var)).await; - ore_order_helpers::ore_order_simple_protocol(&client).await; + ore_order_helpers::ore_order_simple_protocol(&client, TABLE).await; } #[tokio::test] @@ -135,6 +141,7 @@ mod tests { let values: Vec = vec![-100, -10, -1, 0, 1, 5, 10, 20, 100, 200]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_int2", values, SortDirection::Asc, @@ -151,6 +158,7 @@ mod tests { let values: Vec = vec![-100, -10, -1, 0, 1, 5, 10, 20, 100, 200]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_int2", values, SortDirection::Desc, @@ -169,6 +177,7 @@ mod tests { ]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_int4", values, SortDirection::Asc, @@ -187,6 +196,7 @@ mod tests { ]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_int4", values, SortDirection::Desc, @@ -205,6 +215,7 @@ mod tests { ]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_int8", values, SortDirection::Asc, @@ -223,6 +234,7 @@ mod tests { ]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_int8", values, SortDirection::Desc, @@ -241,6 +253,7 @@ mod tests { ]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_float8", values, SortDirection::Asc, @@ -259,6 +272,7 @@ mod tests { ]; ore_order_helpers::ore_order_generic( &client, + TABLE, "encrypted_float8", values, SortDirection::Desc, diff --git a/packages/cipherstash-proxy-integration/src/ore_order_helpers.rs b/packages/cipherstash-proxy-integration/src/ore_order_helpers.rs index 4a93e416..8c3d3f73 100644 --- a/packages/cipherstash-proxy-integration/src/ore_order_helpers.rs +++ b/packages/cipherstash-proxy-integration/src/ore_order_helpers.rs @@ -3,6 +3,10 @@ //! //! Used by both `map_ore_index_order` (default keyset) and `multitenant::ore_order` //! (per-tenant keysets) to avoid duplicating test logic. +//! +//! Each helper takes a `table` name so callers can target their own per-test +//! fixture table — this prevents parallel-test races on a shared `encrypted` +//! table. use std::fmt::Debug; use tokio_postgres::types::{FromSql, ToSql}; @@ -27,7 +31,7 @@ impl SortDirection { } /// Text ASC ordering with lexicographic edge cases. -pub async fn ore_order_text(client: &tokio_postgres::Client) { +pub async fn ore_order_text(client: &tokio_postgres::Client, table: &str) { let values = [ "aardvark", "aplomb", @@ -38,17 +42,17 @@ pub async fn ore_order_text(client: &tokio_postgres::Client) { "zephyr", ]; - let insert_sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)"; + let insert_sql = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)"); for idx in interleaved_indices(values.len()) { client - .query(insert_sql, &[&random_id(), &values[idx]]) + .query(&insert_sql, &[&random_id(), &values[idx]]) .await .unwrap(); } - let sql = "SELECT encrypted_text FROM encrypted ORDER BY encrypted_text"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows.iter().map(|row| row.get(0)).collect::>(); let expected: Vec = values.iter().map(|s| s.to_string()).collect(); @@ -57,7 +61,7 @@ pub async fn ore_order_text(client: &tokio_postgres::Client) { } /// Text DESC ordering with lexicographic edge cases. -pub async fn ore_order_text_desc(client: &tokio_postgres::Client) { +pub async fn ore_order_text_desc(client: &tokio_postgres::Client, table: &str) { let values = [ "aardvark", "aplomb", @@ -68,17 +72,17 @@ pub async fn ore_order_text_desc(client: &tokio_postgres::Client) { "zephyr", ]; - let insert_sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)"; + let insert_sql = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)"); for idx in interleaved_indices(values.len()) { client - .query(insert_sql, &[&random_id(), &values[idx]]) + .query(&insert_sql, &[&random_id(), &values[idx]]) .await .unwrap(); } - let sql = "SELECT encrypted_text FROM encrypted ORDER BY encrypted_text DESC"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text DESC"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows.iter().map(|row| row.get(0)).collect::>(); let expected: Vec = values.iter().rev().map(|s| s.to_string()).collect(); @@ -87,27 +91,32 @@ pub async fn ore_order_text_desc(client: &tokio_postgres::Client) { } /// NULLs sort last in ASC by default. -pub async fn ore_order_nulls_last_by_default(client: &tokio_postgres::Client) { +pub async fn ore_order_nulls_last_by_default(client: &tokio_postgres::Client, table: &str) { let s_one = "a"; let s_two = "b"; client - .query("INSERT INTO encrypted (id) values ($1)", &[&random_id()]) + .query( + &format!("INSERT INTO {table} (id) values ($1)"), + &[&random_id()], + ) .await .unwrap(); - let sql = " - INSERT INTO encrypted (id, encrypted_text) + let sql = format!( + " + INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4) - "; + " + ); client - .query(sql, &[&random_id(), &s_one, &random_id(), &s_two]) + .query(&sql, &[&random_id(), &s_one, &random_id(), &s_two]) .await .unwrap(); - let sql = "SELECT encrypted_text FROM encrypted ORDER BY encrypted_text"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows .iter() @@ -119,27 +128,32 @@ pub async fn ore_order_nulls_last_by_default(client: &tokio_postgres::Client) { } /// NULLS FIRST clause. -pub async fn ore_order_nulls_first(client: &tokio_postgres::Client) { +pub async fn ore_order_nulls_first(client: &tokio_postgres::Client, table: &str) { let s_one = "a"; let s_two = "b"; - let sql = " - INSERT INTO encrypted (id, encrypted_text) + let sql = format!( + " + INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4) - "; + " + ); client - .query(sql, &[&random_id(), &s_one, &random_id(), &s_two]) + .query(&sql, &[&random_id(), &s_one, &random_id(), &s_two]) .await .unwrap(); client - .query("INSERT INTO encrypted (id) values ($1)", &[&random_id()]) + .query( + &format!("INSERT INTO {table} (id) values ($1)"), + &[&random_id()], + ) .await .unwrap(); - let sql = "SELECT encrypted_text FROM encrypted ORDER BY encrypted_text NULLS FIRST"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text NULLS FIRST"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows .iter() @@ -150,20 +164,22 @@ pub async fn ore_order_nulls_first(client: &tokio_postgres::Client) { assert_eq!(actual, expected); } -/// Fully qualified column name: `encrypted.encrypted_text`. -pub async fn ore_order_qualified_column(client: &tokio_postgres::Client) { +/// Fully qualified column name: `.encrypted_text`. +pub async fn ore_order_qualified_column(client: &tokio_postgres::Client, table: &str) { let s_one = "a"; let s_two = "b"; let s_three = "c"; - let sql = " - INSERT INTO encrypted (id, encrypted_text) + let sql = format!( + " + INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4), ($5, $6) - "; + " + ); client .query( - sql, + &sql, &[ &random_id(), &s_two, @@ -176,8 +192,8 @@ pub async fn ore_order_qualified_column(client: &tokio_postgres::Client) { .await .unwrap(); - let sql = "SELECT encrypted_text FROM encrypted ORDER BY encrypted.encrypted_text"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} ORDER BY {table}.encrypted_text"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows.iter().map(|row| row.get(0)).collect::>(); let expected = vec![s_one, s_two, s_three]; @@ -186,19 +202,21 @@ pub async fn ore_order_qualified_column(client: &tokio_postgres::Client) { } /// Table alias: `e.encrypted_text`. -pub async fn ore_order_qualified_column_with_alias(client: &tokio_postgres::Client) { +pub async fn ore_order_qualified_column_with_alias(client: &tokio_postgres::Client, table: &str) { let s_one = "a"; let s_two = "b"; let s_three = "c"; - let sql = " - INSERT INTO encrypted (id, encrypted_text) + let sql = format!( + " + INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4), ($5, $6) - "; + " + ); client .query( - sql, + &sql, &[ &random_id(), &s_two, @@ -211,8 +229,8 @@ pub async fn ore_order_qualified_column_with_alias(client: &tokio_postgres::Clie .await .unwrap(); - let sql = "SELECT encrypted_text FROM encrypted e ORDER BY e.encrypted_text"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} e ORDER BY e.encrypted_text"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows.iter().map(|row| row.get(0)).collect::>(); let expected = vec![s_one, s_two, s_three]; @@ -221,7 +239,10 @@ pub async fn ore_order_qualified_column_with_alias(client: &tokio_postgres::Clie } /// ORDER BY column not in SELECT projection. -pub async fn ore_order_no_eql_column_in_select_projection(client: &tokio_postgres::Client) { +pub async fn ore_order_no_eql_column_in_select_projection( + client: &tokio_postgres::Client, + table: &str, +) { let id_one = random_id(); let s_one = "a"; let id_two = random_id(); @@ -229,21 +250,23 @@ pub async fn ore_order_no_eql_column_in_select_projection(client: &tokio_postgre let id_three = random_id(); let s_three = "c"; - let sql = " - INSERT INTO encrypted (id, encrypted_text) + let sql = format!( + " + INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4), ($5, $6) - "; + " + ); client .query( - sql, + &sql, &[&id_two, &s_two, &id_one, &s_one, &id_three, &s_three], ) .await .unwrap(); - let sql = "SELECT id FROM encrypted ORDER BY encrypted_text"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT id FROM {table} ORDER BY encrypted_text"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows.iter().map(|row| row.get(0)).collect::>(); let expected = vec![id_one, id_two, id_three]; @@ -252,19 +275,21 @@ pub async fn ore_order_no_eql_column_in_select_projection(client: &tokio_postgre } /// Plaintext column ordering (sanity check). -pub async fn ore_order_plaintext_column(client: &tokio_postgres::Client) { +pub async fn ore_order_plaintext_column(client: &tokio_postgres::Client, table: &str) { let s_one = "a"; let s_two = "b"; let s_three = "c"; - let sql = " - INSERT INTO encrypted (id, plaintext) + let sql = format!( + " + INSERT INTO {table} (id, plaintext) VALUES ($1, $2), ($3, $4), ($5, $6) - "; + " + ); client .query( - sql, + &sql, &[ &random_id(), &s_two, @@ -277,8 +302,8 @@ pub async fn ore_order_plaintext_column(client: &tokio_postgres::Client) { .await .unwrap(); - let sql = "SELECT plaintext FROM encrypted ORDER BY plaintext"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = format!("SELECT plaintext FROM {table} ORDER BY plaintext"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows.iter().map(|row| row.get(0)).collect::>(); let expected = vec![s_one, s_two, s_three]; @@ -287,7 +312,7 @@ pub async fn ore_order_plaintext_column(client: &tokio_postgres::Client) { } /// Mixed plaintext + encrypted column ordering. -pub async fn ore_order_plaintext_and_eql_columns(client: &tokio_postgres::Client) { +pub async fn ore_order_plaintext_and_eql_columns(client: &tokio_postgres::Client, table: &str) { let s_plaintext_one = "a"; let s_plaintext_two = "a"; let s_plaintext_three = "b"; @@ -296,14 +321,16 @@ pub async fn ore_order_plaintext_and_eql_columns(client: &tokio_postgres::Client let s_encrypted_two = "b"; let s_encrypted_three = "c"; - let sql = " - INSERT INTO encrypted (id, plaintext, encrypted_text) + let sql = format!( + " + INSERT INTO {table} (id, plaintext, encrypted_text) VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9) - "; + " + ); client .query( - sql, + &sql, &[ &random_id(), &s_plaintext_two, @@ -319,8 +346,9 @@ pub async fn ore_order_plaintext_and_eql_columns(client: &tokio_postgres::Client .await .unwrap(); - let sql = "SELECT plaintext, encrypted_text FROM encrypted ORDER BY plaintext, encrypted_text"; - let rows = client.query(sql, &[]).await.unwrap(); + let sql = + format!("SELECT plaintext, encrypted_text FROM {table} ORDER BY plaintext, encrypted_text"); + let rows = client.query(&sql, &[]).await.unwrap(); let actual = rows .iter() @@ -337,9 +365,9 @@ pub async fn ore_order_plaintext_and_eql_columns(client: &tokio_postgres::Client } /// Simple query protocol ordering. -pub async fn ore_order_simple_protocol(client: &tokio_postgres::Client) { +pub async fn ore_order_simple_protocol(client: &tokio_postgres::Client, table: &str) { let sql = format!( - "INSERT INTO encrypted (id, encrypted_text) VALUES ({}, 'y'), ({}, 'x'), ({}, 'z')", + "INSERT INTO {table} (id, encrypted_text) VALUES ({}, 'y'), ({}, 'x'), ({}, 'z')", random_id(), random_id(), random_id() @@ -347,8 +375,8 @@ pub async fn ore_order_simple_protocol(client: &tokio_postgres::Client) { client.simple_query(&sql).await.unwrap(); - let sql = "SELECT encrypted_text FROM encrypted ORDER BY encrypted_text"; - let rows = client.simple_query(sql).await.unwrap(); + let sql = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text"); + let rows = client.simple_query(&sql).await.unwrap(); let actual = rows .iter() @@ -373,13 +401,14 @@ pub async fn ore_order_simple_protocol(client: &tokio_postgres::Client) { /// via ORDER BY in the given direction. pub async fn ore_order_generic( client: &tokio_postgres::Client, + table: &str, col_name: &str, values: Vec, direction: SortDirection, ) where for<'a> T: Clone + PartialEq + ToSql + Sync + FromSql<'a> + PartialOrd + Debug, { - let insert_sql = format!("INSERT INTO encrypted (id, {col_name}) VALUES ($1, $2)"); + let insert_sql = format!("INSERT INTO {table} (id, {col_name}) VALUES ($1, $2)"); for idx in interleaved_indices(values.len()) { client @@ -389,7 +418,7 @@ pub async fn ore_order_generic( } let dir = direction.as_sql(); - let select_sql = format!("SELECT {col_name} FROM encrypted ORDER BY {col_name} {dir}"); + let select_sql = format!("SELECT {col_name} FROM {table} ORDER BY {col_name} {dir}"); let rows = client.query(&select_sql, &[]).await.unwrap(); let actual: Vec = rows.iter().map(|row| row.get(0)).collect(); diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index 0b33c28e..65dcaf0d 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs @@ -360,6 +360,23 @@ mod tests { assert_eq!(column.indexes[0].index_type, IndexType::Ore); } + #[test] + fn can_parse_ope_index() { + let json = json!({ + "v": 1, + "tables": { + "users": { "email": { "indexes": { "ope": {} } } } + } + }); + + let encrypt_config = parse(json); + let column = encrypt_config + .get(&Identifier::new("users", "email")) + .unwrap(); + + assert_eq!(column.indexes[0].index_type, IndexType::Ope); + } + #[test] fn can_parse_unique_index_with_defaults() { let json = json!({ diff --git a/tests/sql/schema.sql b/tests/sql/schema.sql index 69a6ec5e..86a666f2 100644 --- a/tests/sql/schema.sql +++ b/tests/sql/schema.sql @@ -169,6 +169,144 @@ SELECT eql_v2.add_search_config( SELECT eql_v2.add_encrypted_constraint('encrypted', 'encrypted_text'); +-- Per-test ORE-indexed tables. +-- Each integration test that exercises ORE range/order operators gets its own +-- table. Eliminates parallel-test races on a shared `encrypted` table without +-- having to mark tests `#[serial]`. +-- +-- Schema mirrors `encrypted` minus the jsonb columns (these ORE tests never +-- touch jsonb). Each table gets the same `add_search_config` and constraint +-- calls as the original `encrypted` table. +DO $$ +DECLARE + test_tables text[] := ARRAY[ + -- map_ore_index_where (one per column type) + 'encrypted_ore_where_int2', + 'encrypted_ore_where_int4', + 'encrypted_ore_where_int8', + 'encrypted_ore_where_float8', + 'encrypted_ore_where_date', + 'encrypted_ore_where_text', + 'encrypted_ore_where_bool', + -- map_ore_index_order (one per test fn) + 'encrypted_ore_order_text', + 'encrypted_ore_order_text_desc', + 'encrypted_ore_order_nulls_last', + 'encrypted_ore_order_nulls_first', + 'encrypted_ore_order_qualified', + 'encrypted_ore_order_qualified_alias', + 'encrypted_ore_order_no_select_projection', + 'encrypted_ore_order_plaintext_column', + 'encrypted_ore_order_plaintext_and_eql', + 'encrypted_ore_order_simple_protocol', + 'encrypted_ore_order_int2', + 'encrypted_ore_order_int2_desc', + 'encrypted_ore_order_int4', + 'encrypted_ore_order_int4_desc', + 'encrypted_ore_order_int8', + 'encrypted_ore_order_int8_desc', + 'encrypted_ore_order_float8', + 'encrypted_ore_order_float8_desc' + ]; + tn text; +BEGIN + FOREACH tn IN ARRAY test_tables LOOP + EXECUTE format('DROP TABLE IF EXISTS %I CASCADE', tn); + EXECUTE format( + 'CREATE TABLE %I ( + id bigint, + plaintext text, + plaintext_date date, + encrypted_text eql_v2_encrypted, + encrypted_bool eql_v2_encrypted, + encrypted_int2 eql_v2_encrypted, + encrypted_int4 eql_v2_encrypted, + encrypted_int8 eql_v2_encrypted, + encrypted_float8 eql_v2_encrypted, + encrypted_date eql_v2_encrypted, + PRIMARY KEY(id) + )', tn); + + PERFORM eql_v2.add_search_config(tn, 'encrypted_text', 'unique', 'text'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_text', 'match', 'text'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_text', 'ore', 'text'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_bool', 'unique', 'boolean'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_bool', 'ore', 'boolean'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int2', 'unique', 'small_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int2', 'ore', 'small_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int4', 'unique', 'int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int4', 'ore', 'int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int8', 'unique', 'big_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int8', 'ore', 'big_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_float8', 'unique', 'double'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_float8', 'ore', 'double'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_date', 'unique', 'date'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_date', 'ore', 'date'); + + PERFORM eql_v2.add_encrypted_constraint(tn, 'encrypted_text'); + END LOOP; +END $$; + + +-- Per-test OPE-indexed tables (parallels the ORE block above; uses 'ope' index). +DO $$ +DECLARE + test_tables text[] := ARRAY[ + -- map_ope_index_where (one per column type) + 'encrypted_ope_where_int2', + 'encrypted_ope_where_int4', + 'encrypted_ope_where_int8', + 'encrypted_ope_where_float8', + 'encrypted_ope_where_date', + 'encrypted_ope_where_text', + 'encrypted_ope_where_bool', + -- map_ope_index_order (one per test fn) + 'encrypted_ope_order_text_asc', + 'encrypted_ope_order_text_desc', + 'encrypted_ope_order_int4_asc', + 'encrypted_ope_order_int4_desc', + 'encrypted_ope_order_nulls_last', + 'encrypted_ope_order_nulls_first' + ]; + tn text; +BEGIN + FOREACH tn IN ARRAY test_tables LOOP + EXECUTE format('DROP TABLE IF EXISTS %I CASCADE', tn); + EXECUTE format( + 'CREATE TABLE %I ( + id bigint, + plaintext text, + plaintext_date date, + encrypted_text eql_v2_encrypted, + encrypted_bool eql_v2_encrypted, + encrypted_int2 eql_v2_encrypted, + encrypted_int4 eql_v2_encrypted, + encrypted_int8 eql_v2_encrypted, + encrypted_float8 eql_v2_encrypted, + encrypted_date eql_v2_encrypted, + PRIMARY KEY(id) + )', tn); + + PERFORM eql_v2.add_search_config(tn, 'encrypted_text', 'unique', 'text'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_text', 'ope', 'text'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_bool', 'unique', 'boolean'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_bool', 'ope', 'boolean'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int2', 'unique', 'small_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int2', 'ope', 'small_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int4', 'unique', 'int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int4', 'ope', 'int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int8', 'unique', 'big_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_int8', 'ope', 'big_int'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_float8', 'unique', 'double'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_float8', 'ope', 'double'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_date', 'unique', 'date'); + PERFORM eql_v2.add_search_config(tn, 'encrypted_date', 'ope', 'date'); + + PERFORM eql_v2.add_encrypted_constraint(tn, 'encrypted_text'); + END LOOP; +END $$; + + -- This is the exact same schema as above but using a database-generated primary key. -- It is required to remove flake form the Elixir integration test suite. -- TODO: port all the rest of our integration tests to this schema.