@proteinjs/db-driver-spanner 1.23.5 → 1.24.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 +16 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +15 -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/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/serviceUpdateVerbsTestTables.d.ts +2 -2
- package/generated/test/index.ts +15 -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/index.ts +1 -0
- package/test/util/columnEncryptionTestHarness.ts +54 -0
- package/test/util/columnEncryptionTestTables.ts +115 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
EncryptedColumns,
|
|
7
|
+
EncryptedColumnQueryError,
|
|
8
|
+
QueryBuilder,
|
|
9
|
+
} from '@proteinjs/db';
|
|
10
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
11
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
12
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
13
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
14
|
+
import { EncSortedItem, EncSortedItemTable, EncUniqueTag, EncUniqueTagTable } from './util/columnEncryptionTestTables';
|
|
15
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
16
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
17
|
+
import '../generated/test/index';
|
|
18
|
+
|
|
19
|
+
const spannerDriver = new SpannerDriver({
|
|
20
|
+
projectId: 'proteinjs-test',
|
|
21
|
+
instanceName: 'proteinjs-test',
|
|
22
|
+
databaseName: 'test',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
26
|
+
|
|
27
|
+
const OWNER_A = 'enc-sort-owner-a';
|
|
28
|
+
const OWNER_B = 'enc-sort-owner-b';
|
|
29
|
+
|
|
30
|
+
const sortedTable = new EncSortedItemTable() as Table<EncSortedItem>;
|
|
31
|
+
const uniqueTable = new EncUniqueTagTable() as Table<EncUniqueTag>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The sortKey tier of the sorting story (a DECLARED bounded reveal → native ORDER BY at any
|
|
36
|
+
* scale), the loud live rejection without one, and value uniqueness through the equality
|
|
37
|
+
* fingerprint (per owner).
|
|
38
|
+
*/
|
|
39
|
+
describe('Encrypted sortKey ORDER BY + fingerprint uniqueness', () => {
|
|
40
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
41
|
+
const tableManager = spannerDriver.getTableManager();
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
45
|
+
projectId: 'proteinjs-test',
|
|
46
|
+
instanceName: 'proteinjs-test',
|
|
47
|
+
databaseName: 'test',
|
|
48
|
+
});
|
|
49
|
+
registerTestUser();
|
|
50
|
+
setDbEncryptionConfig({
|
|
51
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
52
|
+
getAccessibleKeyOwners: async () => [OWNER_A],
|
|
53
|
+
});
|
|
54
|
+
await loadColumnEncryptionTestSchema(tableManager, [sortedTable, uniqueTable]);
|
|
55
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [sortedTable, uniqueTable]);
|
|
56
|
+
}, 120000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
setDbEncryptionConfig(undefined);
|
|
60
|
+
clearTestUser();
|
|
61
|
+
await dropTable(new EncryptedColumns().tokenTableFor(sortedTable)!);
|
|
62
|
+
await dropTable(sortedTable);
|
|
63
|
+
await dropTable(uniqueTable);
|
|
64
|
+
await SpannerEmulatorProvisioner.release();
|
|
65
|
+
}, 120000);
|
|
66
|
+
|
|
67
|
+
test('sortKey: native ORDER BY + sorted pagination over encrypted values; only the declared prefix is revealed at rest', async () => {
|
|
68
|
+
const titles = ['banana bread', 'Apple pie', 'cherry tart', 'apricot jam', 'Blueberry scone'];
|
|
69
|
+
const ids: string[] = [];
|
|
70
|
+
for (const title of titles) {
|
|
71
|
+
ids.push((await db.insert(sortedTable, { scope: OWNER_A, title })).id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// native DB-side ORDER BY on the encrypted column (revealPrefix=3 buckets), ascending
|
|
75
|
+
const sorted = await db.query(
|
|
76
|
+
sortedTable,
|
|
77
|
+
new QueryBuilder<EncSortedItem>(sortedTable.name)
|
|
78
|
+
.condition({ field: 'scope', operator: '=', value: OWNER_A })
|
|
79
|
+
.sort([{ field: 'title' }])
|
|
80
|
+
);
|
|
81
|
+
const sortedTitles = sorted.map((row) => row.title);
|
|
82
|
+
// prefix buckets (first 3 chars, normalized): app=Apple/apricot tie-break within bucket,
|
|
83
|
+
// then ban, blu, che — assert bucket ORDER, the declared guarantee
|
|
84
|
+
const buckets = sortedTitles.map((title) => (title ?? '').toLowerCase().slice(0, 3));
|
|
85
|
+
expect(buckets).toEqual([...buckets].sort());
|
|
86
|
+
|
|
87
|
+
// sorted PAGINATION: the first page of the prefix order
|
|
88
|
+
const firstPage = await db.query(
|
|
89
|
+
sortedTable,
|
|
90
|
+
new QueryBuilder<EncSortedItem>(sortedTable.name)
|
|
91
|
+
.condition({ field: 'scope', operator: '=', value: OWNER_A })
|
|
92
|
+
.sort([{ field: 'title' }])
|
|
93
|
+
.paginate({ start: 0, end: 2 })
|
|
94
|
+
);
|
|
95
|
+
expect(firstPage.length).toBe(2);
|
|
96
|
+
for (const row of firstPage) {
|
|
97
|
+
expect((row.title ?? '').toLowerCase().slice(0, 2)).toBe('ap');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// at rest: the sort companion holds EXACTLY the declared 3-character reveal
|
|
101
|
+
const raw = await spannerDriver.runQuery(() => ({
|
|
102
|
+
sql: `SELECT \`title\`, \`title_enc_srt\` FROM \`${sortedTable.name}\` WHERE \`id\` = '${ids[0]}'`,
|
|
103
|
+
}));
|
|
104
|
+
expect((raw[0] as any).title_enc_srt).toBe('ban');
|
|
105
|
+
expect((raw[0] as any).title).not.toContain('banana');
|
|
106
|
+
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
await db.delete(sortedTable, { id });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the live loud rejection: ORDER BY an encrypted column with no sortKey throws at query-build time', async () => {
|
|
113
|
+
const tagQb = new QueryBuilder<EncUniqueTag>(uniqueTable.name).sort([{ field: 'name' }]);
|
|
114
|
+
await expect(db.query(uniqueTable, tagQb)).rejects.toThrow(EncryptedColumnQueryError);
|
|
115
|
+
|
|
116
|
+
const tagQb2 = new QueryBuilder<EncUniqueTag>(uniqueTable.name).sort([{ field: 'name' }]);
|
|
117
|
+
await expect(db.query(uniqueTable, tagQb2)).rejects.toThrow(/no sortKey declared/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('uniqueness on the value rides the fingerprint: duplicate value for the same owner rejected; other owners unaffected', async () => {
|
|
121
|
+
const first = await db.insert(uniqueTable, { scope: OWNER_A, name: 'oncologist' });
|
|
122
|
+
await expect(db.insert(uniqueTable, { scope: OWNER_A, name: 'oncologist' })).rejects.toThrow();
|
|
123
|
+
|
|
124
|
+
// per-owner semantics: another owner may hold the same value (fingerprints are keyed per owner)
|
|
125
|
+
const otherOwner = await db.insert(uniqueTable, { scope: OWNER_B, name: 'oncologist' });
|
|
126
|
+
|
|
127
|
+
await db.delete(uniqueTable, { id: first.id });
|
|
128
|
+
await db.delete(uniqueTable, { id: otherOwner.id });
|
|
129
|
+
});
|
|
130
|
+
});
|
package/test/index.ts
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DataEncryptionKeyTable, EncryptedColumns, Table, TableManager } from '@proteinjs/db';
|
|
2
|
+
import { cascadeDeleteTestTables } from '@proteinjs/db/test';
|
|
3
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Schema loading for the column-encryption suites. `Db.delete` consults the reverse-cascade
|
|
7
|
+
* edge index over the WHOLE static table registry, and dynamic-reference edges are
|
|
8
|
+
* target-agnostic — so a delete from ANY table queries every registered table holding a
|
|
9
|
+
* `reverseCascadeDelete` dynamic-reference column (the cascade-delete test tables). A
|
|
10
|
+
* single-file run must therefore create those alongside the suite's own tables, plus the
|
|
11
|
+
* framework's data-key table. Serial `loadTable` (never batch `loadTables`): the registry
|
|
12
|
+
* deliberately holds same-name table pairs for schema-change tests, which a batch create
|
|
13
|
+
* would collide on.
|
|
14
|
+
*
|
|
15
|
+
* @internal This module is intended to be used only in tests.
|
|
16
|
+
*/
|
|
17
|
+
export const loadColumnEncryptionTestSchema = async (
|
|
18
|
+
tableManager: TableManager,
|
|
19
|
+
suiteTables: Table<any>[]
|
|
20
|
+
): Promise<void> => {
|
|
21
|
+
const tables: Table<any>[] = [
|
|
22
|
+
...Object.values(cascadeDeleteTestTables),
|
|
23
|
+
new DataEncryptionKeyTable(),
|
|
24
|
+
...suiteTables,
|
|
25
|
+
];
|
|
26
|
+
for (const table of tables) {
|
|
27
|
+
await tableManager.loadTable(table);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clean-slate purge of a suite's tables (rows + their derived token tables) in beforeAll:
|
|
33
|
+
* a prior run killed mid-flight (or a teardown starved out by shared-emulator contention)
|
|
34
|
+
* leaves seeded rows behind, and exact-match search assertions must never depend on a
|
|
35
|
+
* previous process having exited cleanly.
|
|
36
|
+
*/
|
|
37
|
+
export const purgeColumnEncryptionTestRows = async (
|
|
38
|
+
spannerDriver: SpannerDriver,
|
|
39
|
+
suiteTables: Table<any>[]
|
|
40
|
+
): Promise<void> => {
|
|
41
|
+
const encryptedColumns = new EncryptedColumns();
|
|
42
|
+
const tables: Table<any>[] = [];
|
|
43
|
+
for (const table of suiteTables) {
|
|
44
|
+
tables.push(table);
|
|
45
|
+
const tokenTable = encryptedColumns.tokenTableFor(table);
|
|
46
|
+
if (tokenTable) {
|
|
47
|
+
tables.push(tokenTable);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const table of tables) {
|
|
52
|
+
await spannerDriver.runDml(() => ({ sql: `DELETE FROM \`${table.name}\` WHERE TRUE` }));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tables for the column-encryption suites (`ColumnEncryption.test.ts`,
|
|
5
|
+
* `EncryptedSearch.test.ts`, `EncryptionSortAndUnique.test.ts`,
|
|
6
|
+
* `EncryptionLifecycleWalker.test.ts`, `EncryptionPerf.test.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Defined here rather than in the test files so the reflection build registers them as
|
|
9
|
+
* `Table` loadables — the driver's statement generation and the framework's derived
|
|
10
|
+
* token-table subqueries resolve tables through `tableByName`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Core seam suite: contains + equality + opaque encrypted + plaintext metadata. */
|
|
14
|
+
export interface EncNote extends Record {
|
|
15
|
+
scope: string;
|
|
16
|
+
title?: string | null; // encrypted + contains search
|
|
17
|
+
label?: string | null; // encrypted + equality lookup
|
|
18
|
+
body?: string | null; // encrypted, never queried by value
|
|
19
|
+
status?: string | null; // plaintext metadata
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class EncNoteTable extends Table<EncNote> {
|
|
23
|
+
name = 'db_test_enc_note';
|
|
24
|
+
columns: Table<EncNote>['columns'] = withRecordColumns<EncNote>({
|
|
25
|
+
scope: new StringColumn('scope', {}, 36),
|
|
26
|
+
title: new StringColumn('title', { encrypted: { searchable: 'contains' } }),
|
|
27
|
+
label: new StringColumn('label', { encrypted: { searchable: 'equality' } }),
|
|
28
|
+
body: new StringColumn('body', { encrypted: {} }, 'MAX'),
|
|
29
|
+
status: new StringColumn('status', { encrypted: false }),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Search-exactness suite: one contains column beside plaintext metadata. */
|
|
34
|
+
export interface EncSearchDoc extends Record {
|
|
35
|
+
scope: string;
|
|
36
|
+
title?: string | null; // encrypted + contains
|
|
37
|
+
name?: string | null; // encrypted + equality
|
|
38
|
+
kind?: string | null; // plaintext metadata
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class EncSearchDocTable extends Table<EncSearchDoc> {
|
|
42
|
+
name = 'db_test_enc_search_doc';
|
|
43
|
+
columns: Table<EncSearchDoc>['columns'] = withRecordColumns<EncSearchDoc>({
|
|
44
|
+
scope: new StringColumn('scope', {}, 36),
|
|
45
|
+
title: new StringColumn('title', { encrypted: { searchable: 'contains' } }),
|
|
46
|
+
name: new StringColumn('name', { encrypted: { searchable: 'equality' } }),
|
|
47
|
+
kind: new StringColumn('kind', { encrypted: false }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** sortKey suite: the declared bounded-reveal native ORDER BY. */
|
|
52
|
+
export interface EncSortedItem extends Record {
|
|
53
|
+
scope: string;
|
|
54
|
+
title?: string | null; // encrypted + sortKey(3)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class EncSortedItemTable extends Table<EncSortedItem> {
|
|
58
|
+
name = 'db_test_enc_sorted_item';
|
|
59
|
+
columns: Table<EncSortedItem>['columns'] = withRecordColumns<EncSortedItem>({
|
|
60
|
+
scope: new StringColumn('scope', {}, 36),
|
|
61
|
+
title: new StringColumn('title', { encrypted: { searchable: 'contains', sortKey: { revealPrefix: 3 } } }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Uniqueness suite: unique moves onto the equality fingerprint. */
|
|
66
|
+
export interface EncUniqueTag extends Record {
|
|
67
|
+
scope: string;
|
|
68
|
+
name?: string | null; // encrypted + equality, unique
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class EncUniqueTagTable extends Table<EncUniqueTag> {
|
|
72
|
+
name = 'db_test_enc_unique_tag';
|
|
73
|
+
columns: Table<EncUniqueTag>['columns'] = withRecordColumns<EncUniqueTag>({
|
|
74
|
+
scope: new StringColumn('scope', {}, 36),
|
|
75
|
+
name: new StringColumn('name', { unique: { unique: true }, encrypted: { searchable: 'equality' } }),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Lifecycle-walker suite: encrypt / retokenize / decrypt / rotate transitions. */
|
|
80
|
+
export interface EncWalkRow extends Record {
|
|
81
|
+
scope: string;
|
|
82
|
+
title?: string | null; // encrypted + contains
|
|
83
|
+
body?: string | null; // encrypted, no search
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class EncWalkRowTable extends Table<EncWalkRow> {
|
|
87
|
+
name = 'db_test_enc_walk_row';
|
|
88
|
+
columns: Table<EncWalkRow>['columns'] = withRecordColumns<EncWalkRow>({
|
|
89
|
+
scope: new StringColumn('scope', {}, 36),
|
|
90
|
+
title: new StringColumn('title', { encrypted: { searchable: 'contains' } }),
|
|
91
|
+
body: new StringColumn('body', { encrypted: {} }, 'MAX'),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Perf suite: encrypted-at-rest body vs a plaintext twin, 1k-row reads. */
|
|
96
|
+
export interface EncPerfRow extends Record {
|
|
97
|
+
scope: string;
|
|
98
|
+
body?: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class EncPerfRowTable extends Table<EncPerfRow> {
|
|
102
|
+
name = 'db_test_enc_perf_row';
|
|
103
|
+
columns: Table<EncPerfRow>['columns'] = withRecordColumns<EncPerfRow>({
|
|
104
|
+
scope: new StringColumn('scope', {}, 36),
|
|
105
|
+
body: new StringColumn('body', { encrypted: {} }, 'MAX'),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class PlainPerfRowTable extends Table<EncPerfRow> {
|
|
110
|
+
name = 'db_test_plain_perf_row';
|
|
111
|
+
columns: Table<EncPerfRow>['columns'] = withRecordColumns<EncPerfRow>({
|
|
112
|
+
scope: new StringColumn('scope', {}, 36),
|
|
113
|
+
body: new StringColumn('body', { encrypted: false }, 'MAX'),
|
|
114
|
+
});
|
|
115
|
+
}
|