|
1 | 1 | import { describe, expect, it } from 'vitest' |
2 | | -import type { EncryptionClient } from '@/encryption' |
3 | 2 | import { createEncryptionClient } from '@/encryption/client-v3' |
4 | 3 | import { encryptedTable, types } from '@/eql/v3' |
| 4 | +import type { Encrypted } from '@/types' |
| 5 | + |
| 6 | +/** |
| 7 | + * What `createEncryptionClient` wraps. Derived from the factory rather than |
| 8 | + * imported, because `UnderlyingNativeClient` is deliberately module-private — |
| 9 | + * naming it here is the only thing a test needs from it, and `Parameters` gets |
| 10 | + * that without widening the module's export surface. |
| 11 | + * |
| 12 | + * The stubs below are cast to this, not to `EncryptionClient`: the wrapper takes |
| 13 | + * the NATIVE client, and casting to the public client type instead was a type |
| 14 | + * error in every call (invisible, since `__tests__` is not typechecked in CI). |
| 15 | + */ |
| 16 | +type NativeClientStub = Parameters<typeof createEncryptionClient>[0] |
5 | 17 |
|
6 | 18 | const table = encryptedTable('t', { |
7 | 19 | when: types.Timestamp('when'), |
@@ -34,11 +46,11 @@ function fakeOp<R>(result: R) { |
34 | 46 | * A minimal client stub whose model-decrypt methods return an operation |
35 | 47 | * resolving to a fixed `Result` payload. |
36 | 48 | */ |
37 | | -function fakeClient(data: Record<string, unknown>): EncryptionClient { |
| 49 | +function fakeClient(data: Record<string, unknown>): NativeClientStub { |
38 | 50 | return { |
39 | 51 | decryptModel: () => fakeOp({ data }), |
40 | 52 | bulkDecryptModels: () => fakeOp({ data: [data] }), |
41 | | - } as unknown as EncryptionClient |
| 53 | + } as unknown as NativeClientStub |
42 | 54 | } |
43 | 55 |
|
44 | 56 | describe('createEncryptionClient — decrypt reconstruction', () => { |
@@ -141,7 +153,7 @@ describe('createEncryptionClient — decrypt reconstruction', () => { |
141 | 153 | fakeOp({ |
142 | 154 | failure: { type: 'DecryptionError', message: 'boom' }, |
143 | 155 | }), |
144 | | - } as unknown as EncryptionClient |
| 156 | + } as unknown as NativeClientStub |
145 | 157 |
|
146 | 158 | const client = createEncryptionClient(failing, table) |
147 | 159 | const result = await client.decryptModel({}, table) |
@@ -232,3 +244,90 @@ describe('createEncryptionClient — decrypt reconstruction', () => { |
232 | 244 | expect(rows[0].when).toBe('2021-06-01T00:00:00.000Z') |
233 | 245 | }) |
234 | 246 | }) |
| 247 | + |
| 248 | +/** |
| 249 | + * The raw-path boundary, pinned rather than asserted away (#779). |
| 250 | + * |
| 251 | + * `decrypt` / `bulkDecrypt` are bare delegations — the typed wrapper adds no |
| 252 | + * `Date` reconstruction — so a `timestamp` column read this way is the string it |
| 253 | + * was stored as, where `decryptModel(row, table)` above turns the same value |
| 254 | + * into a `Date`. That split is deliberate: the raw methods resolve to the FFI |
| 255 | + * plaintext union, which excludes `Date`, and reconstructing without widening |
| 256 | + * it would make the declared type a lie. |
| 257 | + * |
| 258 | + * What it is NOT is a missing capability, which is the claim the JSDoc used to |
| 259 | + * make. The payloads below carry `i: { t, c }` naming a REGISTERED date-like |
| 260 | + * column — the identity needed to resolve `cast_as` is right there and |
| 261 | + * deliberately unused. Pinning it here means a future change of heart has to |
| 262 | + * delete this test, which is the point: the integration suite covers the |
| 263 | + * single-value half end-to-end (`integration/shared/v2-decrypt-compat`), but |
| 264 | + * nothing covered the bulk half, and that gap is what let the split read as an |
| 265 | + * accident. |
| 266 | + */ |
| 267 | +describe('createEncryptionClient — raw decrypt paths are unmapped', () => { |
| 268 | + const storedIso = '2020-01-02T03:04:05.000Z' |
| 269 | + // Shaped like a real storage payload: `i` names `t`'s `when` column, which |
| 270 | + // the table above declares `types.Timestamp` (`cast_as: 'timestamp'`). Typed |
| 271 | + // as `Encrypted` so both calls below go through the PUBLIC signature — the |
| 272 | + // arity a caller actually reaches, unlike the one-arg model tests above. |
| 273 | + const payload: Encrypted = { |
| 274 | + k: 'ct', |
| 275 | + v: 2, |
| 276 | + i: { t: 't', c: 'when' }, |
| 277 | + c: 'ciphertext', |
| 278 | + } |
| 279 | + |
| 280 | + /** |
| 281 | + * Stubs only the two raw methods. They are returned by the wrapper unchanged, |
| 282 | + * so — unlike the model paths, which get wrapped in a `MappedDecryptOperation` |
| 283 | + * that calls `.execute()` — the stub is awaited directly and can simply be a |
| 284 | + * promise of the `Result`. |
| 285 | + */ |
| 286 | + function rawFakeClient() { |
| 287 | + const forwarded: unknown[] = [] |
| 288 | + const client = { |
| 289 | + decrypt: (encrypted: unknown) => { |
| 290 | + forwarded.push(encrypted) |
| 291 | + return Promise.resolve({ data: storedIso }) |
| 292 | + }, |
| 293 | + bulkDecrypt: (payloads: unknown) => { |
| 294 | + forwarded.push(payloads) |
| 295 | + return Promise.resolve({ |
| 296 | + data: [{ id: 'u1', data: storedIso }], |
| 297 | + }) |
| 298 | + }, |
| 299 | + } as unknown as NativeClientStub |
| 300 | + return { client, forwarded } |
| 301 | + } |
| 302 | + |
| 303 | + it('returns a date-like column as its stored string from decrypt', async () => { |
| 304 | + const { client: underlying, forwarded } = rawFakeClient() |
| 305 | + const client = createEncryptionClient(underlying, table) |
| 306 | + |
| 307 | + const result = await client.decrypt(payload) |
| 308 | + expect(result.failure).toBeFalsy() |
| 309 | + if (result.failure) return |
| 310 | + |
| 311 | + expect(result.data).toBe(storedIso) |
| 312 | + expect(result.data).not.toBeInstanceOf(Date) |
| 313 | + // Bare delegation: the payload reaches the underlying client untouched. |
| 314 | + expect(forwarded).toEqual([payload]) |
| 315 | + }) |
| 316 | + |
| 317 | + it('returns date-like columns as stored strings from bulkDecrypt', async () => { |
| 318 | + const { client: underlying, forwarded } = rawFakeClient() |
| 319 | + const client = createEncryptionClient(underlying, table) |
| 320 | + |
| 321 | + const result = await client.bulkDecrypt([{ id: 'u1', data: payload }]) |
| 322 | + expect(result.failure).toBeFalsy() |
| 323 | + if (result.failure) return |
| 324 | + |
| 325 | + expect(result.data).toHaveLength(1) |
| 326 | + const [first] = result.data |
| 327 | + expect(first.data).toBe(storedIso) |
| 328 | + expect(first.data).not.toBeInstanceOf(Date) |
| 329 | + // Position-stable identifiers survive the (absent) mapping too. |
| 330 | + expect(first.id).toBe('u1') |
| 331 | + expect(forwarded).toEqual([[{ id: 'u1', data: payload }]]) |
| 332 | + }) |
| 333 | +}) |
0 commit comments