@proteinjs/user-server 1.13.0 → 1.15.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 +22 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/migrations/BackfillUserStatusActive.d.ts +27 -0
- package/dist/src/migrations/BackfillUserStatusActive.d.ts.map +1 -0
- package/dist/src/migrations/BackfillUserStatusActive.js +103 -0
- package/dist/src/migrations/BackfillUserStatusActive.js.map +1 -0
- package/dist/test/BackfillUserStatusActive.integration.test.d.ts +2 -0
- package/dist/test/BackfillUserStatusActive.integration.test.d.ts.map +1 -0
- package/dist/test/BackfillUserStatusActive.integration.test.js +208 -0
- package/dist/test/BackfillUserStatusActive.integration.test.js.map +1 -0
- package/dist/test/SharedScopeEncryption.integration.test.d.ts +2 -0
- package/dist/test/SharedScopeEncryption.integration.test.d.ts.map +1 -0
- package/dist/test/SharedScopeEncryption.integration.test.js +571 -0
- package/dist/test/SharedScopeEncryption.integration.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +4 -4
- package/src/migrations/BackfillUserStatusActive.ts +43 -0
- package/test/BackfillUserStatusActive.integration.test.ts +98 -0
- package/test/SharedScopeEncryption.integration.test.ts +363 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DataEncryptionKeyTable,
|
|
3
|
+
DataKeyStore,
|
|
4
|
+
EncryptedColumns,
|
|
5
|
+
EncryptionEnvelope,
|
|
6
|
+
InMemoryMasterKeyProvider,
|
|
7
|
+
QueryBuilder,
|
|
8
|
+
Reference,
|
|
9
|
+
StringColumn,
|
|
10
|
+
Table,
|
|
11
|
+
getDb,
|
|
12
|
+
getDbAsSystem,
|
|
13
|
+
getTables,
|
|
14
|
+
setDbEncryptionConfig,
|
|
15
|
+
} from '@proteinjs/db';
|
|
16
|
+
import type { DataEncryptionKey } from '@proteinjs/db';
|
|
17
|
+
import { getDropTestTable } from '@proteinjs/db-driver-spanner/test';
|
|
18
|
+
import {
|
|
19
|
+
AccessGrant,
|
|
20
|
+
SharedRecord,
|
|
21
|
+
SharedScopeKeyOwners,
|
|
22
|
+
User,
|
|
23
|
+
getSharedDb,
|
|
24
|
+
getSharedDbWithOverride,
|
|
25
|
+
tables,
|
|
26
|
+
withSharedRecordColumns,
|
|
27
|
+
} from '@proteinjs/user';
|
|
28
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Share-with-support MVP — encrypted rows in SHARED permission scopes
|
|
32
|
+
* (TRUST_AND_COMPLIANCE §4/§4.4; SHARING_EXPANSION §3). Outcomes asserted against the
|
|
33
|
+
* emulator: envelopes at rest, rows readable/searchable per principal — never calls made.
|
|
34
|
+
*
|
|
35
|
+
* The mechanism under test is `SharedScopeKeyOwners` composed into `DbEncryptionConfig`:
|
|
36
|
+
* - rows key by the SCOPE-ROOT OWNER (a contributor's write keys under the document owner);
|
|
37
|
+
* - a share grant extends decrypt + blind-index search to the recipient (support = an
|
|
38
|
+
* ordinary read grant — the §4.4 path is structurally just a share);
|
|
39
|
+
* - revocation ends access with NO re-encryption (keys are not access control);
|
|
40
|
+
* - owner key rotation and crypto-shred honor the same owner axis.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
interface EncSharedNote extends SharedRecord {
|
|
44
|
+
title?: string | null; // encrypted + contains search
|
|
45
|
+
label?: string | null; // encrypted + equality lookup
|
|
46
|
+
body?: string | null; // encrypted, never queried by value
|
|
47
|
+
kind?: string | null; // plaintext metadata
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class EncSharedNoteTable extends Table<EncSharedNote> {
|
|
51
|
+
name = 'user_test_enc_shared_note';
|
|
52
|
+
auth: Table<EncSharedNote>['auth'] = {
|
|
53
|
+
db: { all: 'authenticated' },
|
|
54
|
+
service: { all: 'authenticated' },
|
|
55
|
+
};
|
|
56
|
+
columns = withSharedRecordColumns<EncSharedNote>({
|
|
57
|
+
title: new StringColumn('title', { encrypted: { searchable: 'contains' } }),
|
|
58
|
+
label: new StringColumn('label', { encrypted: { searchable: 'equality' } }),
|
|
59
|
+
body: new StringColumn('body', { encrypted: {} }, 'MAX'),
|
|
60
|
+
kind: new StringColumn('kind', { encrypted: false }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const testEnv = new UserServerTestEnvironment();
|
|
65
|
+
const noteTable = new EncSharedNoteTable() as Table<EncSharedNote>;
|
|
66
|
+
const keyOwners = new SharedScopeKeyOwners();
|
|
67
|
+
const envelope = new EncryptionEnvelope();
|
|
68
|
+
|
|
69
|
+
const rawColumn = async (id: string, columnName: string): Promise<any> => {
|
|
70
|
+
const rows = await testEnv.spannerDriver.runQuery(() => ({
|
|
71
|
+
sql: `SELECT \`${columnName}\` FROM \`${noteTable.name}\` WHERE \`id\` = '${id}'`,
|
|
72
|
+
}));
|
|
73
|
+
return rows[0]?.[columnName];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const titleLike = (pattern: string) =>
|
|
77
|
+
new QueryBuilder<EncSharedNote>(noteTable.name).condition({ field: 'title', operator: 'LIKE', value: pattern });
|
|
78
|
+
|
|
79
|
+
const clearResolvedOwnerCache = () => {
|
|
80
|
+
((globalThis as any)['__proteinjs_user_sharedScopeOwnerCache'] as Map<string, unknown> | undefined)?.clear();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
describe('Shared-scope encryption: owner-keyed scopes, share/revoke, support path', () => {
|
|
84
|
+
const dropTestTable = getDropTestTable(testEnv.spannerDriver);
|
|
85
|
+
let owner: User; // O — creates the document
|
|
86
|
+
let writer: User; // W — write-grant contributor
|
|
87
|
+
let support: User; // S — the §4.4 support principal (read grant via the help flow)
|
|
88
|
+
let stranger: User; // X — never granted
|
|
89
|
+
let coOwner: User; // C — conferred owner (owner-ceiling path)
|
|
90
|
+
let root: EncSharedNote;
|
|
91
|
+
let supportGrantId: string;
|
|
92
|
+
|
|
93
|
+
beforeAll(async () => {
|
|
94
|
+
await testEnv.beforeAll();
|
|
95
|
+
// Register the suite's table for name-based resolution (statement generation and the
|
|
96
|
+
// derived token-table subqueries resolve through `tableByName`), then create the
|
|
97
|
+
// physical schema: the framework's key table + the note table (its token table and
|
|
98
|
+
// companion columns ride the same load).
|
|
99
|
+
(getTables() as Table<any>[]).push(noteTable);
|
|
100
|
+
const tableManager = testEnv.spannerDriver.getTableManager();
|
|
101
|
+
await tableManager.loadTable(new DataEncryptionKeyTable());
|
|
102
|
+
await tableManager.loadTable(noteTable);
|
|
103
|
+
|
|
104
|
+
setDbEncryptionConfig({
|
|
105
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('shared-scope-encryption-test'),
|
|
106
|
+
resolveKeyOwner: (args) => keyOwners.resolveKeyOwner(args),
|
|
107
|
+
getAccessibleKeyOwners: (args) => keyOwners.getAccessibleKeyOwners(args),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const db = getDbAsSystem();
|
|
111
|
+
await db.delete(tables.AccessGrant, {});
|
|
112
|
+
await db.delete(tables.User, {});
|
|
113
|
+
await db.delete(new DataEncryptionKeyTable(), {});
|
|
114
|
+
clearResolvedOwnerCache();
|
|
115
|
+
|
|
116
|
+
owner = await testEnv.createUser({ name: 'Enc Owner', email: 'enc-owner@test.local' });
|
|
117
|
+
writer = await testEnv.createUser({ name: 'Enc Writer', email: 'enc-writer@test.local' });
|
|
118
|
+
support = await testEnv.createUser({ name: 'Enc Support', email: 'enc-support@test.local' });
|
|
119
|
+
stranger = await testEnv.createUser({ name: 'Enc Stranger', email: 'enc-stranger@test.local' });
|
|
120
|
+
coOwner = await testEnv.createUser({ name: 'Enc CoOwner', email: 'enc-coowner@test.local' });
|
|
121
|
+
}, 180000);
|
|
122
|
+
|
|
123
|
+
afterAll(async () => {
|
|
124
|
+
setDbEncryptionConfig(undefined);
|
|
125
|
+
clearResolvedOwnerCache();
|
|
126
|
+
await dropTestTable(new EncryptedColumns().tokenTableFor(noteTable)!);
|
|
127
|
+
await dropTestTable(noteTable);
|
|
128
|
+
await dropTestTable(new DataEncryptionKeyTable());
|
|
129
|
+
await testEnv.afterAll();
|
|
130
|
+
}, 120000);
|
|
131
|
+
|
|
132
|
+
test('root birth: the creator keys the scope — ciphertext at rest names the owner', async () => {
|
|
133
|
+
testEnv.actAs(owner);
|
|
134
|
+
root = await getSharedDb().insert(noteTable, {
|
|
135
|
+
title: 'Therapy notes shared scope',
|
|
136
|
+
label: 'case-407',
|
|
137
|
+
body: 'The sensitive body text',
|
|
138
|
+
kind: 'note',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// At rest: self-describing envelopes named by O (resolved at insert time, BEFORE the
|
|
142
|
+
// owner grant lands post-DML), no plaintext anywhere, metadata untouched.
|
|
143
|
+
for (const columnName of ['title', 'label', 'body']) {
|
|
144
|
+
const stored = await rawColumn(root.id, columnName);
|
|
145
|
+
expect(envelope.isEnvelope(stored)).toBe(true);
|
|
146
|
+
expect(String(stored)).not.toContain('Therapy');
|
|
147
|
+
expect(String(stored)).not.toContain('sensitive');
|
|
148
|
+
expect(envelope.parse(stored)!.owner).toBe(owner.id);
|
|
149
|
+
}
|
|
150
|
+
expect(await rawColumn(root.id, 'kind')).toBe('note');
|
|
151
|
+
|
|
152
|
+
// The owner grant the birth pre-resolved is now the authoritative record.
|
|
153
|
+
const grants = await getDbAsSystem<AccessGrant>().query(
|
|
154
|
+
tables.AccessGrant,
|
|
155
|
+
new QueryBuilder<AccessGrant>(tables.AccessGrant.name)
|
|
156
|
+
.condition({ field: 'resource', operator: '=', value: root.id })
|
|
157
|
+
.condition({ field: 'accessLevel', operator: '=', value: 'owner' })
|
|
158
|
+
);
|
|
159
|
+
expect(grants.map((grant) => grant.principal?._id)).toEqual([owner.id]);
|
|
160
|
+
|
|
161
|
+
// One data key exists, and it is O's.
|
|
162
|
+
const keyRows = await getDbAsSystem<DataEncryptionKey>().query(new DataEncryptionKeyTable(), {});
|
|
163
|
+
expect(keyRows.map((row) => row.owner)).toEqual([owner.id]);
|
|
164
|
+
|
|
165
|
+
// The owner reads plaintext back.
|
|
166
|
+
const fetched = await getSharedDb().get(noteTable, { id: root.id });
|
|
167
|
+
expect(fetched!.title).toBe('Therapy notes shared scope');
|
|
168
|
+
expect(fetched!.body).toBe('The sensitive body text');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('share with support: a read grant extends decrypt + search to the recipient (§4.4)', async () => {
|
|
172
|
+
// O performs the share — an ordinary direct grant, exactly what the help-flow
|
|
173
|
+
// affordance will mint. Caller-context: the grant table's own gate admits O (owner).
|
|
174
|
+
testEnv.actAs(owner);
|
|
175
|
+
const supportGrant = await getDb<AccessGrant>().insert(tables.AccessGrant, {
|
|
176
|
+
principal: new Reference(tables.User.name, support.id),
|
|
177
|
+
resource: new Reference(noteTable.name, root.id),
|
|
178
|
+
resourceTable: noteTable.name,
|
|
179
|
+
accessLevel: 'read',
|
|
180
|
+
});
|
|
181
|
+
supportGrantId = supportGrant.id;
|
|
182
|
+
|
|
183
|
+
testEnv.actAs(support);
|
|
184
|
+
const fetched = await getSharedDb().get(noteTable, { id: root.id });
|
|
185
|
+
expect(fetched!.title).toBe('Therapy notes shared scope');
|
|
186
|
+
expect(fetched!.body).toBe('The sensitive body text');
|
|
187
|
+
|
|
188
|
+
// Blind-index fan-out: S's contains-search fingerprints under O's index key and finds
|
|
189
|
+
// the shared document; equality rides the fingerprint companion the same way.
|
|
190
|
+
const found = await getSharedDb().query(noteTable, titleLike('%Therapy notes%'));
|
|
191
|
+
expect(found.map((row) => row.id)).toEqual([root.id]);
|
|
192
|
+
const byLabel = await getSharedDb().query(noteTable, { label: 'case-407' } as Partial<EncSharedNote>);
|
|
193
|
+
expect(byLabel.map((row) => row.id)).toEqual([root.id]);
|
|
194
|
+
|
|
195
|
+
// Reading and searching mint NO key for the recipient — keys are created on write only.
|
|
196
|
+
const supportKeys = await getDbAsSystem<DataEncryptionKey>().query(new DataEncryptionKeyTable(), {
|
|
197
|
+
owner: support.id,
|
|
198
|
+
} as Partial<DataEncryptionKey>);
|
|
199
|
+
expect(supportKeys.length).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a stranger sees nothing: no rows, and search covers only their own (empty) key set', async () => {
|
|
203
|
+
testEnv.actAs(stranger);
|
|
204
|
+
expect(await getSharedDb().get(noteTable, { id: root.id })).toBeUndefined();
|
|
205
|
+
expect(await getSharedDb().query(noteTable, titleLike('%Therapy%'))).toEqual([]);
|
|
206
|
+
expect(await keyOwners.getAccessibleKeyOwners({ runAsSystem: false })).toEqual([stranger.id]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a contributor's write into the shared scope keys under the DOCUMENT owner, not the writer", async () => {
|
|
210
|
+
testEnv.actAs(owner);
|
|
211
|
+
await getDb<AccessGrant>().insert(tables.AccessGrant, {
|
|
212
|
+
principal: new Reference(tables.User.name, writer.id),
|
|
213
|
+
resource: new Reference(noteTable.name, root.id),
|
|
214
|
+
resourceTable: noteTable.name,
|
|
215
|
+
accessLevel: 'write',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Fresh resolution (no cache): the write must derive the owner from the GRANT record.
|
|
219
|
+
clearResolvedOwnerCache();
|
|
220
|
+
testEnv.actAs(writer);
|
|
221
|
+
const child = await getSharedDbWithOverride().insert(noteTable, {
|
|
222
|
+
title: 'Contributor note addendum line',
|
|
223
|
+
label: 'addendum',
|
|
224
|
+
body: null,
|
|
225
|
+
kind: 'note',
|
|
226
|
+
permissionSource: new Reference(noteTable.name, root.id),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The crypto-shred agreement: W's row inside O's document is O-keyed — exactly the row
|
|
230
|
+
// set the account-deletion purge walker drains by O's owned permission sources.
|
|
231
|
+
const stored = await rawColumn(child.id, 'title');
|
|
232
|
+
expect(envelope.isEnvelope(stored)).toBe(true);
|
|
233
|
+
expect(envelope.parse(stored)!.owner).toBe(owner.id);
|
|
234
|
+
expect(envelope.parse(stored)!.owner).not.toBe(writer.id);
|
|
235
|
+
|
|
236
|
+
// And W's own separate document stays W-keyed (the default self-scope).
|
|
237
|
+
const own = await getSharedDb().insert(noteTable, {
|
|
238
|
+
title: 'Writer private note planning',
|
|
239
|
+
label: 'own',
|
|
240
|
+
body: null,
|
|
241
|
+
kind: 'note',
|
|
242
|
+
});
|
|
243
|
+
expect(envelope.parse(await rawColumn(own.id, 'title'))!.owner).toBe(writer.id);
|
|
244
|
+
|
|
245
|
+
// W's search spans both scopes in one query: their own key AND O's (the shared scope).
|
|
246
|
+
const lineRows = await getSharedDb().query(noteTable, titleLike('%addendum line%'));
|
|
247
|
+
expect(lineRows.map((row) => row.title)).toEqual(['Contributor note addendum line']);
|
|
248
|
+
const noteRows = await getSharedDb().query(noteTable, titleLike('%note%'));
|
|
249
|
+
expect(noteRows.map((row) => row.title).sort()).toEqual([
|
|
250
|
+
'Contributor note addendum line',
|
|
251
|
+
'Therapy notes shared scope',
|
|
252
|
+
'Writer private note planning',
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('owner key rotation honors shares: old and new envelopes both decrypt and search for the recipient', async () => {
|
|
257
|
+
const newVersion = await new DataKeyStore().rotateKey(owner.id);
|
|
258
|
+
expect(newVersion).toBe(2);
|
|
259
|
+
|
|
260
|
+
testEnv.actAs(owner);
|
|
261
|
+
const rotated = await getSharedDbWithOverride().insert(noteTable, {
|
|
262
|
+
title: 'Post-rotation Therapy addendum',
|
|
263
|
+
label: 'rotated',
|
|
264
|
+
body: null,
|
|
265
|
+
kind: 'note',
|
|
266
|
+
permissionSource: new Reference(noteTable.name, root.id),
|
|
267
|
+
});
|
|
268
|
+
expect(envelope.parse(await rawColumn(rotated.id, 'title'))!.version).toBe(2);
|
|
269
|
+
expect(envelope.parse(await rawColumn(root.id, 'title'))!.version).toBe(1);
|
|
270
|
+
|
|
271
|
+
testEnv.actAs(support);
|
|
272
|
+
const fetchedOld = await getSharedDb().get(noteTable, { id: root.id });
|
|
273
|
+
const fetchedNew = await getSharedDb().get(noteTable, { id: rotated.id });
|
|
274
|
+
expect(fetchedOld!.title).toBe('Therapy notes shared scope');
|
|
275
|
+
expect(fetchedNew!.title).toBe('Post-rotation Therapy addendum');
|
|
276
|
+
|
|
277
|
+
const lower = await getSharedDb().query(noteTable, titleLike('%therapy%'));
|
|
278
|
+
expect(lower.length).toBe(0); // LIKE stays case-exact over encrypted values
|
|
279
|
+
const both = await getSharedDb().query(noteTable, titleLike('%Therapy%'));
|
|
280
|
+
expect(both.map((row) => row.id).sort()).toEqual([root.id, rotated.id].sort());
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('conferred co-ownership: the scope keeps keying by its ORIGINAL owner, and the co-owner searches it', async () => {
|
|
284
|
+
testEnv.actAs(owner);
|
|
285
|
+
await getDb<AccessGrant>().insert(tables.AccessGrant, {
|
|
286
|
+
principal: new Reference(tables.User.name, coOwner.id),
|
|
287
|
+
resource: new Reference(noteTable.name, root.id),
|
|
288
|
+
resourceTable: noteTable.name,
|
|
289
|
+
accessLevel: 'owner', // legal: O holds owner (the owner-ceiling path)
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Fresh resolution (no cache): the EARLIEST owner grant — O's — still keys new writes.
|
|
293
|
+
clearResolvedOwnerCache();
|
|
294
|
+
testEnv.actAs(coOwner);
|
|
295
|
+
const coWrite = await getSharedDbWithOverride().insert(noteTable, {
|
|
296
|
+
title: 'Co-owner amendment entry',
|
|
297
|
+
label: 'co-owner',
|
|
298
|
+
body: null,
|
|
299
|
+
kind: 'note',
|
|
300
|
+
permissionSource: new Reference(noteTable.name, root.id),
|
|
301
|
+
});
|
|
302
|
+
expect(envelope.parse(await rawColumn(coWrite.id, 'title'))!.owner).toBe(owner.id);
|
|
303
|
+
|
|
304
|
+
// The co-owner's accessible set includes O — an owner-level inbound grant is still
|
|
305
|
+
// someone sharing content into their view.
|
|
306
|
+
const accessible = await keyOwners.getAccessibleKeyOwners({ runAsSystem: false });
|
|
307
|
+
expect(accessible.sort()).toEqual([coOwner.id, owner.id].sort());
|
|
308
|
+
const found = await getSharedDb().query(noteTable, titleLike('%amendment%'));
|
|
309
|
+
expect(found.map((row) => row.id)).toEqual([coWrite.id]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('revocation ends access and fan-out immediately — with NO re-encryption', async () => {
|
|
313
|
+
const cipherBefore = await rawColumn(root.id, 'title');
|
|
314
|
+
|
|
315
|
+
testEnv.actAs(owner);
|
|
316
|
+
const revoked = await getDb().delete(tables.AccessGrant, { id: supportGrantId });
|
|
317
|
+
expect(revoked).toBe(1);
|
|
318
|
+
|
|
319
|
+
testEnv.actAs(support);
|
|
320
|
+
expect(await getSharedDb().get(noteTable, { id: root.id })).toBeUndefined();
|
|
321
|
+
expect(await getSharedDb().query(noteTable, titleLike('%Therapy%'))).toEqual([]);
|
|
322
|
+
expect(await keyOwners.getAccessibleKeyOwners({ runAsSystem: false })).toEqual([support.id]);
|
|
323
|
+
|
|
324
|
+
// Keys are not access control: the row's ciphertext is byte-identical — revocation is
|
|
325
|
+
// the permission layer's act, never a re-encryption.
|
|
326
|
+
expect(await rawColumn(root.id, 'title')).toBe(cipherBefore);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('system reads decrypt without caller context — the compliance-decrypt seam composes', async () => {
|
|
330
|
+
// The §4.5 tool's exact read shape: one system get by id. No accessible-owner set is
|
|
331
|
+
// consulted; the envelope names its key.
|
|
332
|
+
const fetched = await getDbAsSystem<EncSharedNote>().get(noteTable, { id: root.id });
|
|
333
|
+
expect(fetched!.title).toBe('Therapy notes shared scope');
|
|
334
|
+
expect(fetched!.body).toBe('The sensitive body text');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('system searches cover EVERY key owner — an unscoped query never silently misses a scope', async () => {
|
|
338
|
+
const rows = await getDbAsSystem<EncSharedNote>().query(noteTable, titleLike('%note%'));
|
|
339
|
+
const rowOwners = new Set<string>();
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
rowOwners.add(envelope.parse(await rawColumn(row.id, 'title'))!.owner);
|
|
342
|
+
}
|
|
343
|
+
// Spans O's scope AND W's private document — both key owners covered in one query.
|
|
344
|
+
expect(rowOwners.has(owner.id)).toBe(true);
|
|
345
|
+
expect(rowOwners.has(writer.id)).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('the resolver contract: non-shared tables fall through; caller-less searches refuse loudly', async () => {
|
|
349
|
+
expect(await keyOwners.resolveKeyOwner({ table: tables.User, record: owner })).toBeUndefined();
|
|
350
|
+
|
|
351
|
+
testEnv.actAs({} as User);
|
|
352
|
+
await expect(keyOwners.getAccessibleKeyOwners({ runAsSystem: false })).rejects.toThrow(/without a caller identity/);
|
|
353
|
+
testEnv.actAs(owner);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('crypto-shred: deleting the owner keys makes every envelope in the scope permanently unreadable', async () => {
|
|
357
|
+
const deleted = await new DataKeyStore().shredOwnerKeys(owner.id);
|
|
358
|
+
expect(deleted).toBeGreaterThanOrEqual(2); // v1 + v2
|
|
359
|
+
await expect(getDbAsSystem<EncSharedNote>().get(noteTable, { id: root.id })).rejects.toThrow(
|
|
360
|
+
/No data key exists for owner/
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
});
|