Skip to content
Open
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
21 changes: 21 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,27 @@ CS_EQL_V3_PATH=../encrypt-query-language/release mise run postgres:eql:v3:setup

Note: unlike `postgres:setup`, this does **not** apply `tests/sql/schema.sql` — that fixture is still EQL v2. Use `postgres:eql:v3:teardown` to just uninstall EQL v3.

##### EQL v3 test fixtures (gated)

The EQL v3 ports of the test fixtures are opt-in while the eql-mapper cannot speak v3:

```shell
# Install EQL v3 and apply the v3 test schema (tests/sql/schema-v3.sql).
# This REPLACES the v2 fixture tables (same table names).
CS_EQL_V3_PATH=../encrypt-query-language/release mise run postgres:setup:v3

# Run the gated EQL v3 integration tests (all `#[ignore]`d by default)
cargo nextest run -p cipherstash-proxy-integration -E 'test(eql_v3)' --run-ignored all

# Restore the v2 fixture afterwards
mise run postgres:setup
```

The v3 benchmark schema (`tests/benchmark/sql/benchmark-schema-v3.sql`) is applied with `mise run benchmark:setup:v3` from `tests/benchmark` (it also requires `postgres:eql:v3:setup` to have been run).

> [!IMPORTANT]
> Re-run `benchmark:setup:v3` after any `postgres:eql:v3:setup` / `postgres:eql:v3:teardown`: the CASCADE uninstall silently drops `eql_v3`-typed columns from tables it doesn't recreate (`postgres:setup:v3` recreates the test fixture tables, but not `benchmark_encrypted`).

#### Convention: PostgreSQL ports

PostgreSQL port numbers are 4 digits:
Expand Down
15 changes: 15 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,21 @@ mise run postgres:fail_if_not_running
cat "{{config_root}}/cipherstash-encrypt-v3.sql" | docker exec -i postgres${CONTAINER_SUFFIX:-} psql postgresql://${CS_DATABASE__USERNAME}:${CS_DATABASE__PASSWORD_ESCAPED_FOR_TESTS}@${CS_DATABASE__HOST}:${CS_DATABASE__PORT}/${CS_DATABASE__NAME} -f-
"""

# GATED: the EQL v3 test fixture is opt-in while the eql-mapper cannot speak
# v3. This task is NOT part of `postgres:setup`; it replaces the v2 fixture
# tables with their v3-domain equivalents (same table names). Re-run
# `mise run postgres:setup` to restore the v2 fixture.
[tasks."postgres:setup:v3"]
depends = ["postgres:eql:v3:setup"]
description = "Installs EQL v3 and applies the v3 test schema (tests/sql/schema-v3.sql) to the database"
run = """
#!/bin/bash
cd tests
mise run postgres:fail_if_not_running
cat sql/schema-v3-uninstall.sql | docker exec -i postgres${CONTAINER_SUFFIX:-} psql -v ON_ERROR_STOP=1 postgresql://${CS_DATABASE__USERNAME}:${CS_DATABASE__PASSWORD_ESCAPED_FOR_TESTS}@${CS_DATABASE__HOST}:${CS_DATABASE__PORT}/${CS_DATABASE__NAME} -f-
cat sql/schema-v3.sql | docker exec -i postgres${CONTAINER_SUFFIX:-} psql -v ON_ERROR_STOP=1 postgresql://${CS_DATABASE__USERNAME}:${CS_DATABASE__PASSWORD_ESCAPED_FOR_TESTS}@${CS_DATABASE__HOST}:${CS_DATABASE__PORT}/${CS_DATABASE__NAME} -f-
"""

[tasks."test:integration:lang:python"]
dir = "{{config_root}}/tests"
description = "Runs python tests"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! EQL v3 variant of `disable_mapping.rs`.
//!
//! With `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true` the proxy passes raw
//! column values through. In v2 those are `eql_v2_encrypted` composites; in v3
//! each column is a jsonb-backed `eql_v3.*` domain, so the raw value is the v3
//! envelope `{v: 3, i: {t, c}, c, <terms>}` with no `k` discriminator.

#[cfg(test)]
mod tests {
use crate::common::{
clear, connect_with_tls, execute_query, query_with_client, random_id,
simple_query_with_client, trace, PROXY,
};
use serde_json::{json, Value};

///
/// Tests mapping is disabled when the `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING` command is used
/// and the raw value is an EQL v3 envelope.
///
#[tokio::test]
#[ignore = "blocked on eql-mapper v3"]
async fn select_with_set_unsafe_disable_mapping_returns_v3_envelope() {
trace();

clear().await;

let client = connect_with_tls(PROXY).await;

let id = random_id();
let encrypted_text = "hello".to_string();
let expected = vec![encrypted_text.to_owned()];

let sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)";
execute_query(sql, &[&id, &encrypted_text]).await;

let sql = "SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true";
client.query(sql, &[]).await.unwrap();

// Data should not be decrypted: the raw jsonb envelope comes back.
// Scoped by id so the test stays parallel-safe on the shared table.
let select_sql = format!("SELECT encrypted_text FROM encrypted WHERE id = {id}");
let rows = simple_query_with_client::<String>(&select_sql, &client).await;

assert_eq!(rows.len(), 1);

for s in rows {
let envelope: Value = serde_json::from_str(&s).unwrap();
// v3 envelope: version 3, identifier, ciphertext - and no v2 `k` discriminator
assert_eq!(envelope["v"], json!(3));
assert_eq!(envelope["i"]["c"], json!("encrypted_text"));
assert!(envelope.get("c").is_some());
assert!(envelope.get("k").is_none());
}

// ---------------------
// Turn mapping back on and regular services resume

let sql = "SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = false";
client.query(sql, &[]).await.unwrap();

let actual = query_with_client::<String>(&select_sql, &client).await;
assert_eq!(expected, actual);
}
}
54 changes: 54 additions & 0 deletions packages/cipherstash-proxy-integration/src/eql_v3/indexing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! EQL v3 variant of `select/indexing.rs`.
//!
//! v2 indexes the encrypted column with an on-column operator class:
//!
//! ```sql
//! CREATE INDEX ON encrypted (encrypted_text eql_v2.encrypted_operator_class);
//! ```
//!
//! v3 has no on-column operator class. Ordering/range queries engage
//! functional btree indexes on the term extractors instead:
//!
//! ```sql
//! CREATE INDEX ON encrypted (eql_v3.ord_term(encrypted_text));
//! CREATE INDEX ON encrypted (eql_v3.ord_ope_term(encrypted_int4));
//! ```

