diff --git a/packages/drizzle/__tests__/fixtures/jsonb-test-data.ts b/packages/drizzle/__tests__/fixtures/jsonb-test-data.ts new file mode 100644 index 00000000..2b0f543c --- /dev/null +++ b/packages/drizzle/__tests__/fixtures/jsonb-test-data.ts @@ -0,0 +1,153 @@ +/** + * JSONB Test Data Fixtures + * + * Shared test data matching the proxy test patterns for JSONB operations. + * These fixtures ensure consistency between Drizzle integration tests and + * the proxy reference tests. + */ + +/** + * Standard JSONB test data structure + * Matches the proxy test data: {"string": "hello", "number": 42, ...} + */ +export const standardJsonbData = { + string: 'hello', + number: 42, + array_string: ['hello', 'world'], + array_number: [42, 84], + nested: { + number: 1815, + string: 'world', + }, +} + +/** + * Type definition for standard JSONB data + */ +export type StandardJsonbData = typeof standardJsonbData + +/** + * Comparison test data (5 rows) + * Used for testing WHERE clause comparisons with equality and range operations + * Pattern: string A-E, number 1-5 + */ +export const comparisonTestData = [ + { string: 'A', number: 1 }, + { string: 'B', number: 2 }, + { string: 'C', number: 3 }, + { string: 'D', number: 4 }, + { string: 'E', number: 5 }, +] + +/** + * Type definition for comparison test data + */ +export type ComparisonTestData = (typeof comparisonTestData)[number] + +/** + * Large dataset generator for containment index tests + * Creates N rows following the proxy pattern: + * { id: 1000000 + n, string: "value_" + (n % 10), number: n % 10 } + * + * @param count - Number of records to generate (default 500) + * @returns Array of test records + */ +export function generateLargeDataset(count = 500): Array<{ + id: number + string: string + number: number +}> { + return Array.from({ length: count }, (_, n) => ({ + id: 1000000 + n, + string: `value_${n % 10}`, + number: n % 10, + })) +} + +/** + * Extended JSONB data with additional fields for comprehensive testing + * Includes all standard fields plus edge cases + */ +export const extendedJsonbData = { + ...standardJsonbData, + // Additional fields for edge case testing + boolean_field: true, + null_field: null, + float_field: 99.99, + negative_number: -500, + empty_array: [], + empty_object: {}, + deep_nested: { + level1: { + level2: { + level3: { + value: 'deep', + }, + }, + }, + }, + unicode_string: '你好世界 🌍', + special_chars: 'Hello "world" with \'quotes\'', +} + +/** + * Type definition for extended JSONB data + */ +export type ExtendedJsonbData = typeof extendedJsonbData + +/** + * JSONB data variations for containment tests + * Each object represents a different containment pattern + */ +export const containmentVariations = { + // String field containment + stringOnly: { string: 'hello' }, + // Number field containment + numberOnly: { number: 42 }, + // Array containment + stringArray: { array_string: ['hello', 'world'] }, + numberArray: { array_number: [42, 84] }, + // Nested object containment + nestedFull: { nested: { number: 1815, string: 'world' } }, + nestedPartial: { nested: { string: 'world' } }, + // Multiple field containment + multipleFields: { string: 'hello', number: 42 }, +} + +/** + * Path test cases for field access and path operations + * Maps path expressions to expected values from standardJsonbData + */ +export const pathTestCases = { + // Simple paths + string: 'hello', + number: 42, + // Array paths + array_string: ['hello', 'world'], + array_number: [42, 84], + // Nested paths + nested: { number: 1815, string: 'world' }, + 'nested.string': 'world', + 'nested.number': 1815, + // Unknown paths (should return null/empty) + unknown_field: null, + 'nested.unknown': null, +} + +/** + * Array wildcard test cases + * Tests $.array[*] and $.array[@] patterns + */ +export const arrayWildcardTestCases = { + 'array_string[*]': ['hello', 'world'], + 'array_string[@]': ['hello', 'world'], + 'array_number[*]': [42, 84], + 'array_number[@]': [42, 84], +} + +/** + * Helper to create a unique test run ID for isolating test data + */ +export function createTestRunId(prefix = 'jsonb-test'): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} diff --git a/packages/drizzle/__tests__/jsonb-array-operations.test.ts b/packages/drizzle/__tests__/jsonb-array-operations.test.ts new file mode 100644 index 00000000..b4c1bfd9 --- /dev/null +++ b/packages/drizzle/__tests__/jsonb-array-operations.test.ts @@ -0,0 +1,674 @@ +/** + * JSONB Array Operations Tests + * + * Tests for JSONB array-specific operations through Drizzle ORM. + * These tests verify that the Drizzle integration correctly handles + * encrypted JSONB array operations matching the proxy test patterns. + * + * Reference: .work/jsonb-test-coverage/proxy-tests-reference.md + * - jsonb_array_elements.rs + * - jsonb_array_length.rs + */ +import 'dotenv/config' +import { protect, type QueryTerm } from '@cipherstash/protect' +import { csColumn, csTable } from '@cipherstash/schema' +import { eq, sql } from 'drizzle-orm' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { encryptedType, extractProtectSchema } from '../src/pg' +import { + createTestRunId, + standardJsonbData, + type StandardJsonbData, +} from './fixtures/jsonb-test-data' + +if (!process.env.DATABASE_URL) { + throw new Error('Missing env.DATABASE_URL') +} + +// ============================================================================= +// Schema Definitions +// ============================================================================= + +/** + * Drizzle table with encrypted JSONB column for array operations testing + */ +const jsonbArrayOpsTable = pgTable('drizzle_jsonb_array_ops_test', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + encrypted_jsonb: encryptedType('encrypted_jsonb', { + searchableJson: true, + }), + createdAt: timestamp('created_at').defaultNow(), + testRunId: text('test_run_id'), +}) + +// Extract Protect.js schema from Drizzle table +const arrayOpsSchema = extractProtectSchema(jsonbArrayOpsTable) + +/** + * Protect.js schema with searchableJson for creating search terms + */ +const searchableSchema = csTable('drizzle_jsonb_array_ops_test', { + encrypted_jsonb: csColumn('encrypted_jsonb').searchableJson(), + // Array length extracted fields for range operations + "jsonb_array_length(encrypted_jsonb->'array_string')": csColumn( + "jsonb_array_length(encrypted_jsonb->'array_string')" + ) + .dataType('number') + .orderAndRange(), + "jsonb_array_length(encrypted_jsonb->'array_number')": csColumn( + "jsonb_array_length(encrypted_jsonb->'array_number')" + ) + .dataType('number') + .orderAndRange(), +}) + +// ============================================================================= +// Test Setup +// ============================================================================= + +const TEST_RUN_ID = createTestRunId('array-ops') + +let protectClient: Awaited> +let db: ReturnType +let insertedId: number + +beforeAll(async () => { + // Initialize Protect.js client + protectClient = await protect({ schemas: [arrayOpsSchema, searchableSchema] }) + + const client = postgres(process.env.DATABASE_URL as string) + db = drizzle({ client }) + + // Drop and recreate test table to ensure correct column type + await db.execute(sql`DROP TABLE IF EXISTS drizzle_jsonb_array_ops_test`) + await db.execute(sql` + CREATE TABLE drizzle_jsonb_array_ops_test ( + id SERIAL PRIMARY KEY, + encrypted_jsonb eql_v2_encrypted, + created_at TIMESTAMP DEFAULT NOW(), + test_run_id TEXT + ) + `) + + // Encrypt and insert standard test data + const encrypted = await protectClient.encryptModel( + { encrypted_jsonb: standardJsonbData }, + arrayOpsSchema, + ) + + if (encrypted.failure) { + throw new Error(`Encryption failed: ${encrypted.failure.message}`) + } + + const inserted = await db + .insert(jsonbArrayOpsTable) + .values({ + ...encrypted.data, + testRunId: TEST_RUN_ID, + }) + .returning({ id: jsonbArrayOpsTable.id }) + + insertedId = inserted[0].id +}, 60000) + +afterAll(async () => { + // Clean up test data + await db + .delete(jsonbArrayOpsTable) + .where(eq(jsonbArrayOpsTable.testRunId, TEST_RUN_ID)) +}, 30000) + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Verify the search term has selector-only format + */ +function expectJsonPathSelectorOnly(term: Record): void { + expect(term).toHaveProperty('s') + expect(typeof term.s).toBe('string') +} + +/** + * Verify the search term has path with value format + * Path+value queries return { sv: [...] } with the ste_vec entries + */ +function expectJsonPathWithValue(term: Record): void { + expect(term).toHaveProperty('sv') + expect(Array.isArray(term.sv)).toBe(true) + const sv = term.sv as Array> + expect(sv.length).toBeGreaterThan(0) +} + +// ============================================================================= +// jsonb_array_elements Tests +// ============================================================================= + +describe('JSONB Array Operations - jsonb_array_elements', () => { + it('should generate array elements selector for string array via wildcard path', async () => { + // SQL: jsonb_array_elements(jsonb_path_query(encrypted_jsonb, '$.array_string[@]')) + const terms: QueryTerm[] = [ + { + path: 'array_string[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array elements failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate array elements selector for numeric array via wildcard path', async () => { + // SQL: jsonb_array_elements(jsonb_path_query(encrypted_jsonb, '$.array_number[@]')) + const terms: QueryTerm[] = [ + { + path: 'array_number[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array elements failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate array elements selector with [*] wildcard notation', async () => { + // Alternative notation: $.array_string[*] + const terms: QueryTerm[] = [ + { + path: 'array_string[*]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array elements failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate array elements with string value filter', async () => { + // Check if 'hello' is in array_string + const terms: QueryTerm[] = [ + { + path: 'array_string[@]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array elements failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate array elements with numeric value filter', async () => { + // Check if 42 is in array_number + const terms: QueryTerm[] = [ + { + path: 'array_number[@]', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array elements failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate array elements selector for unknown field (empty result)', async () => { + // SQL: jsonb_array_elements(encrypted_jsonb->'nonexistent_array') + // Proxy returns empty set when field doesn't exist + const terms: QueryTerm[] = [ + { + path: 'nonexistent_array[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array elements failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// jsonb_array_length Tests +// ============================================================================= + +describe('JSONB Array Operations - jsonb_array_length', () => { + it('should generate range operation on string array length', async () => { + // SQL: jsonb_array_length(encrypted_jsonb->'array_string') > 2 + const result = await protectClient.encryptQuery(2, { + column: searchableSchema["jsonb_array_length(encrypted_jsonb->'array_string')"], + table: searchableSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Array length failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + expect(result.data!.ob!.length).toBeGreaterThan(0) + }, 30000) + + it('should generate range operation on numeric array length', async () => { + // SQL: jsonb_array_length(encrypted_jsonb->'array_number') >= 3 + const result = await protectClient.encryptQuery(3, { + column: searchableSchema["jsonb_array_length(encrypted_jsonb->'array_number')"], + table: searchableSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Array length failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + }, 30000) + + it('should handle array_length selector for unknown field (empty result)', async () => { + // SQL: jsonb_array_length(encrypted_jsonb->'nonexistent_array') + // Proxy returns NULL when field doesn't exist + const terms: QueryTerm[] = [ + { + path: 'nonexistent_array', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array length failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Batch Array Operations Tests +// ============================================================================= + +describe('JSONB Array Operations - Batch Operations', () => { + it('should handle batch of array element queries', async () => { + const terms: QueryTerm[] = [ + // String array with wildcard + { + path: 'array_string[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + // Numeric array with wildcard + { + path: 'array_number[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + // String array with value + { + path: 'array_string[*]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + // Numeric array with value + { + path: 'array_number[*]', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch array ops failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(4) + + // First two are selector-only + expectJsonPathSelectorOnly(result.data[0] as Record) + expectJsonPathSelectorOnly(result.data[1] as Record) + + // Last two have values + expectJsonPathWithValue(result.data[2] as Record) + expectJsonPathWithValue(result.data[3] as Record) + }, 30000) + + it('should handle batch of array length queries', async () => { + const lengthValues = [1, 2, 3, 5, 10] + + const terms = lengthValues.map((val) => ({ + value: val, + column: searchableSchema["jsonb_array_length(encrypted_jsonb->'array_string')"], + table: searchableSchema, + queryType: 'orderAndRange' as const, + })) + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch array length failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(lengthValues.length) + for (const term of result.data) { + expect(term).toHaveProperty('ob') + } + }, 30000) +}) + +// ============================================================================= +// Wildcard Notation Tests +// ============================================================================= + +describe('JSONB Array Operations - Wildcard Notation', () => { + it('should handle [@] wildcard notation', async () => { + const terms: QueryTerm[] = [ + { + path: 'array_string[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Wildcard notation failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should handle [*] wildcard notation', async () => { + const terms: QueryTerm[] = [ + { + path: 'array_string[*]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Wildcard notation failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should handle nested arrays with wildcards', async () => { + // SQL pattern: $.nested.items[*].values[*] + const terms: QueryTerm[] = [ + { + path: 'nested.items[@].values[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Nested wildcards failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should handle specific index access', async () => { + // SQL: encrypted_jsonb->'array_string'->0 + const terms: QueryTerm[] = [ + { + path: 'array_string[0]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Index access failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle last element access', async () => { + // SQL: encrypted_jsonb->'array_string'->-1 (last element) + const terms: QueryTerm[] = [ + { + path: 'array_string[-1]', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Last element access failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe('JSONB Array Operations - Edge Cases', () => { + it('should handle empty array path', async () => { + // Querying an empty array field + const terms: QueryTerm[] = [ + { + path: 'empty_array[@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Empty array failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should handle deeply nested array access', async () => { + // SQL pattern: $.a.b.c.d.array[*].value + const terms: QueryTerm[] = [ + { + path: 'a.b.c.d.array[@].value', + value: 'test', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Deep nested array failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle mixed wildcards and indices', async () => { + // SQL pattern: $.items[*].nested[0].value + const terms: QueryTerm[] = [ + { + path: 'items[@].nested[0].value', + value: 'mixed', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Mixed wildcards failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Encryption Verification Tests +// ============================================================================= + +describe('JSONB Array Operations - Encryption Verification', () => { + it('should store encrypted data (not plaintext)', async () => { + // Query raw value from database + const rawRow = await db + .select({ encrypted_jsonb: sql`encrypted_jsonb::text` }) + .from(jsonbArrayOpsTable) + .where(eq(jsonbArrayOpsTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + const rawValue = rawRow[0].encrypted_jsonb + + // Should NOT contain plaintext values + expect(rawValue).not.toContain('"array_string":["hello","world"]') + expect(rawValue).not.toContain('"array_number":[42,84]') + expect(rawValue).not.toContain('"string":"hello"') + + // Should have encrypted structure (c = ciphertext indicator) + expect(rawValue).toContain('"c"') + }, 30000) + + it('should have encrypted structure with expected fields', async () => { + // Query raw encrypted data + const rawRow = await db + .select({ encrypted_jsonb: jsonbArrayOpsTable.encrypted_jsonb }) + .from(jsonbArrayOpsTable) + .where(eq(jsonbArrayOpsTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + + // The encrypted value should be an object with encryption metadata + const encryptedValue = rawRow[0].encrypted_jsonb as Record + expect(encryptedValue).toBeDefined() + + // Should have ciphertext structure + expect(encryptedValue).toHaveProperty('c') + }, 30000) +}) + +// ============================================================================= +// Decryption Verification Tests +// ============================================================================= + +describe('JSONB Array Operations - Decryption Verification', () => { + it('should decrypt stored data correctly', async () => { + const results = await db + .select() + .from(jsonbArrayOpsTable) + .where(eq(jsonbArrayOpsTable.id, insertedId)) + + expect(results).toHaveLength(1) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Verify decrypted values match original standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + expect(decryptedJsonb).toBeDefined() + expect(decryptedJsonb!.array_string).toEqual(['hello', 'world']) + expect(decryptedJsonb!.array_number).toEqual([42, 84]) + expect(decryptedJsonb!.string).toBe('hello') + expect(decryptedJsonb!.number).toBe(42) + }, 30000) + + it('should round-trip encrypt and decrypt preserving array fields', async () => { + // Fetch and decrypt all data + const results = await db + .select() + .from(jsonbArrayOpsTable) + .where(eq(jsonbArrayOpsTable.id, insertedId)) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Compare with original test data + const original = standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + + expect(decryptedJsonb).toEqual(original) + }, 30000) +}) diff --git a/packages/drizzle/__tests__/jsonb-comparison.test.ts b/packages/drizzle/__tests__/jsonb-comparison.test.ts new file mode 100644 index 00000000..7532cc21 --- /dev/null +++ b/packages/drizzle/__tests__/jsonb-comparison.test.ts @@ -0,0 +1,751 @@ +/** + * JSONB Comparison Operations Tests + * + * Tests for WHERE clause comparisons on extracted JSONB fields through Drizzle ORM. + * These tests verify that the Drizzle integration correctly handles encrypted + * JSONB comparison operations matching the proxy test patterns. + * + * Reference: .work/jsonb-test-coverage/proxy-tests-reference.md + * - select_where_jsonb_eq.rs (=) + * - select_where_jsonb_gt.rs (>) + * - select_where_jsonb_gte.rs (>=) + * - select_where_jsonb_lt.rs (<) + * - select_where_jsonb_lte.rs (<=) + */ +import 'dotenv/config' +import { protect } from '@cipherstash/protect' +import { csColumn, csTable } from '@cipherstash/schema' +import { eq, sql } from 'drizzle-orm' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + createProtectOperators, + encryptedType, + extractProtectSchema, +} from '../src/pg' +import { + comparisonTestData, + createTestRunId, + type ComparisonTestData, +} from './fixtures/jsonb-test-data' + +if (!process.env.DATABASE_URL) { + throw new Error('Missing env.DATABASE_URL') +} + +// ============================================================================= +// Schema Definitions +// ============================================================================= + +/** + * Drizzle table with encrypted JSONB column and extracted field definitions + * for comparison operations + */ +const jsonbComparisonTable = pgTable('drizzle_jsonb_comparison_test', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + encrypted_jsonb: encryptedType('encrypted_jsonb', { + searchableJson: true, + }), + createdAt: timestamp('created_at').defaultNow(), + testRunId: text('test_run_id'), +}) + +// Extract Protect.js schema from Drizzle table +const comparisonSchema = extractProtectSchema(jsonbComparisonTable) + +/** + * Protect.js schema for extracted JSONB fields + * Used for comparison operations on extracted values + */ +const extractedFieldsSchema = csTable('drizzle_jsonb_comparison_test', { + encrypted_jsonb: csColumn('encrypted_jsonb').searchableJson(), + // Arrow operator extracted fields + 'encrypted_jsonb->>string': csColumn('encrypted_jsonb->>string') + .dataType('string') + .equality() + .orderAndRange(), + 'encrypted_jsonb->>number': csColumn('encrypted_jsonb->>number') + .dataType('number') + .equality() + .orderAndRange(), + // jsonb_path_query_first extracted fields + "jsonb_path_query_first(encrypted_jsonb, '$.string')": csColumn( + "jsonb_path_query_first(encrypted_jsonb, '$.string')" + ) + .dataType('string') + .equality() + .orderAndRange(), + "jsonb_path_query_first(encrypted_jsonb, '$.number')": csColumn( + "jsonb_path_query_first(encrypted_jsonb, '$.number')" + ) + .dataType('number') + .equality() + .orderAndRange(), +}) + +// ============================================================================= +// Test Setup +// ============================================================================= + +const TEST_RUN_ID = createTestRunId('comparison') + +let protectClient: Awaited> +let protectOps: ReturnType +let db: ReturnType +const insertedIds: number[] = [] + +beforeAll(async () => { + // Initialize Protect.js client with both schemas + protectClient = await protect({ + schemas: [comparisonSchema, extractedFieldsSchema], + }) + protectOps = createProtectOperators(protectClient) + + const client = postgres(process.env.DATABASE_URL as string) + db = drizzle({ client }) + + // Drop and recreate test table to ensure correct column type + await db.execute(sql`DROP TABLE IF EXISTS drizzle_jsonb_comparison_test`) + await db.execute(sql` + CREATE TABLE drizzle_jsonb_comparison_test ( + id SERIAL PRIMARY KEY, + encrypted_jsonb eql_v2_encrypted, + created_at TIMESTAMP DEFAULT NOW(), + test_run_id TEXT + ) + `) + + // Encrypt and insert comparison test data (5 rows) + for (const data of comparisonTestData) { + const encrypted = await protectClient.encryptModel( + { encrypted_jsonb: data }, + comparisonSchema, + ) + + if (encrypted.failure) { + throw new Error(`Encryption failed: ${encrypted.failure.message}`) + } + + const inserted = await db + .insert(jsonbComparisonTable) + .values({ + ...encrypted.data, + testRunId: TEST_RUN_ID, + }) + .returning({ id: jsonbComparisonTable.id }) + + insertedIds.push(inserted[0].id) + } +}, 60000) + +afterAll(async () => { + // Clean up test data + await db + .delete(jsonbComparisonTable) + .where(eq(jsonbComparisonTable.testRunId, TEST_RUN_ID)) +}, 30000) + +// ============================================================================= +// Equality (=) Comparison Tests +// ============================================================================= + +describe('JSONB Comparison - Equality (=)', () => { + it('should generate equality query term for string via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'string' = 'B' + const result = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'equality', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('hm') + expect(typeof result.data!.hm).toBe('string') + }, 30000) + + it('should generate equality query term for number via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'number' = 3 + const result = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'equality', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('hm') + expect(typeof result.data!.hm).toBe('string') + }, 30000) + + it('should generate equality query term for string via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.string') = 'B' + const result = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.string')"], + table: extractedFieldsSchema, + queryType: 'equality', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('hm') + }, 30000) + + it('should generate equality query term for number via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.number') = 3 + const result = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.number')"], + table: extractedFieldsSchema, + queryType: 'equality', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('hm') + }, 30000) +}) + +// ============================================================================= +// Greater Than (>) Comparison Tests +// ============================================================================= + +describe('JSONB Comparison - Greater Than (>)', () => { + it('should generate greater than query term for string via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'string' > 'C' (should match D, E) + const result = await protectClient.encryptQuery('C', { + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + expect(result.data!.ob!.length).toBeGreaterThan(0) + }, 30000) + + it('should generate greater than query term for number via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'number' > 4 (should match 5) + const result = await protectClient.encryptQuery(4, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + }, 30000) + + it('should generate greater than query term for string via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.string') > 'C' + const result = await protectClient.encryptQuery('C', { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.string')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate greater than query term for number via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.number') > 4 + const result = await protectClient.encryptQuery(4, { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.number')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) +}) + +// ============================================================================= +// Greater Than or Equal (>=) Comparison Tests +// ============================================================================= + +describe('JSONB Comparison - Greater Than or Equal (>=)', () => { + it('should generate gte query term for string via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'string' >= 'C' (should match C, D, E) + const result = await protectClient.encryptQuery('C', { + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + }, 30000) + + it('should generate gte query term for number via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'number' >= 4 (should match 4, 5) + const result = await protectClient.encryptQuery(4, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate gte query term for string via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.string') >= 'C' + const result = await protectClient.encryptQuery('C', { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.string')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate gte query term for number via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.number') >= 4 + const result = await protectClient.encryptQuery(4, { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.number')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) +}) + +// ============================================================================= +// Less Than (<) Comparison Tests +// ============================================================================= + +describe('JSONB Comparison - Less Than (<)', () => { + it('should generate less than query term for string via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'string' < 'B' (should match A) + const result = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + }, 30000) + + it('should generate less than query term for number via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'number' < 3 (should match 1, 2) + const result = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate less than query term for string via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.string') < 'B' + const result = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.string')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate less than query term for number via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.number') < 3 + const result = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.number')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) +}) + +// ============================================================================= +// Less Than or Equal (<=) Comparison Tests +// ============================================================================= + +describe('JSONB Comparison - Less Than or Equal (<=)', () => { + it('should generate lte query term for string via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'string' <= 'B' (should match A, B) + const result = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + expect(Array.isArray(result.data!.ob)).toBe(true) + }, 30000) + + it('should generate lte query term for number via arrow operator', async () => { + // SQL: encrypted_jsonb -> 'number' <= 3 (should match 1, 2, 3) + const result = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate lte query term for string via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.string') <= 'B' + const result = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.string')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) + + it('should generate lte query term for number via jsonb_path_query_first', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.number') <= 3 + const result = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema["jsonb_path_query_first(encrypted_jsonb, '$.number')"], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toBeDefined() + expect(result.data).toHaveProperty('ob') + }, 30000) +}) + +// ============================================================================= +// Batch Comparison Tests +// ============================================================================= + +describe('JSONB Comparison - Batch Operations', () => { + it('should handle batch of comparison queries on extracted fields', async () => { + const terms = [ + // String equality + { + value: 'B', + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'equality' as const, + }, + // Number equality + { + value: 3, + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'equality' as const, + }, + // String range + { + value: 'C', + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'orderAndRange' as const, + }, + // Number range + { + value: 4, + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'orderAndRange' as const, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch comparison failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(4) + + // Equality queries should have 'hm' + expect(result.data[0]).toHaveProperty('hm') + expect(result.data[1]).toHaveProperty('hm') + + // Range queries should have 'ob' + expect(result.data[2]).toHaveProperty('ob') + expect(result.data[3]).toHaveProperty('ob') + }, 30000) + + it('should handle mixed string and number comparisons in batch', async () => { + const stringValues = ['A', 'B', 'C', 'D', 'E'] + const numberValues = [1, 2, 3, 4, 5] + + const terms = [ + ...stringValues.map((val) => ({ + value: val, + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'equality' as const, + })), + ...numberValues.map((val) => ({ + value: val, + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'equality' as const, + })), + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Mixed batch failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(10) + for (const term of result.data) { + expect(term).toHaveProperty('hm') + } + }, 30000) +}) + +// ============================================================================= +// Encryption Verification Tests +// ============================================================================= + +describe('JSONB Comparison - Encryption Verification', () => { + it('should store encrypted data (not plaintext)', async () => { + // Query raw value from database for first inserted row + const rawRow = await db + .select({ encrypted_jsonb: sql`encrypted_jsonb::text` }) + .from(jsonbComparisonTable) + .where(eq(jsonbComparisonTable.id, insertedIds[0])) + + expect(rawRow).toHaveLength(1) + const rawValue = rawRow[0].encrypted_jsonb + + // Should NOT contain plaintext values from comparisonTestData[0] = {string: 'A', number: 1} + expect(rawValue).not.toContain('"string":"A"') + expect(rawValue).not.toContain('"number":1') + + // Should have encrypted structure (c = ciphertext indicator) + expect(rawValue).toContain('"c"') + }, 30000) + + it('should have encrypted structure for all comparison test rows', async () => { + // Query all test rows + const rawRows = await db + .select({ id: jsonbComparisonTable.id, encrypted_jsonb: jsonbComparisonTable.encrypted_jsonb }) + .from(jsonbComparisonTable) + .where(eq(jsonbComparisonTable.testRunId, TEST_RUN_ID)) + + expect(rawRows).toHaveLength(5) + + // All rows should have encrypted structure + for (const row of rawRows) { + const encryptedValue = row.encrypted_jsonb as Record + expect(encryptedValue).toBeDefined() + expect(encryptedValue).toHaveProperty('c') + } + }, 30000) +}) + +// ============================================================================= +// Decryption Verification Tests +// ============================================================================= + +describe('JSONB Comparison - Decryption Verification', () => { + it('should decrypt stored data correctly', async () => { + const results = await db + .select() + .from(jsonbComparisonTable) + .where(eq(jsonbComparisonTable.id, insertedIds[0])) + + expect(results).toHaveLength(1) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Verify decrypted values match original comparisonTestData[0] + const decryptedJsonb = decrypted.data.encrypted_jsonb + expect(decryptedJsonb).toBeDefined() + expect(decryptedJsonb!.string).toBe('A') + expect(decryptedJsonb!.number).toBe(1) + }, 30000) + + it('should decrypt all comparison test rows correctly', async () => { + const results = await db + .select() + .from(jsonbComparisonTable) + .where(eq(jsonbComparisonTable.testRunId, TEST_RUN_ID)) + + expect(results).toHaveLength(5) + + const decryptedResults = await protectClient.bulkDecryptModels(results) + if (decryptedResults.failure) { + throw new Error(`Bulk decryption failed: ${decryptedResults.failure.message}`) + } + + // Sort by number to match original order + const sortedDecrypted = decryptedResults.data.sort( + (a, b) => (a.encrypted_jsonb as { number: number }).number - (b.encrypted_jsonb as { number: number }).number + ) + + // Verify each row matches the original comparisonTestData + for (let i = 0; i < comparisonTestData.length; i++) { + const original = comparisonTestData[i] + const decrypted = sortedDecrypted[i].encrypted_jsonb as { string: string; number: number } + expect(decrypted.string).toBe(original.string) + expect(decrypted.number).toBe(original.number) + } + }, 30000) +}) + +// ============================================================================= +// Query Execution Tests +// ============================================================================= + +describe('JSONB Comparison - Query Execution', () => { + it('should generate valid search terms for string equality comparison', async () => { + // Create encrypted query for string = 'B' + const encryptedQuery = await protectClient.encryptQuery('B', { + column: extractedFieldsSchema['encrypted_jsonb->>string'], + table: extractedFieldsSchema, + queryType: 'equality', + }) + + if (encryptedQuery.failure) { + throw new Error(`Query encryption failed: ${encryptedQuery.failure.message}`) + } + + // Verify the encrypted query has the expected structure + expect(encryptedQuery.data).toBeDefined() + expect(encryptedQuery.data).toHaveProperty('hm') + + // The 'hm' (hash match) property is used for equality comparisons + expect(typeof encryptedQuery.data!.hm).toBe('string') + }, 30000) + + it('should generate valid search terms for numeric equality comparison', async () => { + // Create encrypted query for number = 3 + const encryptedQuery = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'equality', + }) + + if (encryptedQuery.failure) { + throw new Error(`Query encryption failed: ${encryptedQuery.failure.message}`) + } + + // Verify the encrypted query has the expected structure + expect(encryptedQuery.data).toBeDefined() + expect(encryptedQuery.data).toHaveProperty('hm') + expect(typeof encryptedQuery.data!.hm).toBe('string') + }, 30000) + + it('should generate valid search terms for range comparison', async () => { + // Create encrypted query for number > 3 (order and range) + const encryptedQuery = await protectClient.encryptQuery(3, { + column: extractedFieldsSchema['encrypted_jsonb->>number'], + table: extractedFieldsSchema, + queryType: 'orderAndRange', + }) + + if (encryptedQuery.failure) { + throw new Error(`Query encryption failed: ${encryptedQuery.failure.message}`) + } + + // Verify the encrypted query has the expected structure + expect(encryptedQuery.data).toBeDefined() + expect(encryptedQuery.data).toHaveProperty('ob') + + // The 'ob' (order bytes) property is used for range comparisons + expect(Array.isArray(encryptedQuery.data!.ob)).toBe(true) + expect(encryptedQuery.data!.ob!.length).toBeGreaterThan(0) + }, 30000) +}) diff --git a/packages/drizzle/__tests__/jsonb-containment.test.ts b/packages/drizzle/__tests__/jsonb-containment.test.ts new file mode 100644 index 00000000..48e87cf8 --- /dev/null +++ b/packages/drizzle/__tests__/jsonb-containment.test.ts @@ -0,0 +1,618 @@ +/** + * JSONB Containment Operations Tests + * + * Tests for JSONB containment operations (@> and <@) through Drizzle ORM. + * These tests verify that the Drizzle integration correctly handles + * encrypted JSONB containment queries matching the proxy test patterns. + * + * Reference: .work/jsonb-test-coverage/proxy-tests-reference.md + * - jsonb_contains.rs (@> operator) + * - jsonb_contained_by.rs (<@ operator) + * - jsonb_containment_index.rs (large dataset) + */ +import 'dotenv/config' +import { protect } from '@cipherstash/protect' +import { eq, sql } from 'drizzle-orm' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { encryptedType, extractProtectSchema } from '../src/pg' +import { + containmentVariations, + createTestRunId, + standardJsonbData, + type StandardJsonbData, +} from './fixtures/jsonb-test-data' + +if (!process.env.DATABASE_URL) { + throw new Error('Missing env.DATABASE_URL') +} + +// ============================================================================= +// Schema Definitions +// ============================================================================= + +/** + * Drizzle table with encrypted JSONB column for containment testing + */ +const jsonbContainmentTable = pgTable('drizzle_jsonb_containment_test', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + encrypted_jsonb: encryptedType('encrypted_jsonb', { + searchableJson: true, + }), + createdAt: timestamp('created_at').defaultNow(), + testRunId: text('test_run_id'), +}) + +// Extract Protect.js schema from Drizzle table +const containmentSchema = extractProtectSchema(jsonbContainmentTable) + +// ============================================================================= +// Test Setup +// ============================================================================= + +const TEST_RUN_ID = createTestRunId('containment') + +let protectClient: Awaited> +let db: ReturnType +let insertedId: number + +beforeAll(async () => { + // Initialize Protect.js client + protectClient = await protect({ schemas: [containmentSchema] }) + + const client = postgres(process.env.DATABASE_URL as string) + db = drizzle({ client }) + + // Drop and recreate test table to ensure correct column type + await db.execute(sql`DROP TABLE IF EXISTS drizzle_jsonb_containment_test`) + await db.execute(sql` + CREATE TABLE drizzle_jsonb_containment_test ( + id SERIAL PRIMARY KEY, + encrypted_jsonb eql_v2_encrypted, + created_at TIMESTAMP DEFAULT NOW(), + test_run_id TEXT + ) + `) + + // Encrypt and insert standard test data + const encrypted = await protectClient.encryptModel( + { encrypted_jsonb: standardJsonbData }, + containmentSchema, + ) + + if (encrypted.failure) { + throw new Error(`Encryption failed: ${encrypted.failure.message}`) + } + + const inserted = await db + .insert(jsonbContainmentTable) + .values({ + ...encrypted.data, + testRunId: TEST_RUN_ID, + }) + .returning({ id: jsonbContainmentTable.id }) + + insertedId = inserted[0].id +}, 60000) + +afterAll(async () => { + // Clean up test data + await db + .delete(jsonbContainmentTable) + .where(eq(jsonbContainmentTable.testRunId, TEST_RUN_ID)) +}, 30000) + +// ============================================================================= +// Contains (@>) Operator Tests +// ============================================================================= + +describe('JSONB Containment - Contains (@>) via Drizzle', () => { + it('should generate containment search term for string value', async () => { + // SQL: encrypted_jsonb @> '{"string": "hello"}' + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.stringOnly, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + expect(Array.isArray((result.data[0] as { sv: unknown[] }).sv)).toBe(true) + }, 30000) + + it('should generate containment search term for number value', async () => { + // SQL: encrypted_jsonb @> '{"number": 42}' + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.numberOnly, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate containment search term for string array', async () => { + // SQL: encrypted_jsonb @> '{"array_string": ["hello", "world"]}' + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.stringArray, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate containment search term for numeric array', async () => { + // SQL: encrypted_jsonb @> '{"array_number": [42, 84]}' + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.numberArray, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate containment search term for nested object', async () => { + // SQL: encrypted_jsonb @> '{"nested": {"number": 1815, "string": "world"}}' + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.nestedFull, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate containment search term for partial nested object', async () => { + // SQL: encrypted_jsonb @> '{"nested": {"string": "world"}}' + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.nestedPartial, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) +}) + +// ============================================================================= +// Contained By (<@) Operator Tests +// ============================================================================= + +describe('JSONB Containment - Contained By (<@) via Drizzle', () => { + it('should generate contained_by search term for string value', async () => { + // SQL: '{"string": "hello"}' <@ encrypted_jsonb + const result = await protectClient.encryptQuery([ + { + containedBy: containmentVariations.stringOnly, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate contained_by search term for number value', async () => { + // SQL: '{"number": 42}' <@ encrypted_jsonb + const result = await protectClient.encryptQuery([ + { + containedBy: containmentVariations.numberOnly, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate contained_by search term for string array', async () => { + // SQL: '{"array_string": ["hello", "world"]}' <@ encrypted_jsonb + const result = await protectClient.encryptQuery([ + { + containedBy: containmentVariations.stringArray, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate contained_by search term for numeric array', async () => { + // SQL: '{"array_number": [42, 84]}' <@ encrypted_jsonb + const result = await protectClient.encryptQuery([ + { + containedBy: containmentVariations.numberArray, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should generate contained_by search term for nested object', async () => { + // SQL: '{"nested": {"number": 1815, "string": "world"}}' <@ encrypted_jsonb + const result = await protectClient.encryptQuery([ + { + containedBy: containmentVariations.nestedFull, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) +}) + +// ============================================================================= +// Batch Containment Tests (Large Dataset Pattern) +// ============================================================================= + +describe('JSONB Containment - Batch Operations', () => { + it('should handle batch of containment queries', async () => { + // Generate multiple containment queries similar to 500-row test pattern + const terms = Array.from({ length: 20 }, (_, i) => ({ + contains: { [`key_${i}`]: `value_${i}` }, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + })) + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(20) + for (const term of result.data) { + expect(term).toHaveProperty('sv') + } + }, 60000) + + it('should handle mixed contains and contained_by batch', async () => { + const containsTerms = Array.from({ length: 10 }, (_, i) => ({ + contains: { field: `contains_${i}` }, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + })) + + const containedByTerms = Array.from({ length: 10 }, (_, i) => ({ + containedBy: { field: `contained_by_${i}` }, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + })) + + const result = await protectClient.encryptQuery([ + ...containsTerms, + ...containedByTerms, + ]) + + if (result.failure) { + throw new Error(`Mixed batch failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(20) + }, 60000) + + it('should handle complex nested containment object', async () => { + const complexObject = { + metadata: { + created_by: 'user_123', + tags: ['important', 'verified'], + settings: { + enabled: true, + level: 5, + }, + }, + attributes: { + category: 'premium', + scores: [85, 90, 95], + }, + } + + const result = await protectClient.encryptQuery([ + { + contains: complexObject, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Complex containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + + // Verify the ste_vec has multiple entries for the complex structure + const svResult = result.data[0] as { sv: unknown[] } + expect(svResult.sv.length).toBeGreaterThan(5) + }, 30000) + + it('should handle array containment with many elements', async () => { + const largeArray = Array.from({ length: 50 }, (_, i) => `item_${i}`) + + const result = await protectClient.encryptQuery([ + { + contains: { items: largeArray }, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Array containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + + const svResult = result.data[0] as { sv: unknown[] } + expect(svResult.sv.length).toBeGreaterThanOrEqual(50) + }, 30000) + + it('should handle containment with various numeric values', async () => { + const numericValues = [0, 1, -1, 42, 100, -500, 0.5, -0.5, 999999] + + const terms = numericValues.map((num) => ({ + contains: { count: num }, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + })) + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Numeric containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(numericValues.length) + for (const term of result.data) { + expect(term).toHaveProperty('sv') + } + }, 30000) +}) + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe('JSONB Containment - Edge Cases', () => { + it('should handle empty object containment', async () => { + const result = await protectClient.encryptQuery([ + { + contains: {}, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Empty object containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + }, 30000) + + it('should handle null value in containment object', async () => { + const result = await protectClient.encryptQuery([ + { + contains: { nullable_field: null }, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Null containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should handle multiple field containment', async () => { + const result = await protectClient.encryptQuery([ + { + contains: containmentVariations.multipleFields, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Multiple field containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + }, 30000) + + it('should handle large containment object (50+ keys)', async () => { + const largeObject: Record = {} + for (let i = 0; i < 50; i++) { + largeObject[`key${i}`] = `value${i}` + } + + const result = await protectClient.encryptQuery([ + { + contains: largeObject, + column: containmentSchema.encrypted_jsonb, + table: containmentSchema, + }, + ]) + + if (result.failure) { + throw new Error(`Large object containment failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expect(result.data[0]).toHaveProperty('sv') + + const svResult = result.data[0] as { sv: unknown[] } + expect(svResult.sv.length).toBeGreaterThanOrEqual(50) + }, 30000) +}) + +// ============================================================================= +// Encryption Verification Tests +// ============================================================================= + +describe('JSONB Containment - Encryption Verification', () => { + it('should store encrypted data (not plaintext)', async () => { + // Query raw value from database + const rawRow = await db + .select({ encrypted_jsonb: sql`encrypted_jsonb::text` }) + .from(jsonbContainmentTable) + .where(eq(jsonbContainmentTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + const rawValue = rawRow[0].encrypted_jsonb + + // Should NOT contain plaintext values from standardJsonbData + expect(rawValue).not.toContain('"string":"hello"') + expect(rawValue).not.toContain('"number":42') + expect(rawValue).not.toContain('"array_string":["hello","world"]') + + // Should have encrypted structure (c = ciphertext indicator) + expect(rawValue).toContain('"c"') + }, 30000) + + it('should have encrypted structure with expected fields', async () => { + // Query raw encrypted data + const rawRow = await db + .select({ encrypted_jsonb: jsonbContainmentTable.encrypted_jsonb }) + .from(jsonbContainmentTable) + .where(eq(jsonbContainmentTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + + // The encrypted value should be an object with encryption metadata + const encryptedValue = rawRow[0].encrypted_jsonb as Record + expect(encryptedValue).toBeDefined() + + // Should have ciphertext structure + expect(encryptedValue).toHaveProperty('c') + }, 30000) +}) + +// ============================================================================= +// Decryption Verification Tests +// ============================================================================= + +describe('JSONB Containment - Decryption Verification', () => { + it('should decrypt stored data correctly', async () => { + const results = await db + .select() + .from(jsonbContainmentTable) + .where(eq(jsonbContainmentTable.id, insertedId)) + + expect(results).toHaveLength(1) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Verify decrypted values match original standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + expect(decryptedJsonb).toBeDefined() + expect(decryptedJsonb!.string).toBe('hello') + expect(decryptedJsonb!.number).toBe(42) + expect(decryptedJsonb!.array_string).toEqual(['hello', 'world']) + expect(decryptedJsonb!.array_number).toEqual([42, 84]) + expect(decryptedJsonb!.nested.string).toBe('world') + expect(decryptedJsonb!.nested.number).toBe(1815) + }, 30000) + + it('should round-trip encrypt and decrypt preserving all fields', async () => { + // Fetch and decrypt all data + const results = await db + .select() + .from(jsonbContainmentTable) + .where(eq(jsonbContainmentTable.id, insertedId)) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Compare with original test data + const original = standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + + expect(decryptedJsonb).toEqual(original) + }, 30000) +}) diff --git a/packages/drizzle/__tests__/jsonb-field-access.test.ts b/packages/drizzle/__tests__/jsonb-field-access.test.ts new file mode 100644 index 00000000..052f1e64 --- /dev/null +++ b/packages/drizzle/__tests__/jsonb-field-access.test.ts @@ -0,0 +1,725 @@ +/** + * JSONB Field Access Tests + * + * Tests for field extraction via arrow operator (->) through Drizzle ORM. + * These tests verify that the Drizzle integration correctly handles + * encrypted JSONB field access operations matching the proxy test patterns. + * + * Reference: .work/jsonb-test-coverage/proxy-tests-reference.md + * - jsonb_get_field.rs (-> operator) + * - jsonb_get_field_as_ciphertext.rs + */ +import 'dotenv/config' +import { protect, type QueryTerm } from '@cipherstash/protect' +import { csColumn, csTable } from '@cipherstash/schema' +import { eq, sql } from 'drizzle-orm' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { encryptedType, extractProtectSchema } from '../src/pg' +import { + createTestRunId, + pathTestCases, + standardJsonbData, + type StandardJsonbData, +} from './fixtures/jsonb-test-data' + +if (!process.env.DATABASE_URL) { + throw new Error('Missing env.DATABASE_URL') +} + +// ============================================================================= +// Schema Definitions +// ============================================================================= + +/** + * Drizzle table with encrypted JSONB column for field access testing + */ +const jsonbFieldAccessTable = pgTable('drizzle_jsonb_field_access_test', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + encrypted_jsonb: encryptedType('encrypted_jsonb', { + searchableJson: true, + }), + createdAt: timestamp('created_at').defaultNow(), + testRunId: text('test_run_id'), +}) + +// Extract Protect.js schema from Drizzle table +const fieldAccessSchema = extractProtectSchema(jsonbFieldAccessTable) + +/** + * Protect.js schema with searchableJson for creating search terms + */ +const searchableSchema = csTable('drizzle_jsonb_field_access_test', { + encrypted_jsonb: csColumn('encrypted_jsonb').searchableJson(), +}) + +// ============================================================================= +// Test Setup +// ============================================================================= + +const TEST_RUN_ID = createTestRunId('field-access') + +let protectClient: Awaited> +let db: ReturnType +let insertedId: number + +beforeAll(async () => { + // Initialize Protect.js client + protectClient = await protect({ + schemas: [fieldAccessSchema, searchableSchema], + }) + + const client = postgres(process.env.DATABASE_URL as string) + db = drizzle({ client }) + + // Drop and recreate test table to ensure correct column type + await db.execute(sql`DROP TABLE IF EXISTS drizzle_jsonb_field_access_test`) + await db.execute(sql` + CREATE TABLE drizzle_jsonb_field_access_test ( + id SERIAL PRIMARY KEY, + encrypted_jsonb eql_v2_encrypted, + created_at TIMESTAMP DEFAULT NOW(), + test_run_id TEXT + ) + `) + + // Encrypt and insert standard test data + const encrypted = await protectClient.encryptModel( + { encrypted_jsonb: standardJsonbData }, + fieldAccessSchema, + ) + + if (encrypted.failure) { + throw new Error(`Encryption failed: ${encrypted.failure.message}`) + } + + const inserted = await db + .insert(jsonbFieldAccessTable) + .values({ + ...encrypted.data, + testRunId: TEST_RUN_ID, + }) + .returning({ id: jsonbFieldAccessTable.id }) + + insertedId = inserted[0].id +}, 60000) + +afterAll(async () => { + // Clean up test data + await db + .delete(jsonbFieldAccessTable) + .where(eq(jsonbFieldAccessTable.testRunId, TEST_RUN_ID)) +}, 30000) + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Verify the search term has selector-only format (no value) + */ +function expectJsonPathSelectorOnly(term: Record): void { + expect(term).toHaveProperty('s') + expect(typeof term.s).toBe('string') + // Selector-only terms should not have 'sv' (ste_vec for values) +} + +/** + * Verify the search term has path with value format + * Path+value queries return { sv: [...] } with the ste_vec entries + */ +function expectJsonPathWithValue(term: Record): void { + expect(term).toHaveProperty('sv') + expect(Array.isArray(term.sv)).toBe(true) + const sv = term.sv as Array> + expect(sv.length).toBeGreaterThan(0) +} + +// ============================================================================= +// Field Access Tests - Direct Arrow Operator +// ============================================================================= + +describe('JSONB Field Access - Direct Arrow Operator', () => { + it('should generate selector for string field', async () => { + // SQL: encrypted_jsonb -> 'string' + const terms: QueryTerm[] = [ + { + path: 'string', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate selector for numeric field', async () => { + // SQL: encrypted_jsonb -> 'number' + const terms: QueryTerm[] = [ + { + path: 'number', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate selector for numeric array field', async () => { + // SQL: encrypted_jsonb -> 'array_number' + const terms: QueryTerm[] = [ + { + path: 'array_number', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate selector for string array field', async () => { + // SQL: encrypted_jsonb -> 'array_string' + const terms: QueryTerm[] = [ + { + path: 'array_string', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate selector for nested object field', async () => { + // SQL: encrypted_jsonb -> 'nested' + const terms: QueryTerm[] = [ + { + path: 'nested', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate selector for deep nested path', async () => { + // SQL: encrypted_jsonb -> 'nested' -> 'string' + const terms: QueryTerm[] = [ + { + path: 'nested.string', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate selector for unknown field (returns null in SQL)', async () => { + // SQL: encrypted_jsonb -> 'blahvtha' (returns NULL) + const terms: QueryTerm[] = [ + { + path: 'unknown_field', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + // Still generates a selector - proxy will return NULL + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Field Access Tests - Selector Format Flexibility +// ============================================================================= + +describe('JSONB Field Access - Selector Format Flexibility', () => { + it('should accept simple field name format', async () => { + // Path: 'string' (no prefix) + const terms: QueryTerm[] = [ + { + path: 'string', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should accept nested field dot notation', async () => { + // Path: 'nested.string' + const terms: QueryTerm[] = [ + { + path: 'nested.string', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should accept path as array format', async () => { + // Path: ['nested', 'string'] + const terms: QueryTerm[] = [ + { + path: ['nested', 'string'], + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should accept very deep nested paths', async () => { + // Path: 'a.b.c.d.e.f.g.h.i.j' + const terms: QueryTerm[] = [ + { + path: 'a.b.c.d.e.f.g.h.i.j', + value: 'deep_value', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Field Access Tests - With Values +// ============================================================================= + +describe('JSONB Field Access - Path with Value Matching', () => { + it('should generate search term for string field with value', async () => { + const terms: QueryTerm[] = [ + { + path: 'string', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate search term for numeric field with value', async () => { + const terms: QueryTerm[] = [ + { + path: 'number', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate search term for nested string with value', async () => { + const terms: QueryTerm[] = [ + { + path: 'nested.string', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate search term for nested number with value', async () => { + const terms: QueryTerm[] = [ + { + path: 'nested.number', + value: 1815, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Query encryption failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Batch Field Access Tests +// ============================================================================= + +describe('JSONB Field Access - Batch Operations', () => { + it('should handle batch of field access queries', async () => { + const paths = ['string', 'number', 'array_string', 'array_number', 'nested'] + + const terms: QueryTerm[] = paths.map((path) => ({ + path, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + })) + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch field access failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(paths.length) + for (const term of result.data) { + expectJsonPathSelectorOnly(term as Record) + } + }, 30000) + + it('should handle batch of field access with values', async () => { + const terms: QueryTerm[] = [ + { + path: 'string', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'number', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'nested.string', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'nested.number', + value: 1815, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch field access failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(4) + for (const term of result.data) { + expectJsonPathWithValue(term as Record) + } + }, 30000) +}) + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe('JSONB Field Access - Edge Cases', () => { + it('should handle special characters in string values', async () => { + const terms: QueryTerm[] = [ + { + path: 'message', + value: 'Hello "world" with \'quotes\' and \\backslash\\', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Special chars failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle unicode characters', async () => { + const terms: QueryTerm[] = [ + { + path: 'greeting', + value: '你好世界 🌍 مرحبا', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Unicode failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle boolean values', async () => { + const terms: QueryTerm[] = [ + { + path: 'is_active', + value: true, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Boolean failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle float/decimal numbers', async () => { + const terms: QueryTerm[] = [ + { + path: 'price', + value: 99.99, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Float failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle negative numbers', async () => { + const terms: QueryTerm[] = [ + { + path: 'balance', + value: -500, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Negative number failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Encryption Verification Tests +// ============================================================================= + +describe('JSONB Field Access - Encryption Verification', () => { + it('should store encrypted data (not plaintext)', async () => { + // Query raw value from database + const rawRow = await db + .select({ encrypted_jsonb: sql`encrypted_jsonb::text` }) + .from(jsonbFieldAccessTable) + .where(eq(jsonbFieldAccessTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + const rawValue = rawRow[0].encrypted_jsonb + + // Should NOT contain plaintext values + expect(rawValue).not.toContain('"string":"hello"') + expect(rawValue).not.toContain('"number":42') + expect(rawValue).not.toContain('"nested":{"number":1815') + + // Should have encrypted structure (c = ciphertext indicator) + expect(rawValue).toContain('"c"') + }, 30000) + + it('should have encrypted structure with expected fields', async () => { + // Query raw encrypted data + const rawRow = await db + .select({ encrypted_jsonb: jsonbFieldAccessTable.encrypted_jsonb }) + .from(jsonbFieldAccessTable) + .where(eq(jsonbFieldAccessTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + + // The encrypted value should be an object with encryption metadata + const encryptedValue = rawRow[0].encrypted_jsonb as Record + expect(encryptedValue).toBeDefined() + + // Should have ciphertext structure (c, k, or other encryption markers) + expect(encryptedValue).toHaveProperty('c') + }, 30000) +}) + +// ============================================================================= +// Decryption Verification Tests +// ============================================================================= + +describe('JSONB Field Access - Decryption Verification', () => { + it('should decrypt stored data correctly', async () => { + const results = await db + .select() + .from(jsonbFieldAccessTable) + .where(eq(jsonbFieldAccessTable.id, insertedId)) + + expect(results).toHaveLength(1) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Verify decrypted values match original standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + expect(decryptedJsonb).toBeDefined() + expect(decryptedJsonb!.string).toBe('hello') + expect(decryptedJsonb!.number).toBe(42) + expect(decryptedJsonb!.array_string).toEqual(['hello', 'world']) + expect(decryptedJsonb!.array_number).toEqual([42, 84]) + expect(decryptedJsonb!.nested.string).toBe('world') + expect(decryptedJsonb!.nested.number).toBe(1815) + }, 30000) + + it('should round-trip encrypt and decrypt preserving all fields', async () => { + // Fetch and decrypt all data + const results = await db + .select() + .from(jsonbFieldAccessTable) + .where(eq(jsonbFieldAccessTable.id, insertedId)) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Compare with original test data + const original = standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + + expect(decryptedJsonb).toEqual(original) + }, 30000) +}) diff --git a/packages/drizzle/__tests__/jsonb-path-operations.test.ts b/packages/drizzle/__tests__/jsonb-path-operations.test.ts new file mode 100644 index 00000000..bba365d7 --- /dev/null +++ b/packages/drizzle/__tests__/jsonb-path-operations.test.ts @@ -0,0 +1,793 @@ +/** + * JSONB Path Operations Tests + * + * Tests for JSONB path query operations through Drizzle ORM. + * These tests verify that the Drizzle integration correctly handles + * encrypted JSONB path operations matching the proxy test patterns. + * + * Reference: .work/jsonb-test-coverage/proxy-tests-reference.md + * - jsonb_path_exists.rs + * - jsonb_path_query.rs + * - jsonb_path_query_first.rs + */ +import 'dotenv/config' +import { protect, type QueryTerm } from '@cipherstash/protect' +import { csColumn, csTable } from '@cipherstash/schema' +import { eq, sql } from 'drizzle-orm' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { encryptedType, extractProtectSchema } from '../src/pg' +import { + createTestRunId, + standardJsonbData, + type StandardJsonbData, +} from './fixtures/jsonb-test-data' + +if (!process.env.DATABASE_URL) { + throw new Error('Missing env.DATABASE_URL') +} + +// ============================================================================= +// Schema Definitions +// ============================================================================= + +/** + * Drizzle table with encrypted JSONB column for path operations testing + */ +const jsonbPathOpsTable = pgTable('drizzle_jsonb_path_ops_test', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + encrypted_jsonb: encryptedType('encrypted_jsonb', { + searchableJson: true, + }), + createdAt: timestamp('created_at').defaultNow(), + testRunId: text('test_run_id'), +}) + +// Extract Protect.js schema from Drizzle table +const pathOpsSchema = extractProtectSchema(jsonbPathOpsTable) + +/** + * Protect.js schema with searchableJson for creating search terms + */ +const searchableSchema = csTable('drizzle_jsonb_path_ops_test', { + encrypted_jsonb: csColumn('encrypted_jsonb').searchableJson(), +}) + +// ============================================================================= +// Test Setup +// ============================================================================= + +const TEST_RUN_ID = createTestRunId('path-ops') + +let protectClient: Awaited> +let db: ReturnType +let insertedId: number + +beforeAll(async () => { + // Initialize Protect.js client + protectClient = await protect({ schemas: [pathOpsSchema, searchableSchema] }) + + const client = postgres(process.env.DATABASE_URL as string) + db = drizzle({ client }) + + // Drop and recreate test table to ensure correct column type + await db.execute(sql`DROP TABLE IF EXISTS drizzle_jsonb_path_ops_test`) + await db.execute(sql` + CREATE TABLE drizzle_jsonb_path_ops_test ( + id SERIAL PRIMARY KEY, + encrypted_jsonb eql_v2_encrypted, + created_at TIMESTAMP DEFAULT NOW(), + test_run_id TEXT + ) + `) + + // Encrypt and insert standard test data + const encrypted = await protectClient.encryptModel( + { encrypted_jsonb: standardJsonbData }, + pathOpsSchema, + ) + + if (encrypted.failure) { + throw new Error(`Encryption failed: ${encrypted.failure.message}`) + } + + const inserted = await db + .insert(jsonbPathOpsTable) + .values({ + ...encrypted.data, + testRunId: TEST_RUN_ID, + }) + .returning({ id: jsonbPathOpsTable.id }) + + insertedId = inserted[0].id +}, 60000) + +afterAll(async () => { + // Clean up test data + await db + .delete(jsonbPathOpsTable) + .where(eq(jsonbPathOpsTable.testRunId, TEST_RUN_ID)) +}, 30000) + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Verify the search term has selector-only format + */ +function expectJsonPathSelectorOnly(term: Record): void { + expect(term).toHaveProperty('s') + expect(typeof term.s).toBe('string') +} + +/** + * Verify the search term has path with value format + * Path+value queries return { sv: [...] } with the ste_vec entries + */ +function expectJsonPathWithValue(term: Record): void { + expect(term).toHaveProperty('sv') + expect(Array.isArray(term.sv)).toBe(true) + const sv = term.sv as Array> + expect(sv.length).toBeGreaterThan(0) +} + +// ============================================================================= +// jsonb_path_exists Tests +// ============================================================================= + +describe('JSONB Path Operations - jsonb_path_exists', () => { + it('should generate path exists selector for number field', async () => { + // SQL: jsonb_path_exists(encrypted_jsonb, '$.number') + const terms: QueryTerm[] = [ + { + path: 'number', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path exists failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path exists selector for nested string', async () => { + // SQL: jsonb_path_exists(encrypted_jsonb, '$.nested.string') + const terms: QueryTerm[] = [ + { + path: 'nested.string', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path exists failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path exists selector for nested object', async () => { + // SQL: jsonb_path_exists(encrypted_jsonb, '$.nested') + const terms: QueryTerm[] = [ + { + path: 'nested', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path exists failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path exists selector for unknown path', async () => { + // SQL: jsonb_path_exists(encrypted_jsonb, '$.vtha') -> false + // Client generates selector, proxy determines existence + const terms: QueryTerm[] = [ + { + path: 'unknown_path', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path exists failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path exists selector for array path', async () => { + // SQL: jsonb_path_exists(encrypted_jsonb, '$.array_string') + const terms: QueryTerm[] = [ + { + path: 'array_string', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path exists failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// jsonb_path_query Tests +// ============================================================================= + +describe('JSONB Path Operations - jsonb_path_query', () => { + it('should generate path query with number value', async () => { + // SQL: jsonb_path_query(encrypted_jsonb, '$.number') + const terms: QueryTerm[] = [ + { + path: 'number', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate path query with nested string value', async () => { + // SQL: jsonb_path_query(encrypted_jsonb, '$.nested.string') + const terms: QueryTerm[] = [ + { + path: 'nested.string', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate path query selector for nested object', async () => { + // SQL: jsonb_path_query(encrypted_jsonb, '$.nested') + const terms: QueryTerm[] = [ + { + path: 'nested', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path query selector for unknown path (empty set return)', async () => { + // SQL: jsonb_path_query(encrypted_jsonb, '$.vtha') + // Proxy returns empty set when path doesn't exist + const terms: QueryTerm[] = [ + { + path: 'unknown_deep.path.that.does.not.exist', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path query with nested number value', async () => { + // SQL: jsonb_path_query(encrypted_jsonb, '$.nested.number') + const terms: QueryTerm[] = [ + { + path: 'nested.number', + value: 1815, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// jsonb_path_query_first Tests +// ============================================================================= + +describe('JSONB Path Operations - jsonb_path_query_first', () => { + it('should generate path query first for array wildcard string', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.array_string[*]') + const terms: QueryTerm[] = [ + { + path: 'array_string[*]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query first failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate path query first for array wildcard number', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.array_number[*]') + const terms: QueryTerm[] = [ + { + path: 'array_number[*]', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query first failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate path query first for nested string', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.nested.string') + const terms: QueryTerm[] = [ + { + path: 'nested.string', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query first failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should generate path query first selector for nested object', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.nested') + const terms: QueryTerm[] = [ + { + path: 'nested', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query first failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path query first for unknown path (NULL return)', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.unknown_field') + // Proxy returns NULL when path doesn't exist + const terms: QueryTerm[] = [ + { + path: 'nonexistent_field_for_first', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query first failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should generate path query first with alternate wildcard notation', async () => { + // SQL: jsonb_path_query_first(encrypted_jsonb, '$.array_string[@]') + const terms: QueryTerm[] = [ + { + path: 'array_string[@]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Path query first failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Batch Path Operations Tests +// ============================================================================= + +describe('JSONB Path Operations - Batch Operations', () => { + it('should handle batch of path exists queries', async () => { + const paths = [ + 'number', + 'string', + 'nested', + 'nested.string', + 'nested.number', + 'array_string', + 'array_number', + ] + + const terms: QueryTerm[] = paths.map((path) => ({ + path, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + })) + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch path exists failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(paths.length) + for (const term of result.data) { + expectJsonPathSelectorOnly(term as Record) + } + }, 30000) + + it('should handle batch of path queries with values', async () => { + const terms: QueryTerm[] = [ + { + path: 'string', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'number', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'nested.string', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'nested.number', + value: 1815, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'array_string[*]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + { + path: 'array_number[*]', + value: 42, + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Batch path query failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(6) + for (const term of result.data) { + expectJsonPathWithValue(term as Record) + } + }, 30000) + + it('should handle mixed path operations in batch', async () => { + const terms: QueryTerm[] = [ + // Path exists (no value) + { + path: 'nested', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + // Path query with value + { + path: 'string', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + // Path query first with wildcard + { + path: 'array_string[*]', + value: 'world', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + // Unknown path + { + path: 'unknown_field', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Mixed batch failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(4) + // First and last are selector-only, middle two have values + expectJsonPathSelectorOnly(result.data[0] as Record) + expectJsonPathWithValue(result.data[1] as Record) + expectJsonPathWithValue(result.data[2] as Record) + expectJsonPathSelectorOnly(result.data[3] as Record) + }, 30000) +}) + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe('JSONB Path Operations - Edge Cases', () => { + it('should handle multiple array wildcards in path', async () => { + // SQL pattern: $.matrix[*][*] + const terms: QueryTerm[] = [ + { + path: 'matrix[@][@]', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Multiple wildcards failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathSelectorOnly(result.data[0] as Record) + }, 30000) + + it('should handle complex nested array path', async () => { + // SQL pattern: $.users[*].orders[*].items[0].name + const terms: QueryTerm[] = [ + { + path: 'users[@].orders[@].items[0].name', + value: 'Widget', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Complex path failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle very deep nesting (10+ levels)', async () => { + const terms: QueryTerm[] = [ + { + path: 'a.b.c.d.e.f.g.h.i.j.k.l', + value: 'deep_value', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Deep nesting failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) + + it('should handle array index access', async () => { + // Access specific array index: $.array_string[0] + const terms: QueryTerm[] = [ + { + path: 'array_string[0]', + value: 'hello', + column: searchableSchema.encrypted_jsonb, + table: searchableSchema, + }, + ] + + const result = await protectClient.encryptQuery(terms) + + if (result.failure) { + throw new Error(`Array index failed: ${result.failure.message}`) + } + + expect(result.data).toHaveLength(1) + expectJsonPathWithValue(result.data[0] as Record) + }, 30000) +}) + +// ============================================================================= +// Encryption Verification Tests +// ============================================================================= + +describe('JSONB Path Operations - Encryption Verification', () => { + it('should store encrypted data (not plaintext)', async () => { + // Query raw value from database + const rawRow = await db + .select({ encrypted_jsonb: sql`encrypted_jsonb::text` }) + .from(jsonbPathOpsTable) + .where(eq(jsonbPathOpsTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + const rawValue = rawRow[0].encrypted_jsonb + + // Should NOT contain plaintext values from standardJsonbData + expect(rawValue).not.toContain('"string":"hello"') + expect(rawValue).not.toContain('"number":42') + expect(rawValue).not.toContain('"nested":{"number":1815') + + // Should have encrypted structure (c = ciphertext indicator) + expect(rawValue).toContain('"c"') + }, 30000) + + it('should have encrypted structure with expected fields', async () => { + // Query raw encrypted data + const rawRow = await db + .select({ encrypted_jsonb: jsonbPathOpsTable.encrypted_jsonb }) + .from(jsonbPathOpsTable) + .where(eq(jsonbPathOpsTable.id, insertedId)) + + expect(rawRow).toHaveLength(1) + + // The encrypted value should be an object with encryption metadata + const encryptedValue = rawRow[0].encrypted_jsonb as Record + expect(encryptedValue).toBeDefined() + + // Should have ciphertext structure + expect(encryptedValue).toHaveProperty('c') + }, 30000) +}) + +// ============================================================================= +// Decryption Verification Tests +// ============================================================================= + +describe('JSONB Path Operations - Decryption Verification', () => { + it('should decrypt stored data correctly', async () => { + const results = await db + .select() + .from(jsonbPathOpsTable) + .where(eq(jsonbPathOpsTable.id, insertedId)) + + expect(results).toHaveLength(1) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Verify decrypted values match original standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + expect(decryptedJsonb).toBeDefined() + expect(decryptedJsonb!.string).toBe('hello') + expect(decryptedJsonb!.number).toBe(42) + expect(decryptedJsonb!.array_string).toEqual(['hello', 'world']) + expect(decryptedJsonb!.array_number).toEqual([42, 84]) + expect(decryptedJsonb!.nested.string).toBe('world') + expect(decryptedJsonb!.nested.number).toBe(1815) + }, 30000) + + it('should round-trip encrypt and decrypt preserving all fields', async () => { + // Fetch and decrypt all data + const results = await db + .select() + .from(jsonbPathOpsTable) + .where(eq(jsonbPathOpsTable.id, insertedId)) + + const decrypted = await protectClient.decryptModel(results[0]) + if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) + } + + // Compare with original test data + const original = standardJsonbData + const decryptedJsonb = decrypted.data.encrypted_jsonb + + expect(decryptedJsonb).toEqual(original) + }, 30000) +}) diff --git a/packages/drizzle/src/pg/index.ts b/packages/drizzle/src/pg/index.ts index f8f8bce2..c4b42c75 100644 --- a/packages/drizzle/src/pg/index.ts +++ b/packages/drizzle/src/pg/index.ts @@ -23,6 +23,12 @@ export type EncryptedColumnConfig = { * Enable order and range index for sorting and range queries. */ orderAndRange?: boolean + /** + * Enable searchable JSON index for JSONB containment and path queries. + * When enabled, this automatically sets dataType to 'json' and configures + * the ste_vec index required for path selection (->, ->>) and containment (@>, <@) queries. + */ + searchableJson?: boolean } /** diff --git a/packages/drizzle/src/pg/schema-extraction.ts b/packages/drizzle/src/pg/schema-extraction.ts index a655e07c..664f2217 100644 --- a/packages/drizzle/src/pg/schema-extraction.ts +++ b/packages/drizzle/src/pg/schema-extraction.ts @@ -91,6 +91,10 @@ export function extractProtectSchema>( } } + if (config.searchableJson) { + csCol.searchableJson() + } + columns[actualColumnName] = csCol } }