@proteinjs/db-driver-spanner 1.23.5 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +17 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/SpannerSchemaOperations.d.ts.map +1 -1
- package/dist/src/SpannerSchemaOperations.js +4 -1
- package/dist/src/SpannerSchemaOperations.js.map +1 -1
- package/dist/test/ColumnEncryption.test.d.ts +2 -0
- package/dist/test/ColumnEncryption.test.d.ts.map +1 -0
- package/dist/test/ColumnEncryption.test.js +482 -0
- package/dist/test/ColumnEncryption.test.js.map +1 -0
- package/dist/test/EncryptedSearch.test.d.ts +2 -0
- package/dist/test/EncryptedSearch.test.d.ts.map +1 -0
- package/dist/test/EncryptedSearch.test.js +455 -0
- package/dist/test/EncryptedSearch.test.js.map +1 -0
- package/dist/test/EncryptionLifecycleWalker.test.d.ts +2 -0
- package/dist/test/EncryptionLifecycleWalker.test.d.ts.map +1 -0
- package/dist/test/EncryptionLifecycleWalker.test.js +408 -0
- package/dist/test/EncryptionLifecycleWalker.test.js.map +1 -0
- package/dist/test/EncryptionPerf.test.d.ts +2 -0
- package/dist/test/EncryptionPerf.test.d.ts.map +1 -0
- package/dist/test/EncryptionPerf.test.js +278 -0
- package/dist/test/EncryptionPerf.test.js.map +1 -0
- package/dist/test/EncryptionSortAndUnique.test.d.ts +2 -0
- package/dist/test/EncryptionSortAndUnique.test.d.ts.map +1 -0
- package/dist/test/EncryptionSortAndUnique.test.js +229 -0
- package/dist/test/EncryptionSortAndUnique.test.js.map +1 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.d.ts +2 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.d.ts.map +1 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.js +244 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.js.map +1 -0
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/util/columnEncryptionTestHarness.d.ts +23 -0
- package/dist/test/util/columnEncryptionTestHarness.d.ts.map +1 -0
- package/dist/test/util/columnEncryptionTestHarness.js +136 -0
- package/dist/test/util/columnEncryptionTestHarness.js.map +1 -0
- package/dist/test/util/columnEncryptionTestTables.d.ts +75 -0
- package/dist/test/util/columnEncryptionTestTables.d.ts.map +1 -0
- package/dist/test/util/columnEncryptionTestTables.js +124 -0
- package/dist/test/util/columnEncryptionTestTables.js.map +1 -0
- package/dist/test/util/referenceAdoptWidthTestTables.d.ts +24 -0
- package/dist/test/util/referenceAdoptWidthTestTables.d.ts.map +1 -0
- package/dist/test/util/referenceAdoptWidthTestTables.js +35 -0
- package/dist/test/util/referenceAdoptWidthTestTables.js.map +1 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +2 -2
- package/generated/test/index.ts +17 -1
- package/package.json +6 -6
- package/src/SpannerSchemaOperations.ts +4 -1
- package/test/ColumnEncryption.test.ts +252 -0
- package/test/EncryptedSearch.test.ts +247 -0
- package/test/EncryptionLifecycleWalker.test.ts +223 -0
- package/test/EncryptionPerf.test.ts +156 -0
- package/test/EncryptionSortAndUnique.test.ts +130 -0
- package/test/ReferenceColumnAdoptWidth.test.ts +155 -0
- package/test/index.ts +1 -0
- package/test/util/columnEncryptionTestHarness.ts +54 -0
- package/test/util/columnEncryptionTestTables.ts +115 -0
- package/test/util/referenceAdoptWidthTestTables.ts +23 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
DataKeyStore,
|
|
7
|
+
EncryptionEnvelope,
|
|
8
|
+
EncryptionKeyUnavailableError,
|
|
9
|
+
MasterKeyProvider,
|
|
10
|
+
QueryBuilder,
|
|
11
|
+
} from '@proteinjs/db';
|
|
12
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
13
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
14
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
15
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
16
|
+
import { EncNote, EncNoteTable } from './util/columnEncryptionTestTables';
|
|
17
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
18
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
19
|
+
import '../generated/test/index';
|
|
20
|
+
|
|
21
|
+
const spannerDriver = new SpannerDriver({
|
|
22
|
+
projectId: 'proteinjs-test',
|
|
23
|
+
instanceName: 'proteinjs-test',
|
|
24
|
+
databaseName: 'test',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Key IO (DataKeyStore) rides the process's default driver — the getDbAsSystem idiom the
|
|
28
|
+
* migration ledger already uses. The driver test harness has no DefaultDbDriverFactory, so
|
|
29
|
+
* seed the global the same way the factory would (test-harness convention: runtime access
|
|
30
|
+
* to internals rather than widening the API). */
|
|
31
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
32
|
+
|
|
33
|
+
const OWNER_A = 'enc-core-owner-a';
|
|
34
|
+
const OWNER_B = 'enc-core-owner-b';
|
|
35
|
+
|
|
36
|
+
/** Counts vault calls, so unwrap-once caching is asserted on OUTCOMES (vault traffic). */
|
|
37
|
+
class CountingMasterKeyProvider implements MasterKeyProvider {
|
|
38
|
+
public wrapCalls = 0;
|
|
39
|
+
public unwrapCalls = 0;
|
|
40
|
+
private delegate = new InMemoryMasterKeyProvider('column-encryption-test');
|
|
41
|
+
|
|
42
|
+
async wrapDataKey(material: Buffer): Promise<string> {
|
|
43
|
+
this.wrapCalls++;
|
|
44
|
+
return await this.delegate.wrapDataKey(material);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async unwrapDataKey(wrapped: string): Promise<Buffer> {
|
|
48
|
+
this.unwrapCalls++;
|
|
49
|
+
return await this.delegate.unwrapDataKey(wrapped);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getMasterKeyId(): string {
|
|
53
|
+
return 'counting-in-memory';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const masterKeyProvider = new CountingMasterKeyProvider();
|
|
58
|
+
let accessibleOwners: string[] = [OWNER_A];
|
|
59
|
+
|
|
60
|
+
const noteTable = new EncNoteTable() as Table<EncNote>;
|
|
61
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
62
|
+
const envelope = new EncryptionEnvelope();
|
|
63
|
+
|
|
64
|
+
const rawColumn = async (tableName: string, id: string, columnName: string): Promise<any> => {
|
|
65
|
+
const rows = await spannerDriver.runQuery(() => ({
|
|
66
|
+
sql: `SELECT \`${columnName}\` FROM \`${tableName}\` WHERE \`id\` = '${id}'`,
|
|
67
|
+
}));
|
|
68
|
+
return rows[0]?.[columnName];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
describe('Column encryption: the transparent seam', () => {
|
|
72
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
73
|
+
const tableManager = spannerDriver.getTableManager();
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
77
|
+
projectId: 'proteinjs-test',
|
|
78
|
+
instanceName: 'proteinjs-test',
|
|
79
|
+
databaseName: 'test',
|
|
80
|
+
});
|
|
81
|
+
registerTestUser();
|
|
82
|
+
setDbEncryptionConfig({
|
|
83
|
+
masterKeyProvider,
|
|
84
|
+
getAccessibleKeyOwners: async () => accessibleOwners,
|
|
85
|
+
});
|
|
86
|
+
await loadColumnEncryptionTestSchema(tableManager, [noteTable]);
|
|
87
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [noteTable]);
|
|
88
|
+
}, 120000);
|
|
89
|
+
|
|
90
|
+
afterAll(async () => {
|
|
91
|
+
setDbEncryptionConfig(undefined);
|
|
92
|
+
clearTestUser();
|
|
93
|
+
const { EncryptedColumns } = await import('@proteinjs/db');
|
|
94
|
+
await dropTable(new EncryptedColumns().tokenTableFor(noteTable)!);
|
|
95
|
+
await dropTable(noteTable);
|
|
96
|
+
await SpannerEmulatorProvisioner.release();
|
|
97
|
+
}, 60000);
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
accessibleOwners = [OWNER_A];
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('round trip is transparent: callers write and read plaintext; the database stores a ciphertext envelope', async () => {
|
|
104
|
+
const inserted = await db.insert(noteTable, {
|
|
105
|
+
scope: OWNER_A,
|
|
106
|
+
title: 'Therapy notes — divorce',
|
|
107
|
+
label: 'personal',
|
|
108
|
+
body: 'The most sensitive words in the account.',
|
|
109
|
+
status: 'active',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Caller-visible: plaintext, unchanged API
|
|
113
|
+
const fetched = await db.get(noteTable, { id: inserted.id });
|
|
114
|
+
expect(fetched.title).toBe('Therapy notes — divorce');
|
|
115
|
+
expect(fetched.label).toBe('personal');
|
|
116
|
+
expect(fetched.body).toBe('The most sensitive words in the account.');
|
|
117
|
+
expect(fetched.status).toBe('active');
|
|
118
|
+
|
|
119
|
+
// At rest: self-describing envelopes, no plaintext, plaintext column untouched
|
|
120
|
+
for (const columnName of ['title', 'label', 'body']) {
|
|
121
|
+
const stored = await rawColumn(noteTable.name, inserted.id, columnName);
|
|
122
|
+
expect(envelope.isEnvelope(stored)).toBe(true);
|
|
123
|
+
expect(stored).not.toContain('divorce');
|
|
124
|
+
expect(stored).not.toContain('sensitive');
|
|
125
|
+
const parsed = envelope.parse(stored)!;
|
|
126
|
+
expect(parsed.owner).toBe(OWNER_A);
|
|
127
|
+
}
|
|
128
|
+
expect(await rawColumn(noteTable.name, inserted.id, 'status')).toBe('active');
|
|
129
|
+
|
|
130
|
+
// Deserialized records carry no framework companion columns
|
|
131
|
+
expect(Object.keys(fetched).some((key) => key.includes('__enc'))).toBe(false);
|
|
132
|
+
|
|
133
|
+
await db.delete(noteTable, { id: inserted.id });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('update through the seam: new ciphertext at rest, plaintext to callers', async () => {
|
|
137
|
+
const inserted = await db.insert(noteTable, { scope: OWNER_A, title: 'Before', label: 'a', body: 'b' });
|
|
138
|
+
const updateCount = await db.update(noteTable, { id: inserted.id, title: 'After the update' });
|
|
139
|
+
expect(updateCount).toBe(1);
|
|
140
|
+
|
|
141
|
+
const fetched = await db.get(noteTable, { id: inserted.id });
|
|
142
|
+
expect(fetched.title).toBe('After the update');
|
|
143
|
+
expect(fetched.label).toBe('a'); // untouched columns survive
|
|
144
|
+
|
|
145
|
+
const stored = await rawColumn(noteTable.name, inserted.id, 'title');
|
|
146
|
+
expect(envelope.isEnvelope(stored)).toBe(true);
|
|
147
|
+
expect(stored).not.toContain('After');
|
|
148
|
+
|
|
149
|
+
await db.delete(noteTable, { id: inserted.id });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('null stays null: IS NULL / IS NOT NULL queries keep working natively', async () => {
|
|
153
|
+
const withTitle = await db.insert(noteTable, { scope: OWNER_A, title: 'present', label: 'x', body: null });
|
|
154
|
+
const withoutTitle = await db.insert(noteTable, { scope: OWNER_A, title: null, label: 'y', body: 'here' });
|
|
155
|
+
|
|
156
|
+
expect(await rawColumn(noteTable.name, withoutTitle.id, 'title')).toBeNull();
|
|
157
|
+
|
|
158
|
+
const nullTitles = await db.query(
|
|
159
|
+
noteTable,
|
|
160
|
+
new QueryBuilder<EncNote>(noteTable.name).condition({ field: 'title', operator: 'IS NULL' })
|
|
161
|
+
);
|
|
162
|
+
expect(nullTitles.some((row) => row.id === withoutTitle.id)).toBe(true);
|
|
163
|
+
expect(nullTitles.some((row) => row.id === withTitle.id)).toBe(false);
|
|
164
|
+
|
|
165
|
+
await db.delete(noteTable, { id: withTitle.id });
|
|
166
|
+
await db.delete(noteTable, { id: withoutTitle.id });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('adoption path: a pre-encryption plaintext row reads through the seam unchanged', async () => {
|
|
170
|
+
const id = 'enc-adoption-row-1';
|
|
171
|
+
await spannerDriver.runDml(() => ({
|
|
172
|
+
sql:
|
|
173
|
+
`INSERT INTO \`${noteTable.name}\` (\`id\`, \`created\`, \`updated\`, \`scope\`, \`title\`, \`status\`) ` +
|
|
174
|
+
`VALUES ('${id}', CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP(), '${OWNER_A}', 'legacy plaintext title', 'active')`,
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
const fetched = await db.get(noteTable, { id });
|
|
178
|
+
expect(fetched.title).toBe('legacy plaintext title');
|
|
179
|
+
await db.delete(noteTable, { id });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('scope-owner keying: each row encrypts under its scope owner; the vault is called once per key (unwrap caching)', async () => {
|
|
183
|
+
const rowA = await db.insert(noteTable, { scope: OWNER_A, title: 'a note', label: 'la', body: 'ba' });
|
|
184
|
+
const rowB = await db.insert(noteTable, { scope: OWNER_B, title: 'b note', label: 'lb', body: 'bb' });
|
|
185
|
+
|
|
186
|
+
expect(envelope.parse(await rawColumn(noteTable.name, rowA.id, 'title'))!.owner).toBe(OWNER_A);
|
|
187
|
+
expect(envelope.parse(await rawColumn(noteTable.name, rowB.id, 'title'))!.owner).toBe(OWNER_B);
|
|
188
|
+
|
|
189
|
+
// Server-side sharing mechanics: decrypt resolves the key FROM the envelope, so one
|
|
190
|
+
// reader's reads return both owners' rows decrypted.
|
|
191
|
+
const bothRows = [await db.get(noteTable, { id: rowA.id }), await db.get(noteTable, { id: rowB.id })];
|
|
192
|
+
expect(bothRows.map((row) => row.title)).toEqual(['a note', 'b note']);
|
|
193
|
+
|
|
194
|
+
// Unwrap-once caching: repeat reads add ZERO vault calls
|
|
195
|
+
const unwrapCallsBefore = masterKeyProvider.unwrapCalls;
|
|
196
|
+
for (let i = 0; i < 5; i++) {
|
|
197
|
+
await db.get(noteTable, { id: rowA.id });
|
|
198
|
+
await db.get(noteTable, { id: rowB.id });
|
|
199
|
+
}
|
|
200
|
+
expect(masterKeyProvider.unwrapCalls).toBe(unwrapCallsBefore);
|
|
201
|
+
|
|
202
|
+
await db.delete(noteTable, { id: rowA.id });
|
|
203
|
+
await db.delete(noteTable, { id: rowB.id });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a shared-scope row keys by the scope owner, not the writer: config resolveKeyOwner decides', async () => {
|
|
207
|
+
setDbEncryptionConfig({
|
|
208
|
+
masterKeyProvider,
|
|
209
|
+
getAccessibleKeyOwners: async () => accessibleOwners,
|
|
210
|
+
// The consumer's scope→owner mapping (thought-common's permission-source resolver in
|
|
211
|
+
// the app): here, rows whose scope starts with 'shared:' belong to OWNER_B.
|
|
212
|
+
resolveKeyOwner: async ({ record }) =>
|
|
213
|
+
typeof record.scope === 'string' && record.scope.startsWith('shared:') ? OWNER_B : undefined,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const sharedRow = await db.insert(noteTable, { scope: 'shared:tree-root', title: 'contributed text', label: 'l' });
|
|
217
|
+
expect(envelope.parse(await rawColumn(noteTable.name, sharedRow.id, 'title'))!.owner).toBe(OWNER_B);
|
|
218
|
+
|
|
219
|
+
await db.delete(noteTable, { id: sharedRow.id });
|
|
220
|
+
setDbEncryptionConfig({ masterKeyProvider, getAccessibleKeyOwners: async () => accessibleOwners });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('crypto-shred: deleting an owner key makes their rows permanently unreadable, loudly', async () => {
|
|
224
|
+
const shredOwner = 'enc-core-shred-owner';
|
|
225
|
+
const row = await db.insert(noteTable, { scope: shredOwner, title: 'to be shredded', label: 'x', body: 'y' });
|
|
226
|
+
|
|
227
|
+
const deleted = await new DataKeyStore().shredOwnerKeys(shredOwner);
|
|
228
|
+
expect(deleted).toBeGreaterThanOrEqual(1);
|
|
229
|
+
|
|
230
|
+
await expect(db.get(noteTable, { id: row.id })).rejects.toThrow(EncryptionKeyUnavailableError);
|
|
231
|
+
|
|
232
|
+
// other owners' rows are untouched (blast radius = one owner)
|
|
233
|
+
const survivor = await db.insert(noteTable, { scope: OWNER_A, title: 'still fine', label: 'ok' });
|
|
234
|
+
expect((await db.get(noteTable, { id: survivor.id })).title).toBe('still fine');
|
|
235
|
+
|
|
236
|
+
await spannerDriver.runDml(() => ({
|
|
237
|
+
sql: `DELETE FROM \`${noteTable.name}\` WHERE \`id\` = '${row.id}'`,
|
|
238
|
+
}));
|
|
239
|
+
await db.delete(noteTable, { id: survivor.id });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('transactions: encrypted writes and reads ride runTransaction like any other write', async () => {
|
|
243
|
+
const result = await db.runTransaction(async () => {
|
|
244
|
+
const inserted = await db.insert(noteTable, { scope: OWNER_A, title: 'txn title', label: 'txn' });
|
|
245
|
+
await db.update(noteTable, { id: inserted.id, body: 'txn body' });
|
|
246
|
+
return await db.get(noteTable, { id: inserted.id });
|
|
247
|
+
});
|
|
248
|
+
expect(result.title).toBe('txn title');
|
|
249
|
+
expect(result.body).toBe('txn body');
|
|
250
|
+
await db.delete(noteTable, { id: result.id });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
EncryptedColumns,
|
|
7
|
+
QueryBuilder,
|
|
8
|
+
} from '@proteinjs/db';
|
|
9
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
10
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
11
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
12
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
13
|
+
import { EncSearchDoc, EncSearchDocTable } from './util/columnEncryptionTestTables';
|
|
14
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
15
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
16
|
+
import '../generated/test/index';
|
|
17
|
+
|
|
18
|
+
const spannerDriver = new SpannerDriver({
|
|
19
|
+
projectId: 'proteinjs-test',
|
|
20
|
+
instanceName: 'proteinjs-test',
|
|
21
|
+
databaseName: 'test',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
25
|
+
|
|
26
|
+
const OWNER_A = 'enc-search-owner-a';
|
|
27
|
+
const OWNER_B = 'enc-search-owner-b';
|
|
28
|
+
|
|
29
|
+
let accessibleOwners: string[] = [OWNER_A];
|
|
30
|
+
|
|
31
|
+
const docTable = new EncSearchDocTable() as Table<EncSearchDoc>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
const titleLike = (pattern: string) =>
|
|
35
|
+
new QueryBuilder<EncSearchDoc>(docTable.name).condition({ field: 'title', operator: 'LIKE', value: pattern });
|
|
36
|
+
|
|
37
|
+
const titlesOf = (rows: EncSearchDoc[]) => rows.map((row) => row.title).sort();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Search exactness over encrypted columns — the recheck contract: the token index answers a
|
|
41
|
+
* candidate set, each candidate is verified against the decrypted value, so NO false
|
|
42
|
+
* positive surfaces and NO true match is missed (query words >= 3 chars); 1-2 character
|
|
43
|
+
* query words behave as word-prefix search (the documented narrowing).
|
|
44
|
+
*/
|
|
45
|
+
describe('Encrypted search: exact contains/prefix, equality, shared-scope OR', () => {
|
|
46
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
47
|
+
const tableManager = spannerDriver.getTableManager();
|
|
48
|
+
const seeded: string[] = [];
|
|
49
|
+
|
|
50
|
+
const seed = async (scope: string, title: string | null, name?: string | null, kind?: string) => {
|
|
51
|
+
const row = await db.insert(docTable, { scope, title, name: name ?? null, kind: kind ?? 'doc' });
|
|
52
|
+
seeded.push(row.id);
|
|
53
|
+
return row;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
58
|
+
projectId: 'proteinjs-test',
|
|
59
|
+
instanceName: 'proteinjs-test',
|
|
60
|
+
databaseName: 'test',
|
|
61
|
+
});
|
|
62
|
+
registerTestUser();
|
|
63
|
+
setDbEncryptionConfig({
|
|
64
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
65
|
+
getAccessibleKeyOwners: async () => accessibleOwners,
|
|
66
|
+
});
|
|
67
|
+
await loadColumnEncryptionTestSchema(tableManager, [docTable]);
|
|
68
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [docTable]);
|
|
69
|
+
|
|
70
|
+
// The corpus: owner A's documents...
|
|
71
|
+
await seed(OWNER_A, 'Groceries list');
|
|
72
|
+
await seed(OWNER_A, 'Grocery budget planning');
|
|
73
|
+
await seed(OWNER_A, 'Pancake factory notes');
|
|
74
|
+
await seed(OWNER_A, 'cake factory shift plan');
|
|
75
|
+
await seed(OWNER_A, 'Therapy notes — divorce');
|
|
76
|
+
await seed(OWNER_A, 'n3xa notes');
|
|
77
|
+
await seed(OWNER_A, null, 'tag-null-title');
|
|
78
|
+
await seed(OWNER_A, 'Once and future factories');
|
|
79
|
+
// holds every fragment of "cake factory" NON-contiguously — a token-cover candidate the
|
|
80
|
+
// verify step must reject
|
|
81
|
+
await seed(OWNER_A, 'factory made cake platter');
|
|
82
|
+
// ...and owner B's (a sharer), including an overlapping word
|
|
83
|
+
await seed(OWNER_B, 'Groceries for the trip');
|
|
84
|
+
await seed(OWNER_B, 'B-only cake recipe');
|
|
85
|
+
}, 120000);
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
for (const id of seeded) {
|
|
89
|
+
await db.delete(docTable, { id }).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
setDbEncryptionConfig(undefined);
|
|
92
|
+
clearTestUser();
|
|
93
|
+
await dropTable(new EncryptedColumns().tokenTableFor(docTable)!);
|
|
94
|
+
await dropTable(docTable);
|
|
95
|
+
await SpannerEmulatorProvisioner.release();
|
|
96
|
+
}, 120000);
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
accessibleOwners = [OWNER_A];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('contains search is exact: every true match returned, nothing else (the home-search shape)', async () => {
|
|
103
|
+
const rows = await db.query(docTable, titleLike('%Groceries%'));
|
|
104
|
+
expect(titlesOf(rows)).toEqual(['Groceries list']);
|
|
105
|
+
|
|
106
|
+
// multi-word contains, matching across a word prefix ("cake factory" inside "Pancake
|
|
107
|
+
// factory") — and NOT matching 'factory made cake platter', whose token cover holds
|
|
108
|
+
// every fragment non-contiguously (the verify step rejects it)
|
|
109
|
+
const factoryRows = await db.query(docTable, titleLike('%cake factory%'));
|
|
110
|
+
expect(titlesOf(factoryRows)).toEqual(['Pancake factory notes', 'cake factory shift plan']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('no false positives: rows holding all fragments non-contiguously are rejected by the verify step', async () => {
|
|
114
|
+
// "Once and future factories" holds trigrams of 'fact'/'tor' etc. — but not the
|
|
115
|
+
// contiguous phrase "factory shift"
|
|
116
|
+
const rows = await db.query(docTable, titleLike('%factory shift%'));
|
|
117
|
+
expect(titlesOf(rows)).toEqual(['cake factory shift plan']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('case-insensitive LIKE (caseSensitive=false) matches across case', async () => {
|
|
121
|
+
const qb = new QueryBuilder<EncSearchDoc>(docTable.name);
|
|
122
|
+
qb.condition({ field: 'title', operator: 'LIKE', value: '%GROCERIES%' }, undefined, false);
|
|
123
|
+
const rows = await db.query(docTable, qb);
|
|
124
|
+
expect(titlesOf(rows)).toEqual(['Groceries list']);
|
|
125
|
+
|
|
126
|
+
// and the default case-sensitive LIKE stays exact about case
|
|
127
|
+
const strict = await db.query(docTable, titleLike('%GROCERIES%'));
|
|
128
|
+
expect(strict.length).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('prefix search (q%) matches value starts only', async () => {
|
|
132
|
+
const rows = await db.query(docTable, titleLike('Groc%'));
|
|
133
|
+
expect(titlesOf(rows)).toEqual(['Groceries list', 'Grocery budget planning']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('1-2 character queries behave as word-prefix search (the documented narrowing)', async () => {
|
|
137
|
+
// 'n' finds "n3xa notes" (word start), and also "notes" words — anything with an n-starting word
|
|
138
|
+
const rows = await db.query(docTable, titleLike('%n%'));
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
expect(
|
|
141
|
+
(row.title ?? '')
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.split(/[^a-z0-9]+/)
|
|
144
|
+
.some((word) => word.startsWith('n'))
|
|
145
|
+
).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
expect(titlesOf(rows)).toContain('n3xa notes');
|
|
148
|
+
|
|
149
|
+
// a 1-2 char MID-WORD substring is documented unsupported: 'x' inside 'n3xa' does not match
|
|
150
|
+
const midWord = await db.query(docTable, titleLike('%x%'));
|
|
151
|
+
expect(titlesOf(midWord)).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('composition: encrypted search ANDs with metadata conditions natively', async () => {
|
|
155
|
+
const rows = await db.query(
|
|
156
|
+
docTable,
|
|
157
|
+
titleLike('%Groceries%').condition({ field: 'kind', operator: '=', value: 'doc' })
|
|
158
|
+
);
|
|
159
|
+
expect(titlesOf(rows)).toEqual(['Groceries list']);
|
|
160
|
+
|
|
161
|
+
const noRows = await db.query(
|
|
162
|
+
docTable,
|
|
163
|
+
titleLike('%Groceries%').condition({ field: 'kind', operator: '=', value: 'other' })
|
|
164
|
+
);
|
|
165
|
+
expect(noRows.length).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('getRowCount over an encrypted search is exact', async () => {
|
|
169
|
+
expect(await db.getRowCount(docTable, titleLike('%cake factory%'))).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('equality lookup rides the whole-value fingerprint: exact, case-exact (the tag get-or-create shape)', async () => {
|
|
173
|
+
await seed(OWNER_A, 'eq holder', 'Divorce');
|
|
174
|
+
await seed(OWNER_A, 'eq holder 2', 'divorce');
|
|
175
|
+
|
|
176
|
+
const exact = await db.get(docTable, { name: 'Divorce' });
|
|
177
|
+
expect(exact.title).toBe('eq holder');
|
|
178
|
+
|
|
179
|
+
const lower = await db.query(docTable, { name: 'divorce' } as any);
|
|
180
|
+
expect(lower.length).toBe(1);
|
|
181
|
+
expect(lower[0].title).toBe('eq holder 2');
|
|
182
|
+
|
|
183
|
+
const missing = await db.get(docTable, { name: 'no-such-tag' });
|
|
184
|
+
expect(missing).toBeFalsy();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('shared-scope search: fingerprints once per accessible owner, ORs the matches', async () => {
|
|
188
|
+
// Only own scope accessible: A sees only A's groceries
|
|
189
|
+
const own = await db.query(docTable, titleLike('%Groceries%'));
|
|
190
|
+
expect(titlesOf(own)).toEqual(['Groceries list']);
|
|
191
|
+
|
|
192
|
+
// B shares with A: the same caller query now covers both owners' keys
|
|
193
|
+
accessibleOwners = [OWNER_A, OWNER_B];
|
|
194
|
+
const shared = await db.query(docTable, titleLike('%Groceries%'));
|
|
195
|
+
expect(titlesOf(shared)).toEqual(['Groceries for the trip', 'Groceries list']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('token rows follow the row lifecycle: update rewrites them, delete removes them', async () => {
|
|
199
|
+
const row = await db.insert(docTable, {
|
|
200
|
+
scope: OWNER_A,
|
|
201
|
+
title: 'ephemeral zebra thoughts',
|
|
202
|
+
name: null,
|
|
203
|
+
kind: 'doc',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(titlesOf(await db.query(docTable, titleLike('%zebra%')))).toEqual(['ephemeral zebra thoughts']);
|
|
207
|
+
|
|
208
|
+
await db.update(docTable, { id: row.id, title: 'renamed giraffe thoughts' });
|
|
209
|
+
expect((await db.query(docTable, titleLike('%zebra%'))).length).toBe(0);
|
|
210
|
+
expect(titlesOf(await db.query(docTable, titleLike('%giraffe%')))).toEqual(['renamed giraffe thoughts']);
|
|
211
|
+
|
|
212
|
+
await db.delete(docTable, { id: row.id });
|
|
213
|
+
expect((await db.query(docTable, titleLike('%giraffe%'))).length).toBe(0);
|
|
214
|
+
|
|
215
|
+
// and the token table holds no orphans for the deleted row
|
|
216
|
+
const tokenTable = new EncryptedColumns().tokenTableFor(docTable)!;
|
|
217
|
+
const orphans = await spannerDriver.runQuery(() => ({
|
|
218
|
+
sql: `SELECT COUNT(*) as tokenCount FROM \`${tokenTable.name}\` WHERE \`record_id\` = '${row.id}'`,
|
|
219
|
+
}));
|
|
220
|
+
expect(Number((orphans[0] as any).tokenCount)).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('delete by encrypted search: WHERE title LIKE deletes exactly the true matches', async () => {
|
|
224
|
+
const kept = await db.insert(docTable, { scope: OWNER_A, title: 'wombat keep', name: null, kind: 'doc' });
|
|
225
|
+
const doomed = await db.insert(docTable, { scope: OWNER_A, title: 'wombat purge now', name: null, kind: 'doc' });
|
|
226
|
+
|
|
227
|
+
const deleteCount = await db.delete(docTable, titleLike('%purge now%'));
|
|
228
|
+
expect(deleteCount).toBe(1);
|
|
229
|
+
expect((await db.get(docTable, { id: kept.id })).title).toBe('wombat keep');
|
|
230
|
+
expect(await db.get(docTable, { id: doomed.id })).toBeFalsy();
|
|
231
|
+
|
|
232
|
+
await db.delete(docTable, { id: kept.id });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('update by encrypted search: WHERE title LIKE updates exactly the true matches', async () => {
|
|
236
|
+
const target = await db.insert(docTable, { scope: OWNER_A, title: 'quokka target row', name: null, kind: 'doc' });
|
|
237
|
+
const bystander = await db.insert(docTable, { scope: OWNER_A, title: 'quokka bystander', name: null, kind: 'doc' });
|
|
238
|
+
|
|
239
|
+
const updateCount = await db.update(docTable, { kind: 'flagged' }, titleLike('%target row%'));
|
|
240
|
+
expect(updateCount).toBe(1);
|
|
241
|
+
expect((await db.get(docTable, { id: target.id })).kind).toBe('flagged');
|
|
242
|
+
expect((await db.get(docTable, { id: bystander.id })).kind).toBe('doc');
|
|
243
|
+
|
|
244
|
+
await db.delete(docTable, { id: target.id });
|
|
245
|
+
await db.delete(docTable, { id: bystander.id });
|
|
246
|
+
});
|
|
247
|
+
});
|