|
4 | 4 | * |
5 | 5 | * `listEncryptedColumns` can no longer emit `version: 2` — a legacy |
6 | 6 | * `eql_v2_encrypted` column is not classified as an EQL column at all, so it |
7 | | - * never reaches this function as a candidate. The post-cutover v2 state (the |
8 | | - * ciphertext renamed onto the plaintext column's own name) therefore arrives |
9 | | - * here as an EMPTY candidate list, which the first guard already falls through |
10 | | - * on. These tests exist so removing the now-unreachable `version === 2` branch |
11 | | - * is provably behaviour-preserving, and so a future v2 sweep cannot delete the |
12 | | - * empty-list guard the v2 lifecycle actually depends on. |
| 7 | + * never reaches this function as a candidate. Every pure-v2 table therefore |
| 8 | + * arrives here as an EMPTY candidate list, both pre-cutover (`<col>` / |
| 9 | + * `<col>_encrypted`) and post-cutover (the ciphertext renamed onto the |
| 10 | + * plaintext column's own name), and the first guard falls through on it. |
| 11 | + * |
| 12 | + * These tests exist so removing the now-unreachable `version === 2` branch is |
| 13 | + * provably behaviour-preserving, and so a future v2 sweep cannot delete the |
| 14 | + * empty-list guard the v2 lifecycle actually depends on. That guard has to hold |
| 15 | + * even when the manifest recorded an `encryptedColumn` — `backfill` records one |
| 16 | + * for v2 columns too — which is the `candidates.length > 0` gate on the |
| 17 | + * fail-closed path (#787 review). |
13 | 18 | */ |
14 | 19 |
|
15 | 20 | import type { EncryptedColumnInfo } from '@cipherstash/migrate' |
@@ -64,6 +69,15 @@ describe('explainUnresolved', () => { |
64 | 69 | expect(explainUnresolved('users', 'email', [])).toBeNull() |
65 | 70 | }) |
66 | 71 |
|
| 72 | + it('still falls through when no EQL v3 columns exist BUT a hint was recorded', () => { |
| 73 | + // The pure-v2 table. `encrypt backfill` records `encryptedColumn` for v2 |
| 74 | + // columns too, so a hint is present on every table backfilled with this |
| 75 | + // release — it must not flip the empty-candidate fall-through into a |
| 76 | + // refusal, because `cutover` / `drop` in this same build still implement |
| 77 | + // the v2 ladder this falls through to (#787 review). |
| 78 | + expect(explainUnresolved('users', 'ssn', [], 'ssn_encrypted')).toBeNull() |
| 79 | + }) |
| 80 | + |
67 | 81 | it('fails closed, naming every candidate, when none is identifiable', () => { |
68 | 82 | const message = explainUnresolved('users', 'email', [ |
69 | 83 | v3('a_enc'), |
@@ -133,6 +147,36 @@ describe('resolveColumnLifecycle — a recorded hint that is not a v3 candidate' |
133 | 147 | expect(unresolvedHint).toBe('ssn_encrypted') |
134 | 148 | }) |
135 | 149 |
|
| 150 | + // The pure-v2 shape, and the regression the `candidates.length > 0` gate |
| 151 | + // exists to prevent (#787 review). `backfill` records `encryptedColumn` |
| 152 | + // unconditionally — v2 included — so EVERY pure-v2 table backfilled with this |
| 153 | + // release carries a hint naming a real, existing, non-v3 column. Without the |
| 154 | + // gate, `columnExists` returned true, `unresolvedHint` was set, and |
| 155 | + // `cutover` / `drop` refused a lifecycle this same build still performs. |
| 156 | + it('does not fail closed on a pure-v2 table, where no v3 column can be mis-claimed', async () => { |
| 157 | + // No v3 columns at all: just the `ssn` / `ssn_encrypted` v2 pair, which the |
| 158 | + // classifier does not see. |
| 159 | + listEncryptedColumns.mockResolvedValue([]) |
| 160 | + readManifest.mockResolvedValue({ |
| 161 | + tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] }, |
| 162 | + }) |
| 163 | + |
| 164 | + const { info, candidates, unresolvedHint } = await resolveColumnLifecycle( |
| 165 | + clientWithColumns('ssn', 'ssn_encrypted'), |
| 166 | + 'users', |
| 167 | + 'ssn', |
| 168 | + ) |
| 169 | + |
| 170 | + expect(info).toBeNull() |
| 171 | + expect(candidates).toEqual([]) |
| 172 | + // The fall-through signal: no hint reported, so `explainUnresolved` returns |
| 173 | + // null and the caller reaches its own v2 preconditions. |
| 174 | + expect(unresolvedHint).toBeUndefined() |
| 175 | + expect( |
| 176 | + explainUnresolved('users', 'ssn', candidates, unresolvedHint), |
| 177 | + ).toBeNull() |
| 178 | + }) |
| 179 | + |
136 | 180 | it('explains the recorded counterpart by name rather than listing candidates', async () => { |
137 | 181 | const message = explainUnresolved( |
138 | 182 | 'users', |
|
0 commit comments