#[cfg(test)]
mod tests {
use crate::common::{connect_with_tls, insert, query_by, random_id, trace, PROXY};
use tracing::info;

///
/// Tests a range query with a functional index on the ORE term extractor.
///
#[tokio::test]
#[ignore = "blocked on eql-mapper v3"]
async fn select_with_functional_index() {
trace();

for n in 1..=10 {
let id = random_id();

let encrypted_text = format!("hello_{}", n);

let sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)";
insert(sql, &[&id, &encrypted_text]).await;
}

let client = connect_with_tls(PROXY).await;

let sql = "CREATE INDEX IF NOT EXISTS encrypted_text_ord_term_idx ON encrypted (eql_v3.ord_term(encrypted_text))";
client.simple_query(sql).await.unwrap();

let sql = "EXPLAIN ANALYZE SELECT encrypted_text FROM encrypted WHERE encrypted_text <= $1";

let encrypted_text = "hello_10".to_string();
let result = query_by::<String>(sql, &encrypted_text).await;

info!("Result: {:?}", result);

// EXPLAIN ANALYZE returns the executed plan as rows of text
assert!(!result.is_empty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! EQL v3 variant of the explicit-function test in
//! `select/jsonb_containment_index.rs`.
//!
//! v2 exposes SteVec containment both as the `@>` operator and as an explicit
//! `eql_v2.jsonb_contains()` function. v3 has no `jsonb_contains` function:
//! containment is expressed only through the `@>` / `<@` operators on
//! `eql_v3.json`, with `eql_v3.jsonb_query` needles (see
//! `eql_v3.to_ste_vec_query` for the functional GIN index expression).
//!
//! The operator-shaped containment tests in `select/jsonb_containment_index.rs`
//! ride on the mapper and the fixture, and are not duplicated here.

#[cfg(test)]
mod tests {
use crate::common::{clear, connect_with_tls, random_id, trace, PROXY};
use serde_json::json;

/// v2: `WHERE eql_v2.jsonb_contains(encrypted_jsonb, $1)`.
/// v3: `WHERE encrypted_jsonb @> $1` - there is no function-call form.
#[tokio::test]
#[ignore = "blocked on eql-mapper v3"]
async fn jsonb_containment_operator_replaces_jsonb_contains_function() {
trace();

clear().await;

let client = connect_with_tls(PROXY).await;

// 20 rows of {"string": "value_N", "number": n} with N = n % 10,
// giving exactly 2 rows per "value_N".
let mut ids = Vec::with_capacity(20);
for n in 1..=20_i64 {
let id = random_id();
ids.push(id);
let encrypted_jsonb = json!({
"string": format!("value_{}", n % 10),
"number": n,
});

let sql = "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)";
client.query(sql, &[&id, &encrypted_jsonb]).await.unwrap();
}

// Scoped to the inserted ids so the test stays parallel-safe on the
// shared table (mirrors the id-range scoping in the v2 test).
let search_value = json!({"string": "value_1"});
let sql = "SELECT COUNT(*) FROM encrypted WHERE encrypted_jsonb @> $1 AND id = ANY($2)";

let rows = client.query(sql, &[&search_value, &ids]).await.unwrap();
let count: i64 = rows[0].get(0);

assert_eq!(count, 2);
}
}
42 changes: 42 additions & 0 deletions packages/cipherstash-proxy-integration/src/eql_v3/match_index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! EQL v3 replacement surface for the v2 match-index tests.
//!
//! v2's `map_match_index.rs` exercises `LIKE` over the match index. EQL v3
//! does NOT support `LIKE`/`ILIKE`: the match index (bloom filter, `bf` term)
//! only supports containment, via `@>` / `<@` (`eql_v3.contains`). There is
//! deliberately no v3 port of the LIKE/ILIKE tests; this placeholder documents
//! and exercises the replacement operator surface.

#[cfg(test)]
mod tests {
use crate::common::{clear, connect_with_tls, random_id, trace, PROXY};

/// v2: `WHERE encrypted_text LIKE $1` (match index).
/// v3: `WHERE encrypted_text @> $1` - bloom containment on the `bf` term
/// of `eql_v3.text_search` / `eql_v3.text_match`.
#[tokio::test]
#[ignore = "blocked on eql-mapper v3"]
async fn match_index_bloom_containment_replaces_like() {
trace();

clear().await;

let client = connect_with_tls(PROXY).await;

let id = random_id();
let encrypted_text = "hello@cipherstash.com";

let sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)";
client.query(sql, &[&id, &encrypted_text]).await.unwrap();

// Scoped by id so the test stays parallel-safe on the shared table
let sql = "SELECT id, encrypted_text FROM encrypted WHERE encrypted_text @> $1 AND id = $2";
let rows = client.query(sql, &[&"hello@", &id]).await.unwrap();

assert_eq!(rows.len(), 1);

for row in rows {
let result: String = row.get("encrypted_text");
assert_eq!(encrypted_text, result);
}
}
}
31 changes: 31 additions & 0 deletions packages/cipherstash-proxy-integration/src/eql_v3/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! EQL v3 test variants.
//!
//! GATED: every test in this module is `#[ignore = "blocked on eql-mapper v3"]`
//! because the proxy's eql-mapper cannot speak EQL v3 yet (that redesign is
//! out of scope here). The tests exist so the v3 SQL surface is documented and
//! compile-checked, and so they can be switched on when the mapper lands.
//!
//! Running them requires the EQL v3 fixture in place of the default v2 fixture:
//!
//! ```shell
//! CS_EQL_V3_PATH=../encrypt-query-language/release mise run postgres:setup:v3
//! cargo nextest run -p cipherstash-proxy-integration -E 'test(eql_v3)' --run-ignored all
//! mise run postgres:setup # restore the v2 fixture afterwards
//! ```
//!
//! The bulk of the integration suite is intentionally NOT duplicated here: it
//! rides on the fixture and the mapper, and will be enabled wholesale by the
//! eql-mapper v3 project. These modules cover only the payload/SQL-surface
//! coupled minority whose shape changes between v2 and v3:
//!
//! * `disable_mapping` - raw column values are v3 envelopes (`v: 3`, no `k`)
//! * `indexing` - on-column operator class -> functional term index
//! * `jsonb_containment` - `eql_v2.jsonb_contains()` -> `@>` on `eql_v3.json`
//! * `match_index` - LIKE/ILIKE -> bloom containment (`@>` / `<@`)
//! * `regression_cast` - `::eql_v2_encrypted` -> per-domain casts

mod disable_mapping;
mod indexing;
mod jsonb_containment;
mod match_index;
mod regression_cast;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! EQL v3 variant of the direct-insert cast surface in `eql_regression.rs`.
//!
//! The v2 regression harness reinserts captured ciphertexts with a single
//! `$2::eql_v2_encrypted` cast. v3 has no single encrypted type: direct
//! inserts cast to the column's specific domain (here `eql_v3.text_search`),
//! and the fail-closed domain CHECK validates the envelope on the way in.
//!
//! The full fixture-file regression flow (generate on main, replay on branch)
//! stays v2-only until the mapper speaks v3 and v3 fixtures can be generated.

#[cfg(test)]
mod tests {
use crate::common::{clear, connect_with_tls, get_database_port, random_id, trace, PROXY};

///
/// Captures a proxy-encrypted v3 ciphertext, reinserts it directly with a
/// per-domain cast, and decrypts it back through the proxy.
///
#[tokio::test]
#[ignore = "blocked on eql-mapper v3"]
async fn insert_v3_ciphertext_directly_and_decrypt_via_proxy() {
trace();

clear().await;

let id = random_id();
let plaintext = "regression".to_string();

// Insert via proxy (encrypts to a v3 envelope)
let proxy_client = connect_with_tls(PROXY).await;
let sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)";
proxy_client.query(sql, &[&id, &plaintext]).await.unwrap();

// Read the raw ciphertext directly from the database (bypassing proxy)
let db_client = connect_with_tls(get_database_port()).await;
let sql = "SELECT encrypted_text::text FROM encrypted WHERE id = $1";
let rows = db_client.query(sql, &[&id]).await.unwrap();
let ciphertext: String = rows[0].get(0);

// Reinsert the ciphertext directly, casting to the column's v3 domain
// (v2 used a single `::eql_v2_encrypted` cast here). The leading
// `::text` keeps the bound parameter described as text - a bare
// `$2::eql_v3.text_search` makes Postgres describe the parameter as
// the jsonb-backed domain, which a text binding cannot satisfy.
let new_id = random_id();
let sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2::text::jsonb::eql_v3.text_search)";
db_client.query(sql, &[&new_id, &ciphertext]).await.unwrap();

// Decrypt via proxy
let sql = "SELECT encrypted_text FROM encrypted WHERE id = $1";
let rows = proxy_client.query(sql, &[&new_id]).await.unwrap();
let decrypted: String = rows[0].get(0);

assert_eq!(plaintext, decrypted);
}
}
1 change: 1 addition & 0 deletions packages/cipherstash-proxy-integration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod disable_mapping;
mod empty_result;
mod encryption_sanity;
mod eql_regression;
mod eql_v3;
mod extended_protocol_error_messages;
mod insert;
mod map_concat;
Expand Down
17 changes: 17 additions & 0 deletions tests/benchmark/mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@ cat sql/benchmark-schema.sql | docker exec -i postgres${CONTAINER_SUFFIX} psql p
docker compose run --rm postgres${CONTAINER_SUFFIX:-} pgbench --host=host.docker.internal --port=${CS_DATABASE__PORT} --scale=1 -i --no-vacuum
"""

# GATED: EQL v3 variant of benchmark:setup. Opt-in while the eql-mapper cannot
# speak v3. Requires EQL v3 to be installed first:
# CS_EQL_V3_PATH=../encrypt-query-language/release mise run postgres:eql:v3:setup
# The pgbench transaction scripts are version-agnostic and shared with v2.
#
# Re-run this task after any postgres:eql:v3:setup/teardown: the CASCADE
# uninstall silently drops eql_v3-typed columns from tables it doesn't
# recreate (benchmark_encrypted is not recreated by postgres:setup:v3).
[tasks."benchmark:setup:v3"]
description = "Apply the EQL v3 benchmark schema (requires postgres:eql:v3:setup)"
run = """
cat sql/benchmark-schema-v3.sql | docker exec -i postgres${CONTAINER_SUFFIX} psql -v ON_ERROR_STOP=1 postgresql://${CS_DATABASE__USERNAME}:${CS_DATABASE__PASSWORD_ESCAPED_FOR_TESTS}@${CS_DATABASE__HOST}:${CS_DATABASE__PORT}/${CS_DATABASE__NAME} -f-

# Initialize pgbench
docker compose run --rm postgres${CONTAINER_SUFFIX:-} pgbench --host=host.docker.internal --port=${CS_DATABASE__PORT} --scale=1 -i --no-vacuum
"""

[tasks."benchmark:plot"]
alias = 'b'
description = "Plot graphs from benchmark results"
Expand Down
Loading