diff --git a/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts b/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts new file mode 100644 index 00000000..9f679d94 --- /dev/null +++ b/packages/stack/__tests__/bulk-model-hyphen-fields.test.ts @@ -0,0 +1,118 @@ +/** + * Regression tests for bulk model reconstruction with hyphenated field names. + * + * The bulk model helpers key per-field operation results by a + * `${modelIndex}-${fieldKey}` id. Reconstruction previously split that id + * with a naive `split('-')`, truncating any field key containing a hyphen + * (`some-field` → `some`) — the encrypted/decrypted value then landed under + * the truncated key and the real field silently vanished from the model. + * The split now happens at the FIRST hyphen only (`fieldsForModelIndex` in + * src/encryption/helpers/model-helpers.ts). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EncryptionClient } from '@/encryption' +import { Encryption } from '@/index' +import { encryptedColumn, encryptedTable } from '@/schema' + +// A protect-ffi-shaped encrypted payload carrying its plaintext in `c` so +// the fake decrypt can undo it (and `isEncryptedPayload` detects it). +const enc = (plaintext: unknown) => ({ + v: 2, + i: { t: 'users', c: 'col' }, + c: `ct:${String(plaintext)}`, +}) + +vi.mock('@cipherstash/protect-ffi', () => ({ + newClient: vi.fn(async () => ({ __mock: 'client' })), + encrypt: vi.fn(async () => enc('x')), + decrypt: vi.fn(async () => 'decrypted'), + encryptBulk: vi.fn( + async (_c: unknown, opts: { plaintexts: Array<{ plaintext: unknown }> }) => + opts.plaintexts.map((p) => enc(p.plaintext)), + ), + decryptBulk: vi.fn( + async ( + _c: unknown, + opts: { ciphertexts: Array<{ ciphertext: { c: string } }> }, + ) => opts.ciphertexts.map((e) => e.ciphertext.c.replace(/^ct:/, '')), + ), + decryptBulkFallible: vi.fn( + async ( + _c: unknown, + opts: { ciphertexts: Array<{ ciphertext: { c: string } }> }, + ) => + opts.ciphertexts.map((e) => ({ + data: e.ciphertext.c.replace(/^ct:/, ''), + })), + ), + encryptQuery: vi.fn(async () => enc('x')), + encryptQueryBulk: vi.fn(async (_c: unknown, opts: { queries: unknown[] }) => + opts.queries.map(() => enc('x')), + ), +})) + +// DB column names deliberately contain hyphens — legal in (quoted) Postgres +// identifiers and previously corrupted by the naive id split. +const users = encryptedTable('users', { + 'some-field': encryptedColumn('some-field').equality(), + 'multi-part-name': encryptedColumn('multi-part-name').equality(), + plainName: encryptedColumn('plainName').equality(), +}) + +let client: EncryptionClient + +beforeEach(async () => { + vi.clearAllMocks() + client = await Encryption({ schemas: [users] }) +}) + +// biome-ignore lint/suspicious/noExplicitAny: test helper unwraps Result +function unwrap(result: any) { + if (result.failure) { + throw new Error(`operation failed: ${result.failure.message}`) + } + return result.data +} + +describe('bulk model helpers with hyphenated field names', () => { + it('bulkEncryptModels keeps hyphenated field names intact', async () => { + const models = [ + { 'some-field': 'a', 'multi-part-name': 'b', plainName: 'c', other: 1 }, + { 'some-field': 'd', plainName: 'e', other: 2 }, + ] + + const encrypted = unwrap(await client.bulkEncryptModels(models, users)) + + expect(encrypted).toHaveLength(2) + // The full hyphenated keys survive — no truncated `some` / `multi` keys. + expect(encrypted[0]['some-field']).toMatchObject({ c: 'ct:a' }) + expect(encrypted[0]['multi-part-name']).toMatchObject({ c: 'ct:b' }) + expect(encrypted[0].plainName).toMatchObject({ c: 'ct:c' }) + expect(encrypted[0]).not.toHaveProperty('some') + expect(encrypted[0]).not.toHaveProperty('multi') + expect(encrypted[0].other).toBe(1) + expect(encrypted[1]['some-field']).toMatchObject({ c: 'ct:d' }) + }) + + it('bulkDecryptModels keeps hyphenated field names intact', async () => { + const models = [ + { + 'some-field': enc('a'), + 'multi-part-name': enc('b'), + other: 1, + }, + { 'some-field': enc('c'), other: 2 }, + ] + + const decrypted = unwrap(await client.bulkDecryptModels(models)) + + expect(decrypted).toHaveLength(2) + expect(decrypted[0]['some-field']).toBe('a') + expect(decrypted[0]['multi-part-name']).toBe('b') + expect(decrypted[0]).not.toHaveProperty('some') + expect(decrypted[0]).not.toHaveProperty('multi') + expect(decrypted[0].other).toBe(1) + expect(decrypted[1]['some-field']).toBe('c') + }) +}) diff --git a/packages/stack/src/encryption/helpers/model-helpers.ts b/packages/stack/src/encryption/helpers/model-helpers.ts index 1cf59d8d..4530746d 100644 --- a/packages/stack/src/encryption/helpers/model-helpers.ts +++ b/packages/stack/src/encryption/helpers/model-helpers.ts @@ -617,6 +617,27 @@ function prepareBulkModelsForOperation>( return { otherFields, operationFields, keyMap, nullFields } } +/** + * Collect the per-model fields out of a bulk-operation result map keyed by + * `${modelIndex}-${fieldKey}` ids, splitting each id at the FIRST hyphen + * only. Field keys may themselves contain hyphens (a `some-field` column, or + * a nested `profile.some-field` path), so a naive `split('-')` would truncate + * the field key at its first hyphen and silently drop the value during model + * reconstruction. + */ +function fieldsForModelIndex( + fields: Record, + modelIndex: number, +): Record { + const result: Record = {} + for (const [id, value] of Object.entries(fields)) { + const sep = id.indexOf('-') + if (Number.parseInt(id.slice(0, sep), 10) !== modelIndex) continue + result[id.slice(sep + 1)] = value + } + return result +} + /** * Helper function to convert multiple decrypted models to models with encrypted fields */ @@ -666,17 +687,7 @@ export async function bulkEncryptModels( } // Then, reconstruct the encrypted fields - const modelData = Object.fromEntries( - Object.entries(encryptedData) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) + const modelData = fieldsForModelIndex(encryptedData, modelIndex) for (const [key, value] of Object.entries(modelData)) { const parts = key.split('.') @@ -733,17 +744,7 @@ export async function bulkDecryptModels>( } // Then, reconstruct the decrypted fields - const modelData = Object.fromEntries( - Object.entries(decryptedFields) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) + const modelData = fieldsForModelIndex(decryptedFields, modelIndex) for (const [key, value] of Object.entries(modelData)) { const parts = key.split('.') @@ -805,17 +806,7 @@ export async function bulkDecryptModelsWithLockContext< } // Then, reconstruct the decrypted fields - const modelData = Object.fromEntries( - Object.entries(decryptedFields) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) + const modelData = fieldsForModelIndex(decryptedFields, modelIndex) for (const [key, value] of Object.entries(modelData)) { const parts = key.split('.') @@ -878,17 +869,7 @@ export async function bulkEncryptModelsWithLockContext( } // Then, reconstruct the encrypted fields - const modelData = Object.fromEntries( - Object.entries(encryptedData) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) + const modelData = fieldsForModelIndex(encryptedData, modelIndex) for (const [key, value] of Object.entries(modelData)) { const parts = key.split('.')