From 587f222d0490fadcecbb27cdf3005c700a68e0d6 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 9 Jun 2025 09:20:29 -0600 Subject: [PATCH 1/6] feat(protect): add support for deeply nested protect schemas --- .changeset/smart-worlds-wait.md | 5 + docs/reference/schema.md | 29 ++ .../protect/__tests__/nested-models.test.ts | 242 ++++++++++++++++ packages/protect/__tests__/schema.test.ts | 145 +++++++++ packages/protect/src/ffi/model-helpers.ts | 274 +++++++++++++++--- packages/protect/src/schema/index.ts | 43 ++- 6 files changed, 688 insertions(+), 50 deletions(-) create mode 100644 .changeset/smart-worlds-wait.md create mode 100644 packages/protect/__tests__/nested-models.test.ts create mode 100644 packages/protect/__tests__/schema.test.ts diff --git a/.changeset/smart-worlds-wait.md b/.changeset/smart-worlds-wait.md new file mode 100644 index 00000000..3b46d691 --- /dev/null +++ b/.changeset/smart-worlds-wait.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/protect": minor +--- + +Added support for deeply nested protect schemas to support more complex model objects. diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 6a57b3ad..88fd0a06 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -8,6 +8,7 @@ Protect.js lets you define a schema in TypeScript with properties that map to yo - [Understanding schema files](#understanding-schema-files) - [Defining your schema](#defining-your-schema) - [Searchable encryption](#searchable-encryption) + - [Nested objects](#nested-objects) - [Available index options](#available-index-options) - [Initializing the Protect client](#initializing-the-protect-client) @@ -75,6 +76,34 @@ export const protectedUsers = csTable("users", { }); ``` +### Nested objects + +Protect.js supports nested objects in your schema, allowing you to encrypt and search on nested properties. You can define nested objects up to 3 levels deep. + +```ts +import { csTable, csColumn } from "@cipherstash/protect"; + +export const protectedUsers = csTable("users", { + email: csColumn("email").freeTextSearch().equality().orderAndRange(), + profile: { + name: csColumn("name").freeTextSearch(), + address: { + street: csColumn("street").freeTextSearch(), + location: { + coordinates: csColumn("coordinates").equality(), + }, + }, + }, +}); +``` + +When working with nested objects: +- Each level can have its own encrypted fields +- Index options can be applied to any level of nesting +- The maximum nesting depth is 3 levels +- Null and undefined values are supported at any level +- Optional nested objects are supported + ## Available index options The following index options are available for your schema: diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts new file mode 100644 index 00000000..999740aa --- /dev/null +++ b/packages/protect/__tests__/nested-models.test.ts @@ -0,0 +1,242 @@ +import 'dotenv/config' +import { describe, expect, it, vi } from 'vitest' + +import { LockContext, protect, csTable, csColumn } from '../src' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + address: csColumn('address').freeTextSearch(), + example: { + field: csColumn('field').freeTextSearch(), + nested: { + deeper: csColumn('deeper').freeTextSearch(), + }, + }, +}) + +type User = { + id: string + email?: string | null + createdAt?: Date + updatedAt?: Date + address?: string | null + notEncrypted?: string | null + example: { + field: string | undefined | null + nested?: { + deeper: string | undefined | null + } + } +} + +describe('encrypt models with nested fields', () => { + it('should encrypt and decrypt a model with nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + address: '123 Main St', + notEncrypted: 'not encrypted', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + example: { + field: 'test', + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + console.log('data that is encrypted', encryptedModel.data.example.field) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle null values in nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '2', + email: null, + address: null, + example: { + field: null, + nested: { + deeper: null, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle undefined values in nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '3', + example: { + field: undefined, + nested: { + deeper: undefined, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle mixed null and undefined values in nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '4', + email: 'test@example.com', + address: undefined, + notEncrypted: 'not encrypted', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + example: { + field: null, + nested: { + deeper: undefined, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + console.log('data that is encrypted', encryptedModel.data) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + console.log('data that is decrypted', decryptedResult.data) + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle deeply nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '3', + example: { + field: 'outer', + nested: { + deeper: 'inner value', + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle missing optional nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '5', + example: { + field: 'present', + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) +}) diff --git a/packages/protect/__tests__/schema.test.ts b/packages/protect/__tests__/schema.test.ts new file mode 100644 index 00000000..913d3f04 --- /dev/null +++ b/packages/protect/__tests__/schema.test.ts @@ -0,0 +1,145 @@ +import { csColumn, csTable, buildEncryptConfig } from '../src/schema' +import { describe, it, expect } from 'vitest' + +describe('Schema with nested columns', () => { + it('should handle nested column structures in encrypt config', () => { + const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + address: csColumn('address').freeTextSearch(), + example: { + field: csColumn('field').freeTextSearch(), + nested: { + deep: csColumn('deep').equality(), + }, + }, + } as const) + + const config = buildEncryptConfig(users) + + // Verify basic structure + expect(config).toEqual({ + v: 2, + tables: { + users: expect.any(Object), + }, + }) + + // Verify all columns are present with correct names + const columns = config.tables.users + expect(Object.keys(columns)).toEqual([ + 'email', + 'address', + 'example.field', + 'example.nested.deep', + ]) + + // Verify email column configuration + expect(columns.email).toEqual({ + cast_as: 'text', + indexes: { + match: expect.any(Object), + unique: expect.any(Object), + ore: {}, + }, + }) + + // Verify address column configuration + expect(columns.address).toEqual({ + cast_as: 'text', + indexes: { + match: expect.any(Object), + }, + }) + + // Verify nested field configuration + expect(columns['example.field']).toEqual({ + cast_as: 'text', + indexes: { + match: expect.any(Object), + }, + }) + + // Verify deeply nested field configuration + expect(columns['example.nested.deep']).toEqual({ + cast_as: 'text', + indexes: { + unique: expect.any(Object), + }, + }) + }) + + it('should handle multiple tables with nested columns', () => { + const users = csTable('users', { + email: csColumn('email').equality(), + profile: { + name: csColumn('name').freeTextSearch(), + }, + } as const) + + const posts = csTable('posts', { + title: csColumn('title').freeTextSearch(), + metadata: { + tags: csColumn('tags').equality(), + }, + } as const) + + const config = buildEncryptConfig(users, posts) + + // Verify both tables are present + expect(Object.keys(config.tables)).toEqual(['users', 'posts']) + + // Verify users table columns + expect(Object.keys(config.tables.users)).toEqual(['email', 'profile.name']) + expect(config.tables.users.email.indexes).toHaveProperty('unique') + expect(config.tables.users['profile.name'].indexes).toHaveProperty('match') + + // Verify posts table columns + expect(Object.keys(config.tables.posts)).toEqual(['title', 'metadata.tags']) + expect(config.tables.posts.title.indexes).toHaveProperty('match') + expect(config.tables.posts['metadata.tags'].indexes).toHaveProperty( + 'unique', + ) + }) + + it('should handle complex nested structures with multiple index types', () => { + const complex = csTable('complex', { + id: csColumn('id').equality(), + content: { + text: csColumn('text').freeTextSearch().orderAndRange(), + metadata: { + tags: csColumn('tags').equality().freeTextSearch(), + stats: { + views: csColumn('views').orderAndRange(), + }, + }, + }, + } as const) + + const config = buildEncryptConfig(complex) + + // Verify all columns are present + expect(Object.keys(config.tables.complex)).toEqual([ + 'id', + 'content.text', + 'content.metadata.tags', + 'content.metadata.stats.views', + ]) + + // Verify complex nested column with multiple indexes + expect(config.tables.complex['content.metadata.tags']).toEqual({ + cast_as: 'text', + indexes: { + unique: expect.any(Object), + match: expect.any(Object), + }, + }) + + // Verify deeply nested column with order and range + expect(config.tables.complex['content.metadata.stats.views']).toEqual({ + cast_as: 'text', + indexes: { + ore: {}, + }, + }) + }) +}) diff --git a/packages/protect/src/ffi/model-helpers.ts b/packages/protect/src/ffi/model-helpers.ts index 390b15b2..a400a092 100644 --- a/packages/protect/src/ffi/model-helpers.ts +++ b/packages/protect/src/ffi/model-helpers.ts @@ -136,22 +136,51 @@ function prepareFieldsForOperation>( const keyMap: Record = {} let index = 0 - const fieldsToProcess = table - ? Object.entries(model).filter(([key]) => - Object.keys(table.build().columns).includes(key), - ) - : Object.entries(extractEncryptedFields(model)) - - for (const [key, value] of fieldsToProcess) { - if (value === null || value === undefined) { - nullFields[key] = value === undefined ? undefined : null - continue + const processNestedFields = ( + obj: Record, + prefix = '', + isEncrypted = false, + ) => { + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + + if (value === null || value === undefined) { + nullFields[fullKey] = value + continue + } + + if (typeof value === 'object' && !isEncryptedPayload(value)) { + // Recursively process nested objects + processNestedFields( + value as Record, + fullKey, + isEncrypted, + ) + } else if (isEncrypted || isEncryptedPayload(value)) { + // This is an encrypted field or we're in an encrypted object + const id = index.toString() + keyMap[id] = fullKey + operationFields[fullKey] = value + index++ + } } + } - const id = index.toString() - keyMap[id] = key - operationFields[key] = value - index++ + if (table) { + // Get all column paths from the table schema + const columnPaths = Object.keys(table.build().columns) + + // Process only fields that match the column paths + for (const [key, value] of Object.entries(model)) { + if ( + columnPaths.some((path) => path === key || path.startsWith(`${key}.`)) + ) { + processNestedFields({ [key]: value }, '', true) + } + } + } else { + // Process all encrypted fields + processNestedFields(model) } return { otherFields, operationFields, keyMap, nullFields } @@ -184,7 +213,39 @@ export async function decryptModelFields>( keyMap, ) - return { ...otherFields, ...nullFields, ...decryptedFields } as Decrypted + // Helper function to set a nested value + const setNestedValue = ( + obj: Record, + path: string[], + value: unknown, + ) => { + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const part = path[i] + if (!(part in current)) { + current[part] = {} + } + current = current[part] as Record + } + current[path[path.length - 1]] = value + } + + // Reconstruct the object with proper nesting + const result: Record = { ...otherFields } + + // First, reconstruct the null/undefined fields + for (const [key, value] of Object.entries(nullFields)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + // Then, reconstruct the decrypted fields + for (const [key, value] of Object.entries(decryptedFields)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + return result as Decrypted } /** @@ -217,7 +278,39 @@ export async function encryptModelFields>( keyMap, ) - return { ...otherFields, ...nullFields, ...encryptedData } as T + // Helper function to set a nested value + const setNestedValue = ( + obj: Record, + path: string[], + value: unknown, + ) => { + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const part = path[i] + if (!(part in current)) { + current[part] = {} + } + current = current[part] as Record + } + current[path[path.length - 1]] = value + } + + // Reconstruct the object with proper nesting + const result: Record = { ...otherFields } + + // First, reconstruct the null/undefined fields + for (const [key, value] of Object.entries(nullFields)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + // Then, reconstruct the encrypted fields + for (const [key, value] of Object.entries(encryptedData)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + return result as T } /** @@ -323,22 +416,51 @@ function prepareBulkModelsForOperation>( const modelOperationFields: Record = {} const modelNullFields: Record = {} - const fieldsToProcess = table - ? Object.entries(model).filter(([key]) => - Object.keys(table.build().columns).includes(key), - ) - : Object.entries(extractEncryptedFields(model)) - - for (const [key, value] of fieldsToProcess) { - if (value === null || value === undefined) { - modelNullFields[key] = value === undefined ? undefined : null - continue + const processNestedFields = ( + obj: Record, + prefix = '', + isEncrypted = false, + ) => { + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + + if (value === null || value === undefined) { + modelNullFields[fullKey] = value === undefined ? undefined : null + continue + } + + if (typeof value === 'object' && !isEncryptedPayload(value)) { + // Recursively process nested objects + processNestedFields( + value as Record, + fullKey, + isEncrypted, + ) + } else if (isEncrypted || isEncryptedPayload(value)) { + // This is an encrypted field or we're in an encrypted object + const id = index.toString() + keyMap[id] = { modelIndex, fieldKey: fullKey } + modelOperationFields[fullKey] = value + index++ + } } + } - const id = index.toString() - keyMap[id] = { modelIndex, fieldKey: key } - modelOperationFields[key] = value - index++ + if (table) { + // Get all column paths from the table schema + const columnPaths = Object.keys(table.build().columns) + + // Process only fields that match the column paths + for (const [key, value] of Object.entries(model)) { + if ( + columnPaths.some((path) => path === key || path.startsWith(`${key}.`)) + ) { + processNestedFields({ [key]: value }, '', true) + } + } + } else { + // Process all encrypted fields + processNestedFields(model) } otherFields.push(modelOtherFields) @@ -368,7 +490,6 @@ export async function bulkEncryptModels>( const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models, table) - // Collect all fields that need to be encrypted into a single array const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, @@ -378,18 +499,40 @@ export async function bulkEncryptModels>( })), ) - // Make a single FFI call for all fields const encryptedData = await handleMultiModelBulkOperation( bulkEncryptPayload, (items) => encryptBulk(client, items), keyMap, ) - // Reconstruct models - return models.map((_, modelIndex) => ({ - ...otherFields[modelIndex], - ...nullFields[modelIndex], - ...Object.fromEntries( + // Helper function to set a nested value + const setNestedValue = ( + obj: Record, + path: string[], + value: unknown, + ) => { + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const part = path[i] + if (!(part in current)) { + current[part] = {} + } + current = current[part] as Record + } + current[path[path.length - 1]] = value + } + + return models.map((_, modelIndex) => { + const result: Record = { ...otherFields[modelIndex] } + + // First, reconstruct the null/undefined fields + for (const [key, value] of Object.entries(nullFields[modelIndex])) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + // Then, reconstruct the encrypted fields + const modelData = Object.fromEntries( Object.entries(encryptedData) .filter(([key]) => { const [idx] = key.split('-') @@ -399,8 +542,15 @@ export async function bulkEncryptModels>( const [_, fieldKey] = key.split('-') return [fieldKey, value] }), - ), - })) as T[] + ) + + for (const [key, value] of Object.entries(modelData)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + return result as T + }) } /** @@ -421,7 +571,6 @@ export async function bulkDecryptModels>( const { otherFields, operationFields, keyMap, nullFields } = prepareBulkModelsForOperation(models) - // Collect all fields that need to be decrypted into a single array const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, @@ -429,18 +578,40 @@ export async function bulkDecryptModels>( })), ) - // Make a single FFI call for all fields const decryptedFields = await handleMultiModelBulkOperation( bulkDecryptPayload, (items) => decryptBulk(client, items), keyMap, ) - // Reconstruct models - return models.map((_, modelIndex) => ({ - ...otherFields[modelIndex], - ...nullFields[modelIndex], - ...Object.fromEntries( + // Helper function to set a nested value + const setNestedValue = ( + obj: Record, + path: string[], + value: unknown, + ) => { + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const part = path[i] + if (!(part in current)) { + current[part] = {} + } + current = current[part] as Record + } + current[path[path.length - 1]] = value + } + + return models.map((_, modelIndex) => { + const result: Record = { ...otherFields[modelIndex] } + + // First, reconstruct the null/undefined fields + for (const [key, value] of Object.entries(nullFields[modelIndex])) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + // Then, reconstruct the decrypted fields + const modelData = Object.fromEntries( Object.entries(decryptedFields) .filter(([key]) => { const [idx] = key.split('-') @@ -450,8 +621,15 @@ export async function bulkDecryptModels>( const [_, fieldKey] = key.split('-') return [fieldKey, value] }), - ), - })) as Decrypted[] + ) + + for (const [key, value] of Object.entries(modelData)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + return result as Decrypted + }) } /** diff --git a/packages/protect/src/schema/index.ts b/packages/protect/src/schema/index.ts index 215f68b5..3bd78b35 100644 --- a/packages/protect/src/schema/index.ts +++ b/packages/protect/src/schema/index.ts @@ -88,7 +88,21 @@ type UniqueIndexOpts = z.infer type OreIndexOpts = z.infer type ColumnSchema = z.infer -export type ProtectTableColumn = Record +export type ProtectTableColumn = { + [key: string]: + | ProtectColumn + | { + [key: string]: + | ProtectColumn + | { + [key: string]: + | ProtectColumn + | { + [key: string]: ProtectColumn + } + } + } +} export type EncryptConfig = z.infer // ------------------------ @@ -190,8 +204,33 @@ export class ProtectTable { */ build(): TableDefinition { const builtColumns: Record = {} + + const processColumn = ( + builder: + | ProtectColumn + | Record< + string, + | ProtectColumn + | Record< + string, + | ProtectColumn + | Record> + > + >, + prefix = '', + ) => { + if (builder instanceof ProtectColumn) { + builtColumns[prefix] = builder.build() + } else { + for (const [key, value] of Object.entries(builder)) { + const newPrefix = prefix ? `${prefix}.${key}` : key + processColumn(value, newPrefix) + } + } + } + for (const [colName, builder] of Object.entries(this.columnBuilders)) { - builtColumns[colName] = builder.build() + processColumn(builder, colName) } return { From 07cba22e8d630ca7bcf6d853b44e90df110a2238 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 9 Jun 2025 09:25:23 -0600 Subject: [PATCH 2/6] chore(protect): add tests for bulk interface with nested fields --- .../protect/__tests__/nested-models.test.ts | 168 +++++++++++++++++- 1 file changed, 162 insertions(+), 6 deletions(-) diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts index 999740aa..46646da5 100644 --- a/packages/protect/__tests__/nested-models.test.ts +++ b/packages/protect/__tests__/nested-models.test.ts @@ -54,8 +54,6 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } - console.log('data that is encrypted', encryptedModel.data.example.field) - const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -162,8 +160,6 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } - console.log('data that is encrypted', encryptedModel.data) - const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -172,8 +168,6 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${decryptedResult.failure.message}`) } - console.log('data that is decrypted', decryptedResult.data) - expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) @@ -239,4 +233,166 @@ describe('encrypt models with nested fields', () => { expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) + + describe('bulk operations with nested fields', () => { + it('should handle bulk encryption and decryption of models with nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [ + { + id: '1', + email: 'test1@example.com', + example: { + field: 'test1', + nested: { + deeper: 'value1', + }, + }, + }, + { + id: '2', + email: 'test2@example.com', + example: { + field: 'test2', + nested: { + deeper: 'value2', + }, + }, + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual(decryptedModels) + }, 30000) + + it('should handle bulk operations with null and undefined values in nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [ + { + id: '1', + email: null, + example: { + field: null, + nested: { + deeper: undefined, + }, + }, + }, + { + id: '2', + email: undefined, + example: { + field: undefined, + nested: { + deeper: null, + }, + }, + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual(decryptedModels) + }, 30000) + + it('should handle bulk operations with missing optional nested fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [ + { + id: '1', + email: 'test1@example.com', + example: { + field: 'test1', + }, + }, + { + id: '2', + email: 'test2@example.com', + example: { + field: 'test2', + nested: { + deeper: 'value2', + }, + }, + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual(decryptedModels) + }, 30000) + + it('should handle empty array in bulk operations', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual([]) + }, 30000) + }) }) From 09e47a12992b1a222bbbfbc2452e3c6dfb0eb3f5 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 10 Jun 2025 08:34:30 -0600 Subject: [PATCH 3/6] feat(protect): implement nested schemas with csValue --- docs/reference/schema.md | 23 ++++++-- .../protect/__tests__/nested-models.test.ts | 30 +++++++++- packages/protect/__tests__/schema.test.ts | 37 ++++-------- packages/protect/src/ffi/index.ts | 2 + .../protect/src/ffi/operations/encrypt.ts | 5 +- packages/protect/src/index.ts | 9 ++- packages/protect/src/schema/index.ts | 56 +++++++++++++++---- packages/protect/src/types.ts | 4 +- 8 files changed, 116 insertions(+), 50 deletions(-) diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 88fd0a06..b828bdf4 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -78,19 +78,26 @@ export const protectedUsers = csTable("users", { ### Nested objects -Protect.js supports nested objects in your schema, allowing you to encrypt and search on nested properties. You can define nested objects up to 3 levels deep. +Protect.js supports nested objects in your schema, allowing you to encrypt **but not search on** nested properties. You can define nested objects up to 3 levels deep. +This is useful for data stores that have less structured data, like NoSQL databases. + +You can define nested objects by using the `csValue` function to define a value in a nested object. The value naming convention of the `csValue` function is a dot-separated string of the nested object path, e.g. `profile.name` or `profile.address.street`. + +> [!NOTE] +> Using nested objects is not recommended for SQL databases, as it will not be searchable. +> You should either use a JSON data type and encrypt the entire object, or use a separate column for each nested property. ```ts -import { csTable, csColumn } from "@cipherstash/protect"; +import { csTable, csColumn, csValue } from "@cipherstash/protect"; export const protectedUsers = csTable("users", { email: csColumn("email").freeTextSearch().equality().orderAndRange(), profile: { - name: csColumn("name").freeTextSearch(), + name: csValue("profile.name"), address: { - street: csColumn("street").freeTextSearch(), + street: csValue("profile.address.street"), location: { - coordinates: csColumn("coordinates").equality(), + coordinates: csValue("profile.address.location.coordinates"), }, }, }, @@ -98,12 +105,16 @@ export const protectedUsers = csTable("users", { ``` When working with nested objects: +- Searchable encryption is not supported on nested objects - Each level can have its own encrypted fields -- Index options can be applied to any level of nesting - The maximum nesting depth is 3 levels - Null and undefined values are supported at any level - Optional nested objects are supported +> [!WARNING] +> TODO: The schema builder does not validate the values you supply to the `csValue` or `csColumn` functions. +> These values are meant to be unique, and and cause unexpected behavior if they are not defined correctly. + ## Available index options The following index options are available for your schema: diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts index 46646da5..5ba4499c 100644 --- a/packages/protect/__tests__/nested-models.test.ts +++ b/packages/protect/__tests__/nested-models.test.ts @@ -1,15 +1,16 @@ import 'dotenv/config' import { describe, expect, it, vi } from 'vitest' -import { LockContext, protect, csTable, csColumn } from '../src' +import { LockContext, protect, csTable, csColumn, csValue } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), + name: csColumn('name').freeTextSearch(), example: { - field: csColumn('field').freeTextSearch(), + field: csValue('example.field'), nested: { - deeper: csColumn('deeper').freeTextSearch(), + deeper: csValue('example.nested.deeper'), }, }, }) @@ -30,6 +31,29 @@ type User = { } describe('encrypt models with nested fields', () => { + it('should encrypt and decrypt a single value from a nested schema', async () => { + const protectClient = await protect({ schemas: [users] }) + + const encryptResponse = await protectClient.encrypt('hello world', { + column: users.example.field, + table: users, + }) + + if (encryptResponse.failure) { + throw new Error(`[protect]: ${encryptResponse.failure.message}`) + } + + const decryptResponse = await protectClient.decrypt(encryptResponse.data) + + if (decryptResponse.failure) { + throw new Error(`[protect]: ${decryptResponse.failure.message}`) + } + + expect(decryptResponse).toEqual({ + data: 'hello world', + }) + }) + it('should encrypt and decrypt a model with nested fields', async () => { const protectClient = await protect({ schemas: [users] }) diff --git a/packages/protect/__tests__/schema.test.ts b/packages/protect/__tests__/schema.test.ts index 913d3f04..dd5a8677 100644 --- a/packages/protect/__tests__/schema.test.ts +++ b/packages/protect/__tests__/schema.test.ts @@ -1,4 +1,4 @@ -import { csColumn, csTable, buildEncryptConfig } from '../src/schema' +import { csColumn, csTable, buildEncryptConfig, csValue } from '../src/schema' import { describe, it, expect } from 'vitest' describe('Schema with nested columns', () => { @@ -7,9 +7,9 @@ describe('Schema with nested columns', () => { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), example: { - field: csColumn('field').freeTextSearch(), + field: csValue('example.field'), nested: { - deep: csColumn('deep').equality(), + deep: csValue('example.nested.deep'), }, }, } as const) @@ -54,17 +54,13 @@ describe('Schema with nested columns', () => { // Verify nested field configuration expect(columns['example.field']).toEqual({ cast_as: 'text', - indexes: { - match: expect.any(Object), - }, + indexes: {}, }) // Verify deeply nested field configuration expect(columns['example.nested.deep']).toEqual({ cast_as: 'text', - indexes: { - unique: expect.any(Object), - }, + indexes: {}, }) }) @@ -72,14 +68,14 @@ describe('Schema with nested columns', () => { const users = csTable('users', { email: csColumn('email').equality(), profile: { - name: csColumn('name').freeTextSearch(), + name: csValue('profile.name'), }, } as const) const posts = csTable('posts', { title: csColumn('title').freeTextSearch(), metadata: { - tags: csColumn('tags').equality(), + tags: csValue('metadata.tags'), }, } as const) @@ -91,25 +87,21 @@ describe('Schema with nested columns', () => { // Verify users table columns expect(Object.keys(config.tables.users)).toEqual(['email', 'profile.name']) expect(config.tables.users.email.indexes).toHaveProperty('unique') - expect(config.tables.users['profile.name'].indexes).toHaveProperty('match') // Verify posts table columns expect(Object.keys(config.tables.posts)).toEqual(['title', 'metadata.tags']) expect(config.tables.posts.title.indexes).toHaveProperty('match') - expect(config.tables.posts['metadata.tags'].indexes).toHaveProperty( - 'unique', - ) }) it('should handle complex nested structures with multiple index types', () => { const complex = csTable('complex', { id: csColumn('id').equality(), content: { - text: csColumn('text').freeTextSearch().orderAndRange(), + text: csValue('content.text'), metadata: { - tags: csColumn('tags').equality().freeTextSearch(), + tags: csValue('content.metadata.tags'), stats: { - views: csColumn('views').orderAndRange(), + views: csValue('content.metadata.stats.views'), }, }, }, @@ -128,18 +120,13 @@ describe('Schema with nested columns', () => { // Verify complex nested column with multiple indexes expect(config.tables.complex['content.metadata.tags']).toEqual({ cast_as: 'text', - indexes: { - unique: expect.any(Object), - match: expect.any(Object), - }, + indexes: {}, }) // Verify deeply nested column with order and range expect(config.tables.complex['content.metadata.stats.views']).toEqual({ cast_as: 'text', - indexes: { - ore: {}, - }, + indexes: {}, }) }) }) diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index 40ac4232..ab749776 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -68,6 +68,8 @@ export class ProtectClient { client_key: config.clientKey, }) + console.log('config', validated) + this.client = await newClient( JSON.stringify(validated), newClientConfig, diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts index 48f0f862..336c0294 100644 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ b/packages/protect/src/ffi/operations/encrypt.ts @@ -12,6 +12,7 @@ import type { } from '../../types' import type { ProtectColumn, + ProtectValue, ProtectTable, ProtectTableColumn, } from '../../schema' @@ -21,7 +22,7 @@ export class EncryptOperation { private client: Client private plaintext: EncryptPayload - private column: ProtectColumn + private column: ProtectColumn | ProtectValue private table: ProtectTable constructor(client: Client, plaintext: EncryptPayload, opts: EncryptOptions) { @@ -86,7 +87,7 @@ export class EncryptOperation public getOperation(): { client: Client plaintext: EncryptPayload - column: ProtectColumn + column: ProtectColumn | ProtectValue table: ProtectTable } { return { diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts index 6a7b4e1b..0deb7832 100644 --- a/packages/protect/src/index.ts +++ b/packages/protect/src/index.ts @@ -60,8 +60,13 @@ export const protect = async ( export type { Result } from '@byteslice/result' export type { ProtectClient } from './ffi' -export { csTable, csColumn } from './schema' -export type { ProtectColumn, ProtectTable, ProtectTableColumn } from './schema' +export { csTable, csColumn, csValue } from './schema' +export type { + ProtectColumn, + ProtectTable, + ProtectTableColumn, + ProtectValue, +} from './schema' export * from './helpers' export * from './identify' export * from './types' diff --git a/packages/protect/src/schema/index.ts b/packages/protect/src/schema/index.ts index 3bd78b35..1431403f 100644 --- a/packages/protect/src/schema/index.ts +++ b/packages/protect/src/schema/index.ts @@ -93,12 +93,12 @@ export type ProtectTableColumn = { | ProtectColumn | { [key: string]: - | ProtectColumn + | ProtectValue | { [key: string]: - | ProtectColumn + | ProtectValue | { - [key: string]: ProtectColumn + [key: string]: ProtectValue } } } @@ -108,6 +108,35 @@ export type EncryptConfig = z.infer // ------------------------ // Interface definitions // ------------------------ +export class ProtectValue { + private valueName: string + private castAsValue: CastAs + + constructor(valueName: string) { + this.valueName = valueName + this.castAsValue = 'text' + } + + /** + * Set or override the cast_as value. + */ + dataType(castAs: CastAs) { + this.castAsValue = castAs + return this + } + + build() { + return { + cast_as: this.castAsValue, + indexes: {}, + } + } + + getName() { + return this.valueName + } +} + export class ProtectColumn { private columnName: string private castAsValue: CastAs @@ -210,21 +239,24 @@ export class ProtectTable { | ProtectColumn | Record< string, - | ProtectColumn + | ProtectValue | Record< string, - | ProtectColumn - | Record> + | ProtectValue + | Record> > >, - prefix = '', + colName: string, ) => { if (builder instanceof ProtectColumn) { - builtColumns[prefix] = builder.build() + builtColumns[colName] = builder.build() } else { for (const [key, value] of Object.entries(builder)) { - const newPrefix = prefix ? `${prefix}.${key}` : key - processColumn(value, newPrefix) + if (value instanceof ProtectValue) { + builtColumns[value.getName()] = value.build() + } else { + processColumn(value, key) + } } } } @@ -261,6 +293,10 @@ export function csColumn(columnName: string) { return new ProtectColumn(columnName) } +export function csValue(valueName: string) { + return new ProtectValue(valueName) +} + // ------------------------ // Internal functions // ------------------------ diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index 2c43c3dc..bea3f5d2 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -1,5 +1,5 @@ import type { newClient, Encrypted } from '@cipherstash/protect-ffi' -import type { ProtectTableColumn } from './schema' +import type { ProtectTableColumn, ProtectValue } from './schema' import type { ProtectTable } from './schema' import type { ProtectColumn } from './schema' @@ -41,7 +41,7 @@ export type EncryptPayload = string | null * Represents the options for encrypting a payload using the `encrypt` function */ export type EncryptOptions = { - column: ProtectColumn + column: ProtectColumn | ProtectValue table: ProtectTable } From f9c0abba9e71ccb5847f4bc988d32ddc1c29554e Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 12:56:10 -0600 Subject: [PATCH 4/6] feat(protect): tests and support for mixture of plaintext and encrypted nested values --- .../protect/__tests__/nested-models.test.ts | 445 ++++++++++++++++++ packages/protect/src/ffi/index.ts | 2 - packages/protect/src/ffi/model-helpers.ts | 256 +++++++--- .../src/ffi/operations/decrypt-model.ts | 16 +- 4 files changed, 659 insertions(+), 60 deletions(-) diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts index 5ba4499c..3966b69a 100644 --- a/packages/protect/__tests__/nested-models.test.ts +++ b/packages/protect/__tests__/nested-models.test.ts @@ -26,6 +26,19 @@ type User = { field: string | undefined | null nested?: { deeper: string | undefined | null + plaintext?: string | undefined | null + notInSchema?: { + deeper: string | undefined | null + } + deeperNotInSchema?: string | undefined | null + extra?: { + plaintext: string | undefined | null + } + } + plaintext?: string | undefined | null + fieldNotInSchema?: string | undefined | null + notInSchema?: { + deeper: string | undefined | null } } } @@ -420,3 +433,435 @@ describe('encrypt models with nested fields', () => { }, 30000) }) }) + +describe('nested fields with a plaintext field', () => { + it('should handle nested fields with a plaintext field', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + address: '123 Main St', + notEncrypted: 'not encrypted', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + example: { + field: 'test', + plaintext: 'plaintext', + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + expect(encryptedModel.data.example.field).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.notEncrypted).toBe('not encrypted') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.example.plaintext).toBe('plaintext') + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }) + + it('should handle multiple plaintext fields at different nesting levels', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + address: '123 Main St', + notEncrypted: 'not encrypted', + example: { + field: 'encrypted field', + plaintext: 'top level plaintext', + nested: { + deeper: 'encrypted deeper', + plaintext: 'nested plaintext', + extra: { + plaintext: 'deeply nested plaintext', + }, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + expect(encryptedModel.data.example.field).toHaveProperty('c') + expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.notEncrypted).toBe('not encrypted') + expect(encryptedModel.data.example.plaintext).toBe('top level plaintext') + expect(encryptedModel.data.example.nested?.plaintext).toBe( + 'nested plaintext', + ) + expect(encryptedModel.data.example.nested?.extra?.plaintext).toBe( + 'deeply nested plaintext', + ) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }) + + it('should handle partial path matches in nested objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + example: { + field: 'encrypted field', + nested: { + deeper: 'encrypted deeper', + // This should not be encrypted as it's not in the schema + notInSchema: { + deeper: 'not encrypted', + }, + }, + // This should not be encrypted as it's not in the schema + notInSchema: { + deeper: 'not encrypted', + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.example.field).toHaveProperty('c') + expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.example.nested?.notInSchema?.deeper).toBe( + 'not encrypted', + ) + expect(encryptedModel.data.example.notInSchema?.deeper).toBe( + 'not encrypted', + ) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }) + + it('should handle mixed encrypted and plaintext fields with similar paths', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + example: { + field: 'encrypted field', + fieldNotInSchema: 'not encrypted', + nested: { + deeper: 'encrypted deeper', + deeperNotInSchema: 'not encrypted', + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.example.field).toHaveProperty('c') + expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.example.fieldNotInSchema).toBe('not encrypted') + expect(encryptedModel.data.example.nested?.deeperNotInSchema).toBe( + 'not encrypted', + ) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }) + + describe('bulk operations with plaintext fields', () => { + it('should handle bulk encryption and decryption with plaintext fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [ + { + id: '1', + email: 'test1@example.com', + address: '123 Main St', + example: { + field: 'encrypted field 1', + plaintext: 'plaintext 1', + nested: { + deeper: 'encrypted deeper 1', + plaintext: 'nested plaintext 1', + }, + }, + }, + { + id: '2', + email: 'test2@example.com', + address: '456 Main St', + example: { + field: 'encrypted field 2', + plaintext: 'plaintext 2', + nested: { + deeper: 'encrypted deeper 2', + plaintext: 'nested plaintext 2', + }, + }, + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].address).toHaveProperty('c') + expect(encryptedModels.data[0].example.field).toHaveProperty('c') + expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].address).toHaveProperty('c') + expect(encryptedModels.data[1].example.field).toHaveProperty('c') + expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].example.plaintext).toBe('plaintext 1') + expect(encryptedModels.data[0].example.nested?.plaintext).toBe( + 'nested plaintext 1', + ) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].example.plaintext).toBe('plaintext 2') + expect(encryptedModels.data[1].example.nested?.plaintext).toBe( + 'nested plaintext 2', + ) + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual(decryptedModels) + }) + + it('should handle bulk operations with mixed encrypted and non-encrypted fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [ + { + id: '1', + email: 'test1@example.com', + example: { + field: 'encrypted field 1', + fieldNotInSchema: 'not encrypted 1', + nested: { + deeper: 'encrypted deeper 1', + deeperNotInSchema: 'not encrypted deeper 1', + }, + }, + }, + { + id: '2', + email: 'test2@example.com', + example: { + field: 'encrypted field 2', + fieldNotInSchema: 'not encrypted 2', + nested: { + deeper: 'encrypted deeper 2', + deeperNotInSchema: 'not encrypted deeper 2', + }, + }, + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].example.field).toHaveProperty('c') + expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].example.field).toHaveProperty('c') + expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].example.fieldNotInSchema).toBe( + 'not encrypted 1', + ) + expect(encryptedModels.data[0].example.nested?.deeperNotInSchema).toBe( + 'not encrypted deeper 1', + ) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].example.fieldNotInSchema).toBe( + 'not encrypted 2', + ) + expect(encryptedModels.data[1].example.nested?.deeperNotInSchema).toBe( + 'not encrypted deeper 2', + ) + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual(decryptedModels) + }) + + it('should handle bulk operations with deeply nested plaintext fields', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModels: User[] = [ + { + id: '1', + email: 'test1@example.com', + example: { + field: 'encrypted field 1', + nested: { + deeper: 'encrypted deeper 1', + extra: { + plaintext: 'deeply nested plaintext 1', + }, + }, + }, + }, + { + id: '2', + email: 'test2@example.com', + example: { + field: 'encrypted field 2', + nested: { + deeper: 'encrypted deeper 2', + extra: { + plaintext: 'deeply nested plaintext 2', + }, + }, + }, + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].example.field).toHaveProperty('c') + expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].example.field).toHaveProperty('c') + expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].example.nested?.extra?.plaintext).toBe( + 'deeply nested plaintext 1', + ) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].example.nested?.extra?.plaintext).toBe( + 'deeply nested plaintext 2', + ) + + const decryptedResults = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResults.failure) { + throw new Error(`[protect]: ${decryptedResults.failure.message}`) + } + + expect(decryptedResults.data).toEqual(decryptedModels) + }) + }) +}) diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index ab749776..40ac4232 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -68,8 +68,6 @@ export class ProtectClient { client_key: config.clientKey, }) - console.log('config', validated) - this.client = await newClient( JSON.stringify(validated), newClientConfig, diff --git a/packages/protect/src/ffi/model-helpers.ts b/packages/protect/src/ffi/model-helpers.ts index a400a092..742ecc1e 100644 --- a/packages/protect/src/ffi/model-helpers.ts +++ b/packages/protect/src/ffi/model-helpers.ts @@ -119,28 +119,23 @@ async function handleMultiModelBulkOperation( } /** - * Helper function to prepare fields for encryption/decryption + * Helper function to prepare fields for decryption */ -function prepareFieldsForOperation>( +function prepareFieldsForDecryption>( model: T, - table?: ProtectTable, ): { otherFields: Record operationFields: Record keyMap: Record nullFields: Record } { - const otherFields = extractOtherFields(model) + const otherFields = { ...model } as Record const operationFields: Record = {} const nullFields: Record = {} const keyMap: Record = {} let index = 0 - const processNestedFields = ( - obj: Record, - prefix = '', - isEncrypted = false, - ) => { + const processNestedFields = (obj: Record, prefix = '') => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key @@ -151,38 +146,91 @@ function prepareFieldsForOperation>( if (typeof value === 'object' && !isEncryptedPayload(value)) { // Recursively process nested objects - processNestedFields( - value as Record, - fullKey, - isEncrypted, - ) - } else if (isEncrypted || isEncryptedPayload(value)) { - // This is an encrypted field or we're in an encrypted object + processNestedFields(value as Record, fullKey) + } else if (isEncryptedPayload(value)) { + // This is an encrypted field const id = index.toString() keyMap[id] = fullKey operationFields[fullKey] = value index++ + + // Remove from otherFields + const parts = fullKey.split('.') + let current = otherFields + for (let i = 0; i < parts.length - 1; i++) { + current = current[parts[i]] as Record + } + delete current[parts[parts.length - 1]] } } } - if (table) { - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) + processNestedFields(model) + return { otherFields, operationFields, keyMap, nullFields } +} + +/** + * Helper function to prepare fields for encryption + */ +function prepareFieldsForEncryption>( + model: T, + table: ProtectTable, +): { + otherFields: Record + operationFields: Record + keyMap: Record + nullFields: Record +} { + const otherFields = { ...model } as Record + const operationFields: Record = {} + const nullFields: Record = {} + const keyMap: Record = {} + let index = 0 + + const processNestedFields = ( + obj: Record, + prefix = '', + columnPaths: string[] = [], + ) => { + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + + if (value === null || value === undefined) { + nullFields[fullKey] = value + continue + } + + if (typeof value === 'object' && !isEncryptedPayload(value)) { + // Only process nested objects if they're in the schema + if (columnPaths.some((path) => path.startsWith(fullKey))) { + processNestedFields( + value as Record, + fullKey, + columnPaths, + ) + } + } else if (columnPaths.includes(fullKey)) { + // Only process fields that are explicitly defined in the schema + const id = index.toString() + keyMap[id] = fullKey + operationFields[fullKey] = value + index++ - // Process only fields that match the column paths - for (const [key, value] of Object.entries(model)) { - if ( - columnPaths.some((path) => path === key || path.startsWith(`${key}.`)) - ) { - processNestedFields({ [key]: value }, '', true) + // Remove from otherFields + const parts = fullKey.split('.') + let current = otherFields + for (let i = 0; i < parts.length - 1; i++) { + current = current[parts[i]] as Record + } + delete current[parts[parts.length - 1]] } } - } else { - // Process all encrypted fields - processNestedFields(model) } + // Get all column paths from the table schema + const columnPaths = Object.keys(table.build().columns) + processNestedFields(model, '', columnPaths) + return { otherFields, operationFields, keyMap, nullFields } } @@ -198,7 +246,7 @@ export async function decryptModelFields>( } const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForOperation(model) + prepareFieldsForDecryption(model) const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ @@ -261,7 +309,7 @@ export async function encryptModelFields>( } const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForOperation(model, table) + prepareFieldsForEncryption(model, table) const bulkEncryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ @@ -332,7 +380,7 @@ export async function decryptModelFieldsWithLockContext< } const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForOperation(model) + prepareFieldsForDecryption(model) const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ @@ -348,7 +396,39 @@ export async function decryptModelFieldsWithLockContext< keyMap, ) - return { ...otherFields, ...nullFields, ...decryptedFields } as Decrypted + // Helper function to set a nested value + const setNestedValue = ( + obj: Record, + path: string[], + value: unknown, + ) => { + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const part = path[i] + if (!(part in current)) { + current[part] = {} + } + current = current[part] as Record + } + current[path[path.length - 1]] = value + } + + // Reconstruct the object with proper nesting + const result: Record = { ...otherFields } + + // First, reconstruct the null/undefined fields + for (const [key, value] of Object.entries(nullFields)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + // Then, reconstruct the decrypted fields + for (const [key, value] of Object.entries(decryptedFields)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + return result as Decrypted } /** @@ -371,7 +451,7 @@ export async function encryptModelFieldsWithLockContext< } const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForOperation(model, table) + prepareFieldsForEncryption(model, table) const bulkEncryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ @@ -389,7 +469,39 @@ export async function encryptModelFieldsWithLockContext< keyMap, ) - return { ...otherFields, ...nullFields, ...encryptedData } as T + // Helper function to set a nested value + const setNestedValue = ( + obj: Record, + path: string[], + value: unknown, + ) => { + let current = obj + for (let i = 0; i < path.length - 1; i++) { + const part = path[i] + if (!(part in current)) { + current[part] = {} + } + current = current[part] as Record + } + current[path[path.length - 1]] = value + } + + // Reconstruct the object with proper nesting + const result: Record = { ...otherFields } + + // First, reconstruct the null/undefined fields + for (const [key, value] of Object.entries(nullFields)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + // Then, reconstruct the encrypted fields + for (const [key, value] of Object.entries(encryptedData)) { + const parts = key.split('.') + setNestedValue(result, parts, value) + } + + return result as T } /** @@ -412,36 +524,46 @@ function prepareBulkModelsForOperation>( for (let modelIndex = 0; modelIndex < models.length; modelIndex++) { const model = models[modelIndex] - const modelOtherFields = extractOtherFields(model) + const modelOtherFields = { ...model } as Record const modelOperationFields: Record = {} const modelNullFields: Record = {} const processNestedFields = ( obj: Record, prefix = '', - isEncrypted = false, + columnPaths: string[] = [], ) => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key if (value === null || value === undefined) { - modelNullFields[fullKey] = value === undefined ? undefined : null + modelNullFields[fullKey] = value continue } if (typeof value === 'object' && !isEncryptedPayload(value)) { - // Recursively process nested objects - processNestedFields( - value as Record, - fullKey, - isEncrypted, - ) - } else if (isEncrypted || isEncryptedPayload(value)) { - // This is an encrypted field or we're in an encrypted object + // Only process nested objects if they're in the schema + if (columnPaths.some((path) => path.startsWith(fullKey))) { + processNestedFields( + value as Record, + fullKey, + columnPaths, + ) + } + } else if (columnPaths.includes(fullKey)) { + // Only process fields that are explicitly defined in the schema const id = index.toString() keyMap[id] = { modelIndex, fieldKey: fullKey } modelOperationFields[fullKey] = value index++ + + // Remove from otherFields + const parts = fullKey.split('.') + let current = modelOtherFields + for (let i = 0; i < parts.length - 1; i++) { + current = current[parts[i]] as Record + } + delete current[parts[parts.length - 1]] } } } @@ -449,18 +571,42 @@ function prepareBulkModelsForOperation>( if (table) { // Get all column paths from the table schema const columnPaths = Object.keys(table.build().columns) - - // Process only fields that match the column paths - for (const [key, value] of Object.entries(model)) { - if ( - columnPaths.some((path) => path === key || path.startsWith(`${key}.`)) - ) { - processNestedFields({ [key]: value }, '', true) + processNestedFields(model, '', columnPaths) + } else { + // For decryption, process all encrypted fields + const processEncryptedFields = ( + obj: Record, + prefix = '', + ) => { + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + + if (value === null || value === undefined) { + modelNullFields[fullKey] = value + continue + } + + if (typeof value === 'object' && !isEncryptedPayload(value)) { + // Recursively process nested objects + processEncryptedFields(value as Record, fullKey) + } else if (isEncryptedPayload(value)) { + // This is an encrypted field + const id = index.toString() + keyMap[id] = { modelIndex, fieldKey: fullKey } + modelOperationFields[fullKey] = value + index++ + + // Remove from otherFields + const parts = fullKey.split('.') + let current = modelOtherFields + for (let i = 0; i < parts.length - 1; i++) { + current = current[parts[i]] as Record + } + delete current[parts[parts.length - 1]] + } } } - } else { - // Process all encrypted fields - processNestedFields(model) + processEncryptedFields(model) } otherFields.push(modelOtherFields) diff --git a/packages/protect/src/ffi/operations/decrypt-model.ts b/packages/protect/src/ffi/operations/decrypt-model.ts index 1ce17d1b..fb631f69 100644 --- a/packages/protect/src/ffi/operations/decrypt-model.ts +++ b/packages/protect/src/ffi/operations/decrypt-model.ts @@ -8,16 +8,23 @@ import { decryptModelFields, decryptModelFieldsWithLockContext, } from '../model-helpers' +import type { ProtectTable, ProtectTableColumn } from '../../schema' export class DecryptModelOperation> implements PromiseLike, ProtectError>> { private client: Client private model: T + private table?: ProtectTable - constructor(client: Client, model: T) { + constructor( + client: Client, + model: T, + table?: ProtectTable, + ) { this.client = client this.model = model + this.table = table } public withLockContext( @@ -47,7 +54,7 @@ export class DecryptModelOperation> throw noClientError() } - return await decryptModelFields(this.model, this.client) + return await decryptModelFields(this.model, this.client, this.table) }, (error) => ({ type: ProtectErrorTypes.DecryptionError, @@ -59,10 +66,12 @@ export class DecryptModelOperation> public getOperation(): { client: Client model: T + table?: ProtectTable } { return { client: this.client, model: this.model, + table: this.table, } } } @@ -94,7 +103,7 @@ export class DecryptModelOperationWithLockContext< private async execute(): Promise, ProtectError>> { return await withResult( async () => { - const { client, model } = this.operation.getOperation() + const { client, model, table } = this.operation.getOperation() logger.debug('Decrypting model WITH a lock context') @@ -112,6 +121,7 @@ export class DecryptModelOperationWithLockContext< model, client, context.data, + table, ) }, (error) => ({ From d2f773df645eb69cb07b2ded5e2b03fbfe972f48 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 12:58:28 -0600 Subject: [PATCH 5/6] chore(protect): clean up unused variables --- .../protect/src/ffi/operations/decrypt-model.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/protect/src/ffi/operations/decrypt-model.ts b/packages/protect/src/ffi/operations/decrypt-model.ts index fb631f69..57e7e056 100644 --- a/packages/protect/src/ffi/operations/decrypt-model.ts +++ b/packages/protect/src/ffi/operations/decrypt-model.ts @@ -17,14 +17,9 @@ export class DecryptModelOperation> private model: T private table?: ProtectTable - constructor( - client: Client, - model: T, - table?: ProtectTable, - ) { + constructor(client: Client, model: T) { this.client = client this.model = model - this.table = table } public withLockContext( @@ -54,7 +49,7 @@ export class DecryptModelOperation> throw noClientError() } - return await decryptModelFields(this.model, this.client, this.table) + return await decryptModelFields(this.model, this.client) }, (error) => ({ type: ProtectErrorTypes.DecryptionError, @@ -71,7 +66,6 @@ export class DecryptModelOperation> return { client: this.client, model: this.model, - table: this.table, } } } @@ -103,7 +97,7 @@ export class DecryptModelOperationWithLockContext< private async execute(): Promise, ProtectError>> { return await withResult( async () => { - const { client, model, table } = this.operation.getOperation() + const { client, model } = this.operation.getOperation() logger.debug('Decrypting model WITH a lock context') @@ -121,7 +115,6 @@ export class DecryptModelOperationWithLockContext< model, client, context.data, - table, ) }, (error) => ({ From cccd75134b707065ed89c45f71414afecbd50c54 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 13:07:24 -0600 Subject: [PATCH 6/6] chore(protect): explicit check in tests for encrypted fields --- .../protect/__tests__/nested-models.test.ts | 91 ++++++++++++++ packages/protect/__tests__/protect.test.ts | 118 ++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts index 3966b69a..e245b4a5 100644 --- a/packages/protect/__tests__/nested-models.test.ts +++ b/packages/protect/__tests__/nested-models.test.ts @@ -56,6 +56,9 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptResponse.failure.message}`) } + // Verify encrypted field + expect(encryptResponse.data).toHaveProperty('c') + const decryptResponse = await protectClient.decrypt(encryptResponse.data) if (decryptResponse.failure) { @@ -91,6 +94,17 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + expect(encryptedModel.data.example.field).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.notEncrypted).toBe('not encrypted') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -126,6 +140,12 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify null fields are preserved + expect(encryptedModel.data.email).toBeNull() + expect(encryptedModel.data.address).toBeNull() + expect(encryptedModel.data.example.field).toBeNull() + expect(encryptedModel.data.example.nested?.deeper).toBeNull() + const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -159,6 +179,11 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify undefined fields are preserved + expect(encryptedModel.data.email).toBeUndefined() + expect(encryptedModel.data.example.field).toBeUndefined() + expect(encryptedModel.data.example.nested?.deeper).toBeUndefined() + const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -197,6 +222,20 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + + // Verify null/undefined fields are preserved + expect(encryptedModel.data.address).toBeUndefined() + expect(encryptedModel.data.example.field).toBeNull() + expect(encryptedModel.data.example.nested?.deeper).toBeUndefined() + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('4') + expect(encryptedModel.data.notEncrypted).toBe('not encrypted') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -230,6 +269,13 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify encrypted fields + expect(encryptedModel.data.example.field).toHaveProperty('c') + expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('3') + const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -260,6 +306,13 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify encrypted fields + expect(encryptedModel.data.example.field).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('5') + expect(encryptedModel.data.example.nested).toBeUndefined() + const decryptedResult = await protectClient.decryptModel( encryptedModel.data, ) @@ -307,6 +360,18 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].example.field).toHaveProperty('c') + expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].example.field).toHaveProperty('c') + expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[1].id).toBe('2') + const decryptedResults = await protectClient.bulkDecryptModels( encryptedModels.data, ) @@ -353,6 +418,18 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify null/undefined fields are preserved + expect(encryptedModels.data[0].email).toBeNull() + expect(encryptedModels.data[0].example.field).toBeNull() + expect(encryptedModels.data[0].example.nested?.deeper).toBeUndefined() + expect(encryptedModels.data[1].email).toBeUndefined() + expect(encryptedModels.data[1].example.field).toBeUndefined() + expect(encryptedModels.data[1].example.nested?.deeper).toBeNull() + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[1].id).toBe('2') + const decryptedResults = await protectClient.bulkDecryptModels( encryptedModels.data, ) @@ -396,6 +473,18 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].example.field).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].example.field).toHaveProperty('c') + expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].example.nested).toBeUndefined() + expect(encryptedModels.data[1].id).toBe('2') + const decryptedResults = await protectClient.bulkDecryptModels( encryptedModels.data, ) @@ -421,6 +510,8 @@ describe('encrypt models with nested fields', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + expect(encryptedModels.data).toEqual([]) + const decryptedResults = await protectClient.bulkDecryptModels( encryptedModels.data, ) diff --git a/packages/protect/__tests__/protect.test.ts b/packages/protect/__tests__/protect.test.ts index 0dc13fd0..c685803e 100644 --- a/packages/protect/__tests__/protect.test.ts +++ b/packages/protect/__tests__/protect.test.ts @@ -32,6 +32,9 @@ describe('encryption and decryption', () => { throw new Error(`[protect]: ${ciphertext.failure.message}`) } + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + const plaintext = await protectClient.decrypt(ciphertext.data) expect(plaintext).toEqual({ @@ -51,6 +54,9 @@ describe('encryption and decryption', () => { throw new Error(`[protect]: ${ciphertext.failure.message}`) } + // Verify null is preserved + expect(ciphertext.data).toBeNull() + const plaintext = await protectClient.decrypt(ciphertext.data) expect(plaintext).toEqual({ @@ -81,6 +87,16 @@ describe('encryption and decryption', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.number).toBe(1) + // Decrypt the model const decryptedResult = await protectClient.decryptModel( encryptedModel.data, @@ -123,6 +139,16 @@ describe('encryption and decryption', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify null fields are preserved + expect(encryptedModel.data.email).toBeNull() + expect(encryptedModel.data.address).toBeNull() + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.number).toBe(1) + // Decrypt the model const decryptedResult = await protectClient.decryptModel( encryptedModel.data, @@ -165,6 +191,16 @@ describe('encryption and decryption', () => { throw new Error(`[protect]: ${encryptedModel.failure.message}`) } + // Verify undefined fields are preserved + expect(encryptedModel.data.email).toBeUndefined() + expect(encryptedModel.data.address).toBeNull() + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.number).toBe(1) + // Decrypt the model const decryptedResult = await protectClient.decryptModel( encryptedModel.data, @@ -219,6 +255,22 @@ describe('bulk encryption', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].address).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].address).toBeNull() + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].number).toBe(1) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].number).toBe(2) + // Decrypt the models const decryptedResult = await protectClient.bulkDecryptModels( encryptedModels.data, @@ -318,6 +370,28 @@ describe('bulk encryption edge cases', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].address).toBeNull() + expect(encryptedModels.data[1].email).toBeNull() + expect(encryptedModels.data[1].address).toHaveProperty('c') + expect(encryptedModels.data[2].email).toHaveProperty('c') + expect(encryptedModels.data[2].address).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].number).toBe(1) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].number).toBe(2) + expect(encryptedModels.data[2].id).toBe('3') + expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[2].number).toBe(3) + // Decrypt the models const decryptedResult = await protectClient.bulkDecryptModels( encryptedModels.data, @@ -369,6 +443,28 @@ describe('bulk encryption edge cases', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].address).toBeUndefined() + expect(encryptedModels.data[1].email).toBeNull() + expect(encryptedModels.data[1].address).toHaveProperty('c') + expect(encryptedModels.data[2].email).toHaveProperty('c') + expect(encryptedModels.data[2].address).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].number).toBe(1) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].number).toBe(2) + expect(encryptedModels.data[2].id).toBe('3') + expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[2].number).toBe(3) + // Decrypt the models const decryptedResult = await protectClient.bulkDecryptModels( encryptedModels.data, @@ -415,6 +511,28 @@ describe('bulk encryption edge cases', () => { throw new Error(`[protect]: ${encryptedModels.failure.message}`) } + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toBeUndefined() + expect(encryptedModels.data[0].address).toBeUndefined() + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].address).toBeUndefined() + expect(encryptedModels.data[2].email).toBeUndefined() + expect(encryptedModels.data[2].address).toBeUndefined() + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].number).toBe(1) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].number).toBe(2) + expect(encryptedModels.data[2].id).toBe('3') + expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[2].number).toBe(3) + // Decrypt the models const decryptedResult = await protectClient.bulkDecryptModels( encryptedModels.data,