diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9d4ac787..6bbbc08f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,7 @@ jobs: echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> ./packages/protect/.env echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" >> ./packages/protect/.env + echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> ./packages/protect/.env echo "CS_ZEROKMS_HOST=https://ap-southeast-2.aws.zerokms.cipherstashmanaged.net" >> ./packages/protect/.env echo "CS_CTS_HOST=https://ap-southeast-2.aws.cts.cipherstashmanaged.net" >> ./packages/protect/.env diff --git a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts b/packages/protect/__tests__/encrypt-query-searchable-json.test.ts index dd5fc6b4..a8c05f85 100644 --- a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts +++ b/packages/protect/__tests__/encrypt-query-searchable-json.test.ts @@ -366,6 +366,79 @@ describe('searchableJson with returnType formatting', () => { // Format: "(\"json\")" - outer quotes with escaped inner quotes expect(data[0]).toMatch(/^"\(.*\)"$/) }, 30000) + + describe('single-value returnType', () => { + it('returns composite-literal for selector', async () => { + const result = await protectClient.encryptQuery('$.user.email', { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'searchableJson', + returnType: 'composite-literal', + }) + + const data = unwrapResult(result) + expect(typeof data).toBe('string') + expect(data).toMatch(/^\(".*"\)$/) + }, 30000) + + it('returns composite-literal for term', async () => { + const result = await protectClient.encryptQuery({ role: 'admin' }, { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'searchableJson', + returnType: 'composite-literal', + }) + + const data = unwrapResult(result) + expect(typeof data).toBe('string') + expect(data).toMatch(/^\(".*"\)$/) + }, 30000) + + it('returns escaped-composite-literal for selector', async () => { + const result = await protectClient.encryptQuery('$.user.email', { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'searchableJson', + returnType: 'escaped-composite-literal', + }) + + const data = unwrapResult(result) + expect(typeof data).toBe('string') + expect(data as string).toMatch(/^"\(.*\)"$/) + // JSON.parse should yield the composite-literal format + const parsed = JSON.parse(data as string) + expect(parsed).toMatch(/^\(.*\)$/) + }, 30000) + + it('returns escaped-composite-literal for term', async () => { + const result = await protectClient.encryptQuery({ role: 'admin' }, { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'searchableJson', + returnType: 'escaped-composite-literal', + }) + + const data = unwrapResult(result) + expect(typeof data).toBe('string') + expect(data as string).toMatch(/^"\(.*\)"$/) + const parsed = JSON.parse(data as string) + expect(parsed).toMatch(/^\(.*\)$/) + }, 30000) + + it('returns Encrypted object when returnType is omitted', async () => { + const result = await protectClient.encryptQuery('$.user.email', { + column: jsonbSchema.metadata, + table: jsonbSchema, + queryType: 'searchableJson', + }) + + const data = unwrapResult(result) as any + expect(typeof data).toBe('object') + expect(data).toHaveProperty('i') + expect(data.i).toHaveProperty('t') + expect(data.i).toHaveProperty('c') + }, 30000) + }) }) describe('searchableJson with LockContext', () => { diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts index 0a76b354..7e0edba8 100644 --- a/packages/protect/__tests__/encrypt-query.test.ts +++ b/packages/protect/__tests__/encrypt-query.test.ts @@ -561,6 +561,84 @@ describe('encryptQuery', () => { }, 30000) }) + describe('single-value returnType formatting', () => { + it('returns Encrypted by default (no returnType)', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + + const data = unwrapResult(result) + + expect(data).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(typeof data).toBe('object') + }, 30000) + + it('returns composite-literal format when specified', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + returnType: 'composite-literal', + }) + + const data = unwrapResult(result) + + expect(typeof data).toBe('string') + // Format: ("json") + expect(data).toMatch(/^\(".*"\)$/) + }, 30000) + + it('returns escaped-composite-literal format when specified', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + returnType: 'escaped-composite-literal', + }) + + const data = unwrapResult(result) + + expect(typeof data).toBe('string') + // Format: "(\"json\")" - outer quotes with escaped inner quotes + expect(data).toMatch(/^"\(.*\)"$/) + }, 30000) + + it('returns eql format when explicitly specified', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + returnType: 'eql', + }) + + const data = unwrapResult(result) + + expect(data).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(typeof data).toBe('object') + }, 30000) + + it('handles null value with composite-literal returnType', async () => { + const result = await protectClient.encryptQuery(null, { + column: users.email, + table: users, + queryType: 'equality', + returnType: 'composite-literal', + }) + + const data = unwrapResult(result) + + expect(data).toBeNull() + }, 30000) + }) + describe('LockContext support', () => { it('single query with LockContext calls getLockContext', async () => { const mockLockContext = createMockLockContext() diff --git a/packages/protect/__tests__/searchable-json-pg.test.ts b/packages/protect/__tests__/searchable-json-pg.test.ts new file mode 100644 index 00000000..66b93f41 --- /dev/null +++ b/packages/protect/__tests__/searchable-json-pg.test.ts @@ -0,0 +1,2392 @@ +import 'dotenv/config' +import { csColumn, csTable } from '@cipherstash/schema' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { LockContext, protect } from '../src' + +if (!process.env.DATABASE_URL) { + throw new Error('Missing env.DATABASE_URL') +} + +// Disable prepared statements — required for pooled connections (PgBouncer in transaction mode) +const sql = postgres(process.env.DATABASE_URL, { prepare: false }) + +const table = csTable('protect-ci-jsonb', { + metadata: csColumn('metadata').searchableJson(), +}) + +const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const userJwt = process.env.USER_JWT + +type ProtectClient = Awaited> +let protectClient: ProtectClient + +// ─── Helpers ───────────────────────────────────────────────────────── + +async function insertRow(plaintext: any) { + const encrypted = await protectClient.encryptModel( + { metadata: plaintext }, + table, + ) + if (encrypted.failure) throw new Error(encrypted.failure.message) + + const [inserted] = await sql` + INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) + VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) + RETURNING id + ` + return { id: inserted.id, encrypted } +} + +async function verifyRow(row: any, expected: any) { + expect(row).toBeDefined() + const decrypted = await protectClient.decryptModel({ metadata: row.metadata }) + if (decrypted.failure) throw new Error(decrypted.failure.message) + expect(decrypted.data.metadata).toEqual(expected) +} + +async function encryptQueryTerm( + value: any, + queryType: 'steVecSelector' | 'steVecTerm' | 'searchableJson', + returnType: + | 'composite-literal' + | 'escaped-composite-literal' = 'composite-literal', +) { + const result = await protectClient.encryptQuery(value, { + column: table.metadata, + table: table, + queryType, + returnType, + }) + if (result.failure) throw new Error(result.failure.message) + return result.data +} + +beforeAll(async () => { + protectClient = await protect({ schemas: [table] }) + + await sql` + CREATE TABLE IF NOT EXISTS "protect-ci-jsonb" ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + metadata eql_v2_encrypted, + test_run_id TEXT + ) + ` +}, 30000) + +afterAll(async () => { + await sql`DELETE FROM "protect-ci-jsonb" WHERE test_run_id = ${TEST_RUN_ID}` + await sql.end() +}, 30000) + +describe('searchableJson postgres integration', () => { + // ─── Storage: encrypt → insert → select → decrypt ────────────────── + + describe('storage: encrypt → insert → select → decrypt', () => { + it('round-trips a flat JSON object', async () => { + const plaintext = { user: { email: 'flat-rt@test.com' }, role: 'admin' } + const { id } = await insertRow(plaintext) + + const rows = await sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" + WHERE id = ${id} + ` + + expect(rows).toHaveLength(1) + await verifyRow(rows[0], plaintext) + }, 30000) + + it('round-trips nested JSON with arrays', async () => { + const plaintext = { + user: { + profile: { role: 'admin', permissions: ['read', 'write'] }, + tags: [{ name: 'vip' }, { name: 'beta' }], + }, + items: [{ id: 1, name: 'widget' }], + } + const { id } = await insertRow(plaintext) + + const rows = await sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" + WHERE id = ${id} + ` + + expect(rows).toHaveLength(1) + await verifyRow(rows[0], plaintext) + }, 30000) + + it('round-trips null values', async () => { + const encrypted = await protectClient.encrypt(null, { + column: table.metadata, + table: table, + }) + + if (encrypted.failure) throw new Error(encrypted.failure.message) + expect(encrypted.data).toBeNull() + + const [inserted] = await sql` + INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) + VALUES (NULL, ${TEST_RUN_ID}) + RETURNING id + ` + + const rows = await sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" + WHERE id = ${inserted.id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].metadata).toBeNull() + }, 30000) + }) + + // ─── jsonb_path_query: path-based selector queries ───────────────── + + describe('jsonb_path_query: path-based selector queries', () => { + it('finds row by simple top-level path ($.role)', async () => { + const plaintext = { role: 'path-toplevel-test', extra: 'data' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('finds row by nested path ($.user.email)', async () => { + const plaintext = { + user: { email: 'nested-path@test.com' }, + type: 'nested-path', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('finds row by deeply nested path ($.a.b.c)', async () => { + const plaintext = { a: { b: { c: 'deep-value' } }, marker: 'deep-path' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.a.b.c', 'steVecSelector') + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching path returns zero rows', async () => { + // Insert a doc that does NOT have $.nonexistent.path + const plaintext = { exists: true, marker: 'no-match-test' } + await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent.path', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + // No row should have this path + expect(rows.length).toBe(0) + }, 30000) + + it('multiple docs — only matching doc returned', async () => { + // Insert two docs: one with $.target.value, one without + const plaintextWithPath = { + target: { value: 'found-it' }, + marker: 'has-target', + } + const plaintextWithoutPath = { + other: { key: 'nope' }, + marker: 'no-target', + } + + const { id: idWith } = await insertRow(plaintextWithPath) + const { id: idWithout } = await insertRow(plaintextWithoutPath) + + const selectorTerm = await encryptQueryTerm( + '$.target.value', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + // The doc with $.target.value should be found + const matchingRow = rows.find((r) => r.id === idWith) + expect(matchingRow).toBeDefined() + + // The doc without $.target.value should NOT be found + const nonMatchingRow = rows.find((r) => r.id === idWithout) + expect(nonMatchingRow).toBeUndefined() + + // Decrypt and verify the matching row + await verifyRow(matchingRow!, plaintextWithPath) + }, 30000) + + it('finds row by simple top-level path (Simple)', async () => { + const plaintext = { role: 'path-tl-simple', extra: 'data' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result + WHERE t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('finds row by nested path (Simple)', async () => { + const plaintext = { + user: { email: 'nested-simple@test.com' }, + type: 'nested-path-simple', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result + WHERE t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('finds with deep nested path (Simple)', async () => { + const plaintext = { + target: { nested: { value: 'deep-simple' } }, + marker: 'jpq-deep-simple', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.target.nested.value', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result + WHERE t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching path returns zero rows (Simple)', async () => { + const plaintext = { data: true, marker: 'jpq-nomatch-simple' } + await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.missing.path', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result + WHERE t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBe(0) + }, 30000) + }) + + // ─── Containment: @> term queries ────────────────────────────────── + + describe('containment: @> term queries', () => { + it('matches by key/value pair', async () => { + const plaintext = { role: 'admin-containment', department: 'engineering' } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { role: 'admin-containment' }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('matches by nested object structure', async () => { + const plaintext = { + user: { profile: { role: 'superadmin' } }, + active: true, + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { user: { profile: { role: 'superadmin' } } }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching term returns zero rows', async () => { + const plaintext = { status: 'active', tier: 'free' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { status: 'nonexistent-value-xyz' }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBe(0) + }, 30000) + }) + + // ─── Mixed and batch operations ──────────────────────────────────── + + describe('mixed and batch operations', () => { + it('batch encrypts selector + containment terms together', async () => { + const plaintext = { + user: { email: 'batch@test.com' }, + role: 'editor', + kind: 'batch-mixed', + } + const { id } = await insertRow(plaintext) + + const queryResult = await protectClient.encryptQuery([ + { + value: '$.user.email', + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }, + { + value: { role: 'editor' }, + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'composite-literal', + }, + ]) + + if (queryResult.failure) throw new Error(queryResult.failure.message) + const [selectorTerm, containmentTerm] = queryResult.data + + // Selector query: jsonb_path_query + const selectorRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(selectorRows.length).toBeGreaterThanOrEqual(1) + const selectorMatch = selectorRows.find((r) => r.id === id) + expect(selectorMatch).toBeDefined() + await verifyRow(selectorMatch!, plaintext) + + // Containment query: @> + const containmentRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${containmentTerm}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(containmentRows.length).toBeGreaterThanOrEqual(1) + const containmentMatch = containmentRows.find((r) => r.id === id) + expect(containmentMatch).toBeDefined() + await verifyRow(containmentMatch!, plaintext) + }, 30000) + + it('inferred vs explicit queryType produce same results', async () => { + const plaintext = { category: 'equivalence-test', priority: 'high' } + const { id } = await insertRow(plaintext) + + // Selector: inferred (searchableJson) vs explicit (steVecSelector) + const inferredSelectorTerm = await encryptQueryTerm( + '$.category', + 'searchableJson', + ) + const explicitSelectorTerm = await encryptQueryTerm( + '$.category', + 'steVecSelector', + ) + + const inferredRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${inferredSelectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + const explicitRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${explicitSelectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(inferredRows.length).toBe(explicitRows.length) + expect(inferredRows.length).toBeGreaterThanOrEqual(1) + + // Both should find our inserted row + const inferredMatch = inferredRows.find((r) => r.id === id) + const explicitMatch = explicitRows.find((r) => r.id === id) + expect(inferredMatch).toBeDefined() + expect(explicitMatch).toBeDefined() + + // Decrypt and compare — both should yield identical plaintext + const inferredDecrypted = await protectClient.decryptModel({ + metadata: inferredMatch!.metadata, + }) + const explicitDecrypted = await protectClient.decryptModel({ + metadata: explicitMatch!.metadata, + }) + if (inferredDecrypted.failure) + throw new Error(inferredDecrypted.failure.message) + if (explicitDecrypted.failure) + throw new Error(explicitDecrypted.failure.message) + + expect(inferredDecrypted.data.metadata).toEqual( + explicitDecrypted.data.metadata, + ) + expect(inferredDecrypted.data.metadata).toEqual(plaintext) + + // Containment: inferred (searchableJson) vs explicit (steVecTerm) + const inferredContainmentTerm = await encryptQueryTerm( + { category: 'equivalence-test' }, + 'searchableJson', + ) + const explicitContainmentTerm = await encryptQueryTerm( + { category: 'equivalence-test' }, + 'steVecTerm', + ) + + const inferredTermRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${inferredContainmentTerm}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + const explicitTermRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${explicitContainmentTerm}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(inferredTermRows.length).toBe(explicitTermRows.length) + expect(inferredTermRows.length).toBeGreaterThanOrEqual(1) + + const inferredTermMatch = inferredTermRows.find((r) => r.id === id) + const explicitTermMatch = explicitTermRows.find((r) => r.id === id) + expect(inferredTermMatch).toBeDefined() + expect(explicitTermMatch).toBeDefined() + + const inferredTermDecrypted = await protectClient.decryptModel({ + metadata: inferredTermMatch!.metadata, + }) + const explicitTermDecrypted = await protectClient.decryptModel({ + metadata: explicitTermMatch!.metadata, + }) + if (inferredTermDecrypted.failure) + throw new Error(inferredTermDecrypted.failure.message) + if (explicitTermDecrypted.failure) + throw new Error(explicitTermDecrypted.failure.message) + + expect(inferredTermDecrypted.data.metadata).toEqual( + explicitTermDecrypted.data.metadata, + ) + expect(inferredTermDecrypted.data.metadata).toEqual(plaintext) + }, 30000) + }) + + // ─── Escaped-composite-literal format ───────────────────────────── + + describe('escaped-composite-literal format', () => { + it('escaped selector → unwrap → query PG', async () => { + const plaintext = { + user: { email: 'escaped-sel@test.com' }, + marker: 'escaped-selector', + } + const { id } = await insertRow(plaintext) + + // Encrypt with both formats + const compositeData = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + 'composite-literal', + ) + const escapedData = (await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + 'escaped-composite-literal', + )) as string + + // Verify escaped format and unwrap + expect(typeof escapedData).toBe('string') + expect(escapedData).toMatch(/^"\(.*\)"$/) + const unwrapped = JSON.parse(escapedData) + + expect(unwrapped).toBe(compositeData) + + // Use composite-literal form to query PG + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${compositeData}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('escaped containment → unwrap → query PG', async () => { + const plaintext = { + role: 'escaped-containment-test', + department: 'security', + } + const { id } = await insertRow(plaintext) + + const escapedData = (await encryptQueryTerm( + { role: 'escaped-containment-test' }, + 'steVecTerm', + 'escaped-composite-literal', + )) as string + + // Verify escaped format and unwrap + expect(typeof escapedData).toBe('string') + expect(escapedData).toMatch(/^"\(.*\)"$/) + const unwrapped = JSON.parse(escapedData) + + // Unwrapped escaped format should be a valid composite-literal + expect(typeof unwrapped).toBe('string') + expect(unwrapped).toMatch(/^\(.*\)$/) + + // Use unwrapped composite-literal form to query PG + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${unwrapped}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('batch escaped format', async () => { + const plaintext = { + user: { email: 'batch-escaped@test.com' }, + role: 'batch-escaped-role', + marker: 'batch-escaped', + } + const { id } = await insertRow(plaintext) + + const queryResult = await protectClient.encryptQuery([ + { + value: '$.user.email', + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'escaped-composite-literal', + }, + { + value: { role: 'batch-escaped-role' }, + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'escaped-composite-literal', + }, + ]) + if (queryResult.failure) throw new Error(queryResult.failure.message) + + expect(queryResult.data).toHaveLength(2) + for (const item of queryResult.data) { + expect(typeof item).toBe('string') + expect(item).toMatch(/^"\(.*\)"$/) + } + + // Unwrap escaped format + const selectorUnwrapped = JSON.parse(queryResult.data[0] as string) + const containmentUnwrapped = JSON.parse(queryResult.data[1] as string) + + // Selector query + const selectorRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorUnwrapped}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(selectorRows.length).toBeGreaterThanOrEqual(1) + const selectorMatch = selectorRows.find((r) => r.id === id) + expect(selectorMatch).toBeDefined() + await verifyRow(selectorMatch!, plaintext) + + // Containment query + const containmentRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${containmentUnwrapped}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(containmentRows.length).toBeGreaterThanOrEqual(1) + const containmentMatch = containmentRows.find((r) => r.id === id) + expect(containmentMatch).toBeDefined() + await verifyRow(containmentMatch!, plaintext) + }, 30000) + }) + + // ─── LockContext integration ────────────────────────────────────── + + describe.skipIf(!userJwt)('LockContext integration', () => { + it('selector with LockContext', async () => { + const lc = new LockContext() + const lockContext = await lc.identify(userJwt!) + if (lockContext.failure) throw new Error(lockContext.failure.message) + + const plaintext = { + user: { email: 'lc-selector@test.com' }, + marker: 'lock-context-selector', + } + + const encrypted = await protectClient + .encryptModel({ metadata: plaintext }, table) + .withLockContext(lockContext.data) + if (encrypted.failure) throw new Error(encrypted.failure.message) + + const [inserted] = await sql` + INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) + VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) + RETURNING id + ` + + const selectorResult = await protectClient + .encryptQuery('$.user.email', { + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }) + .withLockContext(lockContext.data) + .execute() + if (selectorResult.failure) + throw new Error(selectorResult.failure.message) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorResult.data}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === inserted.id) + expect(matchingRow).toBeDefined() + + const decrypted = await protectClient + .decryptModel({ metadata: matchingRow!.metadata }) + .withLockContext(lockContext.data) + if (decrypted.failure) throw new Error(decrypted.failure.message) + expect(decrypted.data.metadata).toEqual(plaintext) + }, 60000) + + it('containment with LockContext', async () => { + const lc = new LockContext() + const lockContext = await lc.identify(userJwt!) + if (lockContext.failure) throw new Error(lockContext.failure.message) + + const plaintext = { role: 'lc-containment-test', department: 'auth' } + + const encrypted = await protectClient + .encryptModel({ metadata: plaintext }, table) + .withLockContext(lockContext.data) + if (encrypted.failure) throw new Error(encrypted.failure.message) + + const [inserted] = await sql` + INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) + VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) + RETURNING id + ` + + const containmentResult = await protectClient + .encryptQuery( + { role: 'lc-containment-test' }, + { + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'composite-literal', + }, + ) + .withLockContext(lockContext.data) + .execute() + if (containmentResult.failure) + throw new Error(containmentResult.failure.message) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${containmentResult.data}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === inserted.id) + expect(matchingRow).toBeDefined() + + const decrypted = await protectClient + .decryptModel({ metadata: matchingRow!.metadata }) + .withLockContext(lockContext.data) + if (decrypted.failure) throw new Error(decrypted.failure.message) + expect(decrypted.data.metadata).toEqual(plaintext) + }, 60000) + + it('batch with LockContext', async () => { + const lc = new LockContext() + const lockContext = await lc.identify(userJwt!) + if (lockContext.failure) throw new Error(lockContext.failure.message) + + const plaintext = { + user: { email: 'lc-batch@test.com' }, + role: 'lc-batch-role', + kind: 'lock-context-batch', + } + + const encrypted = await protectClient + .encryptModel({ metadata: plaintext }, table) + .withLockContext(lockContext.data) + if (encrypted.failure) throw new Error(encrypted.failure.message) + + const [inserted] = await sql` + INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) + VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) + RETURNING id + ` + + const batchResult = await protectClient + .encryptQuery([ + { + value: '$.user.email', + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }, + { + value: { role: 'lc-batch-role' }, + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'composite-literal', + }, + ]) + .withLockContext(lockContext.data) + .execute() + if (batchResult.failure) throw new Error(batchResult.failure.message) + + const [selectorTerm, containmentTerm] = batchResult.data + + // Selector query + const selectorRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(selectorRows.length).toBeGreaterThanOrEqual(1) + const selectorMatch = selectorRows.find((r) => r.id === inserted.id) + expect(selectorMatch).toBeDefined() + + const selectorDecrypted = await protectClient + .decryptModel({ metadata: selectorMatch!.metadata }) + .withLockContext(lockContext.data) + if (selectorDecrypted.failure) + throw new Error(selectorDecrypted.failure.message) + expect(selectorDecrypted.data.metadata).toEqual(plaintext) + + // Containment query + const containmentRows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${containmentTerm}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(containmentRows.length).toBeGreaterThanOrEqual(1) + const containmentMatch = containmentRows.find((r) => r.id === inserted.id) + expect(containmentMatch).toBeDefined() + + const containmentDecrypted = await protectClient + .decryptModel({ metadata: containmentMatch!.metadata }) + .withLockContext(lockContext.data) + if (containmentDecrypted.failure) + throw new Error(containmentDecrypted.failure.message) + expect(containmentDecrypted.data.metadata).toEqual(plaintext) + }, 60000) + }) + + // ─── Concurrent query operations ───────────────────────────────── + + describe('concurrent query operations', () => { + it('parallel selector queries', async () => { + // Insert 3 docs with distinct structures + const docs = [ + { alpha: { key: 'concurrent-sel-1' }, marker: 'concurrent-1' }, + { beta: { key: 'concurrent-sel-2' }, marker: 'concurrent-2' }, + { gamma: { key: 'concurrent-sel-3' }, marker: 'concurrent-3' }, + ] + + const insertedIds: number[] = [] + for (const plaintext of docs) { + const { id } = await insertRow(plaintext) + insertedIds.push(id) + } + + // Parallel encrypt 3 selector queries + const [q1, q2, q3] = await Promise.all([ + protectClient.encryptQuery('$.alpha.key', { + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }), + protectClient.encryptQuery('$.beta.key', { + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }), + protectClient.encryptQuery('$.gamma.key', { + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }), + ]) + + if (q1.failure) throw new Error(q1.failure.message) + if (q2.failure) throw new Error(q2.failure.message) + if (q3.failure) throw new Error(q3.failure.message) + + // Execute each against PG + const [rows1, rows2, rows3] = await Promise.all([ + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${q1.data}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + `, + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${q2.data}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + `, + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${q3.data}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + `, + ]) + + // Each query should find its respective doc and not others + expect(rows1.find((r) => r.id === insertedIds[0])).toBeDefined() + expect(rows1.find((r) => r.id === insertedIds[1])).toBeUndefined() + expect(rows1.find((r) => r.id === insertedIds[2])).toBeUndefined() + expect(rows2.find((r) => r.id === insertedIds[1])).toBeDefined() + expect(rows2.find((r) => r.id === insertedIds[0])).toBeUndefined() + expect(rows2.find((r) => r.id === insertedIds[2])).toBeUndefined() + expect(rows3.find((r) => r.id === insertedIds[2])).toBeDefined() + expect(rows3.find((r) => r.id === insertedIds[0])).toBeUndefined() + expect(rows3.find((r) => r.id === insertedIds[1])).toBeUndefined() + + // Decrypt and validate each matched row + const match1 = rows1.find((r) => r.id === insertedIds[0])! + const decrypted1 = await protectClient.decryptModel({ + metadata: match1.metadata, + }) + if (decrypted1.failure) throw new Error(decrypted1.failure.message) + expect(decrypted1.data.metadata).toEqual(docs[0]) + + const match2 = rows2.find((r) => r.id === insertedIds[1])! + const decrypted2 = await protectClient.decryptModel({ + metadata: match2.metadata, + }) + if (decrypted2.failure) throw new Error(decrypted2.failure.message) + expect(decrypted2.data.metadata).toEqual(docs[1]) + + const match3 = rows3.find((r) => r.id === insertedIds[2])! + const decrypted3 = await protectClient.decryptModel({ + metadata: match3.metadata, + }) + if (decrypted3.failure) throw new Error(decrypted3.failure.message) + expect(decrypted3.data.metadata).toEqual(docs[2]) + }, 60000) + + it('parallel containment queries', async () => { + const docs = [ + { role: 'concurrent-contain-1', tier: 'gold' }, + { role: 'concurrent-contain-2', tier: 'silver' }, + ] + + const insertedIds: number[] = [] + for (const plaintext of docs) { + const { id } = await insertRow(plaintext) + insertedIds.push(id) + } + + // Parallel encrypt 2 containment queries + const [c1, c2] = await Promise.all([ + protectClient.encryptQuery( + { role: 'concurrent-contain-1' }, + { + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'composite-literal', + }, + ), + protectClient.encryptQuery( + { role: 'concurrent-contain-2' }, + { + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'composite-literal', + }, + ), + ]) + + if (c1.failure) throw new Error(c1.failure.message) + if (c2.failure) throw new Error(c2.failure.message) + + const [rows1, rows2] = await Promise.all([ + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" + WHERE metadata @> ${c1.data}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + `, + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" + WHERE metadata @> ${c2.data}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + `, + ]) + + // Each finds only its target doc + expect(rows1.find((r) => r.id === insertedIds[0])).toBeDefined() + expect(rows1.find((r) => r.id === insertedIds[1])).toBeUndefined() + expect(rows2.find((r) => r.id === insertedIds[1])).toBeDefined() + expect(rows2.find((r) => r.id === insertedIds[0])).toBeUndefined() + + // Decrypt and validate each matched row + const match1 = rows1.find((r) => r.id === insertedIds[0])! + const decrypted1 = await protectClient.decryptModel({ + metadata: match1.metadata, + }) + if (decrypted1.failure) throw new Error(decrypted1.failure.message) + expect(decrypted1.data.metadata).toEqual(docs[0]) + + const match2 = rows2.find((r) => r.id === insertedIds[1])! + const decrypted2 = await protectClient.decryptModel({ + metadata: match2.metadata, + }) + if (decrypted2.failure) throw new Error(decrypted2.failure.message) + expect(decrypted2.data.metadata).toEqual(docs[1]) + }, 60000) + + it('parallel mixed encrypt+query', async () => { + const plaintext = { + user: { email: 'concurrent-mixed@test.com' }, + role: 'concurrent-mixed-role', + kind: 'mixed-concurrent', + } + + // Parallel: encryptModel + selector encryptQuery + containment encryptQuery + const [encryptedModel, selectorResult, containmentResult] = + await Promise.all([ + protectClient.encryptModel({ metadata: plaintext }, table), + protectClient.encryptQuery('$.user.email', { + column: table.metadata, + table: table, + queryType: 'steVecSelector', + returnType: 'composite-literal', + }), + protectClient.encryptQuery( + { role: 'concurrent-mixed-role' }, + { + column: table.metadata, + table: table, + queryType: 'steVecTerm', + returnType: 'composite-literal', + }, + ), + ]) + + if (encryptedModel.failure) + throw new Error(encryptedModel.failure.message) + if (selectorResult.failure) + throw new Error(selectorResult.failure.message) + if (containmentResult.failure) + throw new Error(containmentResult.failure.message) + + // Insert the encrypted doc + const [inserted] = await sql` + INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) + VALUES (${sql.json(encryptedModel.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) + RETURNING id + ` + + // Query with both terms + const [selectorRows, containmentRows] = await Promise.all([ + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorResult.data}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + `, + sql` + SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" + WHERE metadata @> ${containmentResult.data}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + `, + ]) + + // Both should find the inserted row + expect(selectorRows.find((r) => r.id === inserted.id)).toBeDefined() + expect(containmentRows.find((r) => r.id === inserted.id)).toBeDefined() + // Verify result sets are bounded (not returning all rows) + expect(selectorRows.length).toBeGreaterThanOrEqual(1) + expect(containmentRows.length).toBeGreaterThanOrEqual(1) + + // Decrypt and validate both matched rows + const selectorMatch = selectorRows.find((r) => r.id === inserted.id)! + const selectorDecrypted = await protectClient.decryptModel({ + metadata: selectorMatch.metadata, + }) + if (selectorDecrypted.failure) + throw new Error(selectorDecrypted.failure.message) + expect(selectorDecrypted.data.metadata).toEqual(plaintext) + + const containmentMatch = containmentRows.find( + (r) => r.id === inserted.id, + )! + const containmentDecrypted = await protectClient.decryptModel({ + metadata: containmentMatch.metadata, + }) + if (containmentDecrypted.failure) + throw new Error(containmentDecrypted.failure.message) + expect(containmentDecrypted.data.metadata).toEqual(plaintext) + }, 60000) + }) + + // ─── Contained-by: <@ term queries ──────────────────────────────── + + describe('contained-by: <@ term queries', () => { + it('matches by key/value pair (Extended)', async () => { + const plaintext = { role: 'contained-by-kv', department: 'eng' } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { role: 'contained-by-kv' }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('matches by nested object (Extended)', async () => { + const plaintext = { + user: { profile: { role: 'contained-by-nested' } }, + active: true, + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { user: { profile: { role: 'contained-by-nested' } } }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching value returns zero rows (Extended)', async () => { + const plaintext = { status: 'active-cb', tier: 'free' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { status: 'nonexistent-cb-xyz' }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBe(0) + }, 30000) + + it('matches by key/value pair (Simple)', async () => { + const plaintext = { role: 'contained-by-kv-simple', department: 'ops' } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { role: 'contained-by-kv-simple' }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE $1::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('matches by nested object (Simple)', async () => { + const plaintext = { + user: { profile: { role: 'contained-by-nested-simple' } }, + active: true, + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { user: { profile: { role: 'contained-by-nested-simple' } } }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE $1::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching value returns zero rows (Simple)', async () => { + const plaintext = { status: 'active-cb-simple', tier: 'premium' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { status: 'nonexistent-cb-simple-xyz' }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE $1::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBe(0) + }, 30000) + }) + + // ─── jsonb_path_query_first: scalar path queries ────────────────── + + describe('jsonb_path_query_first: scalar path queries', () => { + it('finds row by string field (Extended)', async () => { + const plaintext = { role: 'qf-string', extra: 'data' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('finds row by nested path (Extended)', async () => { + const plaintext = { + user: { email: 'qf-nested@test.com' }, + type: 'qf-nested', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('returns no rows for unknown path (Extended)', async () => { + const plaintext = { exists: true, marker: 'qf-nomatch' } + await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent.path', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBe(0) + }, 30000) + + it('finds row by string field (Simple)', async () => { + const plaintext = { role: 'qf-string-simple', extra: 'data' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL + AND t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('finds row by nested path (Simple)', async () => { + const plaintext = { + user: { email: 'qf-nested-simple@test.com' }, + type: 'qf-nested-simple', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL + AND t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('returns no rows for unknown path (Simple)', async () => { + const plaintext = { exists: true, marker: 'qf-nomatch-simple' } + await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent.path', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL + AND t.test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBe(0) + }, 30000) + }) + + // ─── jsonb_path_exists: boolean path queries ────────────────────── + + describe('jsonb_path_exists: boolean path queries', () => { + it('returns true for existing field (Extended)', async () => { + const plaintext = { role: 'pe-exists', extra: 'data' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('returns true for nested path (Extended)', async () => { + const plaintext = { + user: { email: 'pe-nested@test.com' }, + type: 'pe-nested', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('returns false for unknown path (Extended)', async () => { + const plaintext = { exists: true, marker: 'pe-nomatch' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent.path', + 'steVecSelector', + ) + + const rows = await sql` + SELECT id, eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) as path_exists + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].path_exists).toBe(false) + }, 30000) + + it('returns true for existing field (Simple)', async () => { + const plaintext = { role: 'pe-exists-simple', extra: 'data' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) + AND test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('returns true for nested path (Simple)', async () => { + const plaintext = { + user: { email: 'pe-nested-simple@test.com' }, + type: 'pe-nested-simple', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) + AND test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('returns false for unknown path (Simple)', async () => { + const plaintext = { exists: true, marker: 'pe-nomatch-simple' } + await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent.path', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) + AND test_run_id = '${TEST_RUN_ID}'`, + ) + + expect(rows.length).toBe(0) + }, 30000) + }) + + describe('jsonb_array_elements + jsonb_array_length: array queries', () => { + it('returns null length for missing path (Extended)', async () => { + const plaintext = { exists: true, marker: 'al-nomatch' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent', + 'steVecSelector', + ) + + const rows = await sql` + SELECT t.id, + eql_v2.jsonb_array_length( + eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) + ) as arr_len + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].arr_len).toBeNull() + + const dataRows = await sql` + SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} + ` + expect(dataRows).toHaveLength(1) + await verifyRow(dataRows[0], plaintext) + }, 30000) + + // [@] notation (proxy convention) produces the selector hash matching is_array=true STE vec entries + it('[@] selector matches is_array=true entries in STE vec', async () => { + const plaintext = { colors: ['a', 'b'], marker: 'diag-sv' } + const { id } = await insertRow(plaintext) + + const entries = await sql` + SELECT + eql_v2.selector(e.entry::jsonb) as selector, + eql_v2.is_ste_vec_array(e.entry::jsonb) as is_array + FROM "protect-ci-jsonb" t, + LATERAL unnest(eql_v2.ste_vec((t.metadata).data)) WITH ORDINALITY AS e(entry, idx) + WHERE t.id = ${id} + ` + + const arrayEntries = entries.filter((e: any) => e.is_array === true) + expect(arrayEntries.length).toBeGreaterThan(0) + + const selectorAt = await encryptQueryTerm('$.colors[@]', 'steVecSelector') + const hashAt = + await sql`SELECT eql_v2.selector(${selectorAt}::eql_v2_encrypted) as s` + + expect(hashAt[0].s).toBe(arrayEntries[0].selector) + }, 30000) + + it('returns correct length for known array (Extended)', async () => { + const plaintext = { colors: ['a', 'b', 'c', 'd'], marker: 'al-known' } + const { id } = await insertRow(plaintext) + + // Use [@] notation — proxy convention for array element selector (is_array=true entries) + const selectorTerm = await encryptQueryTerm( + '$.colors[@]', + 'steVecSelector', + ) + + const rows = await sql` + SELECT t.id, + eql_v2.jsonb_array_length( + eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) + ) as arr_len + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].arr_len).toBe(4) + + const dataRows = await sql` + SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} + ` + expect(dataRows).toHaveLength(1) + await verifyRow(dataRows[0], plaintext) + }, 30000) + + it('returns correct length for known array (Simple)', async () => { + const plaintext = { colors: ['x', 'y', 'z'], marker: 'al-known-s' } + const { id } = await insertRow(plaintext) + + // Use [@] notation — proxy convention for array element selector (is_array=true entries) + const selectorTerm = await encryptQueryTerm( + '$.colors[@]', + 'steVecSelector', + ) + + const rows = await sql.unsafe( + `SELECT t.id, + eql_v2.jsonb_array_length( + eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted) + ) as arr_len + FROM "protect-ci-jsonb" t + WHERE t.id = $2`, + [selectorTerm, id], + ) + + expect(rows).toHaveLength(1) + expect(rows[0].arr_len).toBe(3) + + const dataRows = await sql.unsafe( + `SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = $1`, + [id], + ) + expect(dataRows).toHaveLength(1) + await verifyRow(dataRows[0], plaintext) + }, 30000) + + // EQL pattern: jsonb_array_elements(jsonb_path_query(...)) in SELECT clause, not FROM + it('expands array via jsonb_array_elements (Extended)', async () => { + const plaintext = { tags: ['ae-a', 'ae-b', 'ae-c'], marker: 'ae-expand' } + const { id } = await insertRow(plaintext) + + // Use [@] notation — proxy convention for array element selector (is_array=true entries) + const selectorTerm = await encryptQueryTerm('$.tags[@]', 'steVecSelector') + + const rows = await sql` + SELECT eql_v2.jsonb_array_elements( + eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) + ) as elem + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(3) + + const dataRows = await sql` + SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} + ` + expect(dataRows).toHaveLength(1) + await verifyRow(dataRows[0], plaintext) + }, 30000) + + it('expands array via jsonb_array_elements (Simple)', async () => { + const plaintext = { + tags: ['ae-s-a', 'ae-s-b', 'ae-s-c'], + marker: 'ae-expand-s', + } + const { id } = await insertRow(plaintext) + + // Use [@] notation — proxy convention for array element selector (is_array=true entries) + const selectorTerm = await encryptQueryTerm('$.tags[@]', 'steVecSelector') + + const rows = await sql.unsafe( + `SELECT eql_v2.jsonb_array_elements( + eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted) + ) as elem + FROM "protect-ci-jsonb" t + WHERE t.id = $2`, + [selectorTerm, id], + ) + + expect(rows).toHaveLength(3) + + const dataRows = await sql.unsafe( + `SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = $1`, + [id], + ) + expect(dataRows).toHaveLength(1) + await verifyRow(dataRows[0], plaintext) + }, 30000) + }) + + describe('containment: @> with array values', () => { + it('matches array subset (Extended)', async () => { + const plaintext = { + tags: ['ac-alpha', 'ac-beta', 'ac-gamma'], + marker: 'ac-subset', + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['ac-alpha'] }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching array value returns no rows (Extended)', async () => { + const plaintext = { tags: ['ac-exist'], marker: 'ac-nomatch' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['ac-nonexistent'] }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBe(0) + }, 30000) + + it('matches array subset (Simple)', async () => { + const plaintext = { + tags: ['ac-simple-x', 'ac-simple-y'], + marker: 'ac-simple', + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['ac-simple-x'] }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> $1::eql_v2_encrypted + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching array value returns no rows (Simple)', async () => { + const plaintext = { tags: ['ac-s-exist'], marker: 'ac-s-nomatch' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['ac-s-absent'] }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> $1::eql_v2_encrypted + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBe(0) + }, 30000) + + it('matches nested array subset (Extended)', async () => { + const plaintext = { + user: { roles: ['ac-nested-admin', 'ac-nested-editor'] }, + marker: 'ac-nested', + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { user: { roles: ['ac-nested-admin'] } }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + }) + + describe('contained-by: <@ with array values', () => { + it('matches array superset (Extended)', async () => { + const plaintext = { + tags: ['cb-one', 'cb-two', 'cb-three'], + marker: 'cb-superset', + } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['cb-one'] }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching array returns no rows (Extended)', async () => { + const plaintext = { tags: ['cb-exist'], marker: 'cb-nomatch' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['cb-absent'] }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBe(0) + }, 30000) + + it('matches array superset (Simple)', async () => { + const plaintext = { tags: ['cb-s-one', 'cb-s-two'], marker: 'cb-s-super' } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['cb-s-one'] }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE $1::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('non-matching array returns no rows (Simple)', async () => { + const plaintext = { tags: ['cb-s-exist'], marker: 'cb-s-nomatch' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { tags: ['cb-s-absent'] }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE $1::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBe(0) + }, 30000) + }) + + describe('storage: array round-trips (gaps only)', () => { + it('round-trips object with empty string array', async () => { + const plaintext = { tags: [], marker: 'rt-empty-string-arr' } + const { id } = await insertRow(plaintext) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + await verifyRow(rows[0], plaintext) + }, 30000) + + it('round-trips nested empty object array', async () => { + const plaintext = { data: { items: [] }, marker: 'rt-empty-obj-arr' } + const { id } = await insertRow(plaintext) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + await verifyRow(rows[0], plaintext) + }, 30000) + }) + + // ─── Containment: operand and protocol matrix ────────────────────── + + describe('containment: operand and protocol matrix', () => { + it('@> matches key/value (Simple)', async () => { + const plaintext = { role: 'cm-admin-s', dept: 'cm-eng-s' } + const { id } = await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { role: 'cm-admin-s' }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> $1::eql_v2_encrypted + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('@> non-matching returns no rows (Simple)', async () => { + const plaintext = { role: 'cm-exist-s' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { role: 'cm-nope-s' }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.metadata @> $1::eql_v2_encrypted + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBe(0) + }, 30000) + + it('term <@ column matches subset (Extended)', async () => { + const plaintext = { role: 'cm-sub', marker: 'cm-sub-marker' } + const { id } = await insertRow(plaintext) + + // Query term is a SUBSET of the stored data + const containmentTerm = await encryptQueryTerm( + { role: 'cm-sub' }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('term <@ column non-matching (Extended)', async () => { + const plaintext = { role: 'cm-sub-x' } + await insertRow(plaintext) + + const containmentTerm = await encryptQueryTerm( + { role: 'cm-sub-miss' }, + 'steVecTerm', + ) + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBe(0) + }, 30000) + + it('term <@ column matches subset (Simple)', async () => { + const plaintext = { role: 'cm-sub-s', marker: 'cm-sub-s-marker' } + const { id } = await insertRow(plaintext) + + // Query term is a SUBSET of the stored data + const containmentTerm = await encryptQueryTerm( + { role: 'cm-sub-s' }, + 'steVecTerm', + ) + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE $1::eql_v2_encrypted <@ t.metadata + AND t.test_run_id = $2`, + [containmentTerm, TEST_RUN_ID], + ) + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r: any) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + }) + + // ─── Field access: -> operator ───────────────────────────────────── + + describe('field access: -> operator', () => { + it('extracts field by encrypted selector (Extended)', async () => { + const plaintext = { + role: 'fa-enc', + dept: 'fa-dept', + marker: 'fa-enc-sel', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql` + SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].extracted).not.toBeNull() + + const fullRows = await sql` + SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} + ` + await verifyRow(fullRows[0], plaintext) + }, 30000) + + it('extracts field by encrypted selector (Simple)', async () => { + const plaintext = { + role: 'fa-enc-s', + dept: 'fa-dept-s', + marker: 'fa-enc-sel-s', + } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql.unsafe( + `SELECT t.metadata -> $1::eql_v2_encrypted as extracted + FROM "protect-ci-jsonb" t + WHERE t.id = $2`, + [selectorTerm, id], + ) + + expect(rows).toHaveLength(1) + expect(rows[0].extracted).not.toBeNull() + + const fullRows = await sql` + SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} + ` + await verifyRow(fullRows[0], plaintext) + }, 30000) + + it('returns null for non-existent field (Extended)', async () => { + const plaintext = { role: 'fa-null', marker: 'fa-null-marker' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm( + '$.nonexistent', + 'steVecSelector', + ) + + const rows = await sql` + SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].extracted).toBeNull() + }, 30000) + + it('extracted field can be round-tripped (Extended)', async () => { + const plaintext = { + role: 'fa-roundtrip', + dept: 'fa-rt-dept', + marker: 'fa-rt-marker', + } + const { id } = await insertRow(plaintext) + + // Extract the role field via -> operator + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql` + SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted, + (t.metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE t.id = ${id} + ` + + expect(rows).toHaveLength(1) + expect(rows[0].extracted).not.toBeNull() + + // Decrypt the full document and verify the extracted field matches + await verifyRow(rows[0], plaintext) + }, 30000) + }) + + // ─── WHERE comparison: = equality ────────────────────────────────── + + describe('WHERE comparison: = equality', () => { + it('jsonb_path_query_first = self-comparison (Extended)', async () => { + const plaintext = { role: 'eq-jpqf', marker: 'eq-jpqf-marker' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) + = eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) + AND t.id = ${id} + ` + + expect(rows).toHaveLength(1) + await verifyRow(rows[0], plaintext) + }, 30000) + + it('jsonb_path_query_first = self-comparison (Simple)', async () => { + const plaintext = { role: 'eq-jpqf-s', marker: 'eq-jpqf-s-marker' } + const { id } = await insertRow(plaintext) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + const rows = await sql.unsafe( + `SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t + WHERE eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted) + = eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted) + AND t.id = $2`, + [selectorTerm, id], + ) + + expect(rows).toHaveLength(1) + await verifyRow(rows[0], plaintext) + }, 30000) + + // Cross-document equality via = on jsonb_path_query_first results is not supported — + // the eql_v2 extension lacks a hash function for this operator. + it('equality across two documents rejects with missing hash function', async () => { + const doc1 = { role: 'eq-cross-same', dept: 'eq-cross-d1' } + const doc2 = { role: 'eq-cross-same', dept: 'eq-cross-d2' } + + const { id: id1 } = await insertRow(doc1) + const { id: id2 } = await insertRow(doc2) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + await expect( + sql` + SELECT a.id as id_a, b.id as id_b + FROM "protect-ci-jsonb" a, "protect-ci-jsonb" b + WHERE eql_v2.jsonb_path_query_first(a.metadata, ${selectorTerm}::eql_v2_encrypted) + = eql_v2.jsonb_path_query_first(b.metadata, ${selectorTerm}::eql_v2_encrypted) + AND a.id = ${id1} + AND b.id = ${id2} + `, + ).rejects.toThrow(/could not find hash function for hash operator/) + }, 30000) + + it('equality mismatch across two documents rejects with missing hash function', async () => { + const doc1 = { role: 'eq-cross-mismatch-1', marker: 'eq-mm-1' } + const doc2 = { role: 'eq-cross-mismatch-2', marker: 'eq-mm-2' } + + const { id: id1 } = await insertRow(doc1) + const { id: id2 } = await insertRow(doc2) + + const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') + + await expect( + sql` + SELECT a.id as id_a, b.id as id_b + FROM "protect-ci-jsonb" a, "protect-ci-jsonb" b + WHERE eql_v2.jsonb_path_query_first(a.metadata, ${selectorTerm}::eql_v2_encrypted) + = eql_v2.jsonb_path_query_first(b.metadata, ${selectorTerm}::eql_v2_encrypted) + AND a.id = ${id1} + AND b.id = ${id2} + `, + ).rejects.toThrow(/could not find hash function for hash operator/) + }, 30000) + }) + + // ─── eql (default) return type ────────────────────────────────────── + + describe('eql (default) return type', () => { + it('selector query using raw eql return type', async () => { + const plaintext = { + user: { email: 'eql-raw-sel@test.com' }, + marker: 'eql-raw-sel', + } + const { id } = await insertRow(plaintext) + + // Omit returnType — single-value encryptQuery returns raw Encrypted object + const queryResult = await protectClient.encryptQuery('$.user.email', { + column: table.metadata, + table: table, + queryType: 'steVecSelector', + }) + if (queryResult.failure) throw new Error(queryResult.failure.message) + const rawResult = queryResult.data + + // Must use sql.json() to pass raw Encrypted object to PG + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${sql.json(rawResult)}::eql_v2_encrypted) as result + WHERE t.test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + + it('containment query using raw eql return type', async () => { + const plaintext = { role: 'eql-raw-contain', marker: 'eql-raw-ct' } + const { id } = await insertRow(plaintext) + + // Omit returnType — single-value encryptQuery returns raw Encrypted object + const queryResult = await protectClient.encryptQuery( + { role: 'eql-raw-contain' }, + { + column: table.metadata, + table: table, + queryType: 'steVecTerm', + }, + ) + if (queryResult.failure) throw new Error(queryResult.failure.message) + const rawResult = queryResult.data + + // Must use sql.json() to pass raw Encrypted object to PG + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" + WHERE metadata @> ${sql.json(rawResult)}::eql_v2_encrypted + AND test_run_id = ${TEST_RUN_ID} + ` + + expect(rows.length).toBeGreaterThanOrEqual(1) + const matchingRow = rows.find((r) => r.id === id) + expect(matchingRow).toBeDefined() + await verifyRow(matchingRow!, plaintext) + }, 30000) + }) + + // ─── Concurrent encrypt + decrypt stress ──────────────────────────── + + describe('concurrent encrypt + decrypt stress', () => { + it('concurrent encrypt + decrypt stress (10 parallel)', async () => { + const docs = Array.from({ length: 10 }, (_, i) => ({ + user: { email: `stress-${i}@test.com` }, + role: `stress-role-${i}`, + index: i, + marker: `stress-${i}`, + })) + + // Insert all 10 docs + const insertedIds: number[] = [] + for (const plaintext of docs) { + const { id } = await insertRow(plaintext) + insertedIds.push(id) + } + + // 10 parallel encrypt-query-decrypt pipelines + const results = await Promise.all( + docs.map(async (plaintext, i) => { + // Encrypt a selector query + const selectorTerm = await encryptQueryTerm( + '$.user.email', + 'steVecSelector', + ) + + // Query PG + const rows = await sql` + SELECT id, (metadata).data as metadata + FROM "protect-ci-jsonb" t, + eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result + WHERE t.id = ${insertedIds[i]} + ` + + expect(rows).toHaveLength(1) + + // Decrypt + const decrypted = await protectClient.decryptModel({ + metadata: rows[0].metadata, + }) + if (decrypted.failure) throw new Error(decrypted.failure.message) + + return decrypted.data.metadata + }), + ) + + // Assert all 10 return correct plaintext + expect(results).toHaveLength(10) + results.forEach((result, i) => { + expect(result).toEqual(docs[i]) + }) + }, 120000) + }) +}) diff --git a/packages/protect/package.json b/packages/protect/package.json index 38cde34a..746ffb6c 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -58,6 +58,7 @@ "@supabase/supabase-js": "^2.47.10", "execa": "^9.5.2", "json-schema-to-typescript": "^15.0.2", + "postgres": "^3.4.7", "tsup": "catalog:repo", "tsx": "catalog:repo", "typescript": "catalog:repo", diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts index f0b8877b..377d1b8b 100644 --- a/packages/protect/src/ffi/operations/batch-encrypt-query.ts +++ b/packages/protect/src/ffi/operations/batch-encrypt-query.ts @@ -14,7 +14,7 @@ import { noClientError } from '../index' import { ProtectOperation } from './base-operation' import { resolveIndexType } from '../helpers/infer-index-type' import { assertValidNumericValue, assertValueIndexCompatibility } from '../helpers/validation' -import { encryptedToCompositeLiteral, encryptedToEscapedCompositeLiteral } from '../../helpers' +import { formatEncryptedResult } from '../../helpers' /** * Separates null/undefined values from non-null terms in the input array. @@ -95,13 +95,7 @@ function assembleResults( nonNullTerms.forEach(({ term, originalIndex }, i) => { const encrypted = encryptedValues[i] - if (term.returnType === 'composite-literal') { - results[originalIndex] = encryptedToCompositeLiteral(encrypted) - } else if (term.returnType === 'escaped-composite-literal') { - results[originalIndex] = encryptedToEscapedCompositeLiteral(encrypted) - } else { - results[originalIndex] = encrypted - } + results[originalIndex] = formatEncryptedResult(encrypted, term.returnType) }) return results diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts index c7c4afec..145d3220 100644 --- a/packages/protect/src/ffi/operations/encrypt-query.ts +++ b/packages/protect/src/ffi/operations/encrypt-query.ts @@ -4,10 +4,11 @@ import { encryptQuery as ffiEncryptQuery, } from '@cipherstash/protect-ffi' import { type ProtectError, ProtectErrorTypes } from '../..' +import { formatEncryptedResult } from '../../helpers' import { getErrorCode } from '../helpers/error-code' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' -import type { Client, Encrypted, EncryptQueryOptions } from '../../types' +import type { Client, EncryptedQueryResult, EncryptQueryOptions } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' import { resolveIndexType } from '../helpers/infer-index-type' @@ -16,7 +17,7 @@ import { validateNumericValue, assertValueIndexCompatibility } from '../helpers/ /** * @internal Use {@link ProtectClient.encryptQuery} instead. */ -export class EncryptQueryOperation extends ProtectOperation { +export class EncryptQueryOperation extends ProtectOperation { constructor( private client: Client, private plaintext: JsPlaintext | null, @@ -29,7 +30,7 @@ export class EncryptQueryOperation extends ProtectOperation { return new EncryptQueryOperationWithLockContext(this.client, this.plaintext, this.opts, lockContext, this.auditMetadata) } - public async execute(): Promise> { + public async execute(): Promise> { logger.debug('Encrypting query', { column: this.opts.column.getName(), table: this.opts.table.tableName, @@ -64,7 +65,7 @@ export class EncryptQueryOperation extends ProtectOperation { this.opts.column.getName() ) - return await ffiEncryptQuery(this.client, { + const encrypted = await ffiEncryptQuery(this.client, { plaintext: this.plaintext as JsPlaintext, column: this.opts.column.getName(), table: this.opts.table.tableName, @@ -72,6 +73,8 @@ export class EncryptQueryOperation extends ProtectOperation { queryOp, unverifiedContext: metadata, }) + + return formatEncryptedResult(encrypted, this.opts.returnType) }, (error: unknown) => ({ type: ProtectErrorTypes.EncryptionError, @@ -89,7 +92,7 @@ export class EncryptQueryOperation extends ProtectOperation { /** * @internal Use {@link ProtectClient.encryptQuery} with `.withLockContext()` instead. */ -export class EncryptQueryOperationWithLockContext extends ProtectOperation { +export class EncryptQueryOperationWithLockContext extends ProtectOperation { constructor( private client: Client, private plaintext: JsPlaintext | null, @@ -101,7 +104,7 @@ export class EncryptQueryOperationWithLockContext extends ProtectOperation> { + public async execute(): Promise> { if (this.plaintext === null || this.plaintext === undefined) { return { data: null } } @@ -137,7 +140,7 @@ export class EncryptQueryOperationWithLockContext extends ProtectOperation ({ type: ProtectErrorTypes.EncryptionError, diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts index e4d15862..0f457215 100644 --- a/packages/protect/src/helpers/index.ts +++ b/packages/protect/src/helpers/index.ts @@ -1,5 +1,5 @@ import type { Encrypted as CipherStashEncrypted, KeysetIdentifier as KeysetIdentifierFfi } from '@cipherstash/protect-ffi' -import type { Encrypted, KeysetIdentifier } from '../types' +import type { Encrypted, EncryptedQueryResult, KeysetIdentifier } from '../types' export type EncryptedPgComposite = { data: Encrypted @@ -70,6 +70,19 @@ export function encryptedToEscapedCompositeLiteral(obj: CipherStashEncrypted): s return JSON.stringify(encryptedToCompositeLiteral(obj)) } +export function formatEncryptedResult( + encrypted: CipherStashEncrypted, + returnType?: string, +): EncryptedQueryResult { + if (returnType === 'composite-literal') { + return encryptedToCompositeLiteral(encrypted) + } + if (returnType === 'escaped-composite-literal') { + return encryptedToEscapedCompositeLiteral(encrypted) + } + return encrypted +} + /** * Helper function to transform a model's encrypted fields into PostgreSQL composite types */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7998546d..67469a9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: json-schema-to-typescript: specifier: ^15.0.2 version: 15.0.4 + postgres: + specifier: ^3.4.7 + version: 3.4.7 tsup: specifier: catalog:repo version: 8.4.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.6.3)