@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1
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 +122 -52
- package/README.md +663 -661
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -388
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
package/src/sqlite/values.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading values back out of a result row.
|
|
3
|
-
*
|
|
4
|
-
* The engine hands back `unknown` per column, and every read site would
|
|
5
|
-
* otherwise repeat the same `String(...)` / `Number(...)` coercion with a
|
|
6
|
-
* slightly different null check. One place to do it means one place where the
|
|
7
|
-
* "NULL means absent, not empty string" rule is decided.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export function text(value: unknown): string {
|
|
11
|
-
return typeof value === 'string' ? value : String(value as string | number | boolean);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function num(value: unknown): number {
|
|
15
|
-
return typeof value === 'number' ? value : Number(value as string | number);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function maybeText(value: unknown): string | null {
|
|
19
|
-
return value == null ? null : text(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function maybeNum(value: unknown): number | null {
|
|
23
|
-
return value == null ? null : num(value);
|
|
24
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reading values back out of a result row.
|
|
3
|
+
*
|
|
4
|
+
* The engine hands back `unknown` per column, and every read site would
|
|
5
|
+
* otherwise repeat the same `String(...)` / `Number(...)` coercion with a
|
|
6
|
+
* slightly different null check. One place to do it means one place where the
|
|
7
|
+
* "NULL means absent, not empty string" rule is decided.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function text(value: unknown): string {
|
|
11
|
+
return typeof value === 'string' ? value : String(value as string | number | boolean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function num(value: unknown): number {
|
|
15
|
+
return typeof value === 'number' ? value : Number(value as string | number);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function maybeText(value: unknown): string | null {
|
|
19
|
+
return value == null ? null : text(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function maybeNum(value: unknown): number | null {
|
|
23
|
+
return value == null ? null : num(value);
|
|
24
|
+
}
|
|
@@ -1,136 +1,138 @@
|
|
|
1
|
-
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
-
import { isCode } from '../../diagnostics/errors';
|
|
3
|
-
import type { ConformanceContext } from './index';
|
|
4
|
-
|
|
5
|
-
/** US7: artifacts the package owns, and the reconciliation that keeps them honest. */
|
|
6
|
-
export function registerAttachmentConformance(context: ConformanceContext): void {
|
|
7
|
-
describe('conformance: attachments', () => {
|
|
8
|
-
beforeEach(() => context.reset());
|
|
9
|
-
|
|
10
|
-
const scope = { kind: 'guest' } as const;
|
|
11
|
-
const content = Buffer.from('hello attachment', 'utf8').toString('base64');
|
|
12
|
-
|
|
13
|
-
it('stores a reference alongside the artifact', async () => {
|
|
14
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
15
|
-
const attachments = handle.attachments();
|
|
16
|
-
const reference = await attachments.write({
|
|
17
|
-
id: 'a1',
|
|
18
|
-
owner: { collection: 'notes', id: 'n1' },
|
|
19
|
-
contentType: 'text/plain',
|
|
20
|
-
content,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
expect(reference.size).toBe(Buffer.from(content, 'base64').length);
|
|
24
|
-
expect(await attachments.read('a1')).toBe(content);
|
|
25
|
-
expect((await attachments.availability('a1')).state).toBe('available');
|
|
26
|
-
await handle.close();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('reads a byte range without materialising the whole artifact', async () => {
|
|
30
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
31
|
-
const attachments = handle.attachments();
|
|
32
|
-
await attachments.write({ id: 'a1', contentType: 'text/plain', content });
|
|
33
|
-
|
|
34
|
-
const slice = await attachments.read('a1', { offset: 0, length: 5 });
|
|
35
|
-
expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('hello');
|
|
36
|
-
await handle.close();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('lists the attachments belonging to a record', async () => {
|
|
40
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
41
|
-
const attachments = handle.attachments();
|
|
42
|
-
await attachments.write({
|
|
43
|
-
id: 'a1',
|
|
44
|
-
owner: { collection: 'notes', id: 'n1' },
|
|
45
|
-
contentType: 'text/plain',
|
|
46
|
-
content,
|
|
47
|
-
});
|
|
48
|
-
await attachments.write({ id: 'a2', contentType: 'text/plain', content });
|
|
49
|
-
|
|
50
|
-
const owned = await attachments.listFor({ collection: 'notes', id: 'n1' });
|
|
51
|
-
expect(owned.map(r => r.id)).toEqual(['a1']);
|
|
52
|
-
await handle.close();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('reports a missing artifact without making the record unreadable', async () => {
|
|
56
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
57
|
-
await handle.collection('notes').insert({ id: 'n1', data: { title: 'has media' } });
|
|
58
|
-
const attachments = handle.attachments();
|
|
59
|
-
await attachments.write({
|
|
60
|
-
id: 'a1',
|
|
61
|
-
owner: { collection: 'notes', id: 'n1' },
|
|
62
|
-
contentType: 'text/plain',
|
|
63
|
-
content,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// Remove the artifact behind the package's back.
|
|
67
|
-
await context.removeArtifactBehindTheScenes(handle.scopeKey, 'a1');
|
|
68
|
-
|
|
69
|
-
expect((await attachments.availability('a1')).state).toBe('missing');
|
|
70
|
-
expect((await handle.collection('notes').get('n1')).data.title).toBe('has media');
|
|
71
|
-
await handle.close();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('finds an orphaned artifact and a dangling reference', async () => {
|
|
75
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
76
|
-
const attachments = handle.attachments();
|
|
77
|
-
await attachments.write({ id: 'a1', contentType: 'text/plain', content });
|
|
78
|
-
await context.removeReferenceBehindTheScenes(handle.scopeKey, 'a1');
|
|
79
|
-
|
|
80
|
-
await attachments.write({ id: 'a2', contentType: 'text/plain', content });
|
|
81
|
-
await context.removeArtifactBehindTheScenes(handle.scopeKey, 'a2');
|
|
82
|
-
|
|
83
|
-
const report = await attachments.reconcile();
|
|
84
|
-
expect(report.orphanedArtifacts).toEqual(['a1']);
|
|
85
|
-
expect(report.danglingReferences).toEqual(['a2']);
|
|
86
|
-
|
|
87
|
-
const removed = await attachments.reconcile({ removeOrphans: true });
|
|
88
|
-
expect(removed.removedArtifacts).toEqual(['a1']);
|
|
89
|
-
expect((await attachments.reconcile()).orphanedArtifacts).toEqual([]);
|
|
90
|
-
await handle.close();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('rolls a reference back with its transaction', async () => {
|
|
94
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
95
|
-
try {
|
|
96
|
-
await handle.transaction(async tx => {
|
|
97
|
-
await tx.collection('notes').insert({ id: 'n1', data: { title: 'x' } });
|
|
98
|
-
await
|
|
99
|
-
throw new Error('injected');
|
|
100
|
-
});
|
|
101
|
-
} catch {
|
|
102
|
-
// Expected.
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
expect(await handle.collection('notes').find('n1')).toBeNull();
|
|
106
|
-
// The reference is gone; the artifact on disk is not, which is
|
|
107
|
-
// exactly why reconciliation exists.
|
|
108
|
-
const report = await handle.attachments().reconcile();
|
|
109
|
-
expect(report.orphanedArtifacts).toEqual(['a1']);
|
|
110
|
-
await handle.close();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('rejects an artifact over the size budget', async () => {
|
|
114
|
-
const handle = await context.adapter.open(context.configFor(scope));
|
|
115
|
-
const huge = Buffer.alloc(101 * 1024 * 1024).toString('base64');
|
|
116
|
-
|
|
117
|
-
let thrown: unknown;
|
|
118
|
-
try {
|
|
119
|
-
await handle
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
await
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
1
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
+
import { isCode } from '../../diagnostics/errors';
|
|
3
|
+
import type { ConformanceContext } from './index';
|
|
4
|
+
|
|
5
|
+
/** US7: artifacts the package owns, and the reconciliation that keeps them honest. */
|
|
6
|
+
export function registerAttachmentConformance(context: ConformanceContext): void {
|
|
7
|
+
describe('conformance: attachments', () => {
|
|
8
|
+
beforeEach(() => context.reset());
|
|
9
|
+
|
|
10
|
+
const scope = { kind: 'guest' } as const;
|
|
11
|
+
const content = Buffer.from('hello attachment', 'utf8').toString('base64');
|
|
12
|
+
|
|
13
|
+
it('stores a reference alongside the artifact', async () => {
|
|
14
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
15
|
+
const attachments = handle.attachments();
|
|
16
|
+
const reference = await attachments.write({
|
|
17
|
+
id: 'a1',
|
|
18
|
+
owner: { collection: 'notes', id: 'n1' },
|
|
19
|
+
contentType: 'text/plain',
|
|
20
|
+
content,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(reference.size).toBe(Buffer.from(content, 'base64').length);
|
|
24
|
+
expect(await attachments.read('a1')).toBe(content);
|
|
25
|
+
expect((await attachments.availability('a1')).state).toBe('available');
|
|
26
|
+
await handle.close();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reads a byte range without materialising the whole artifact', async () => {
|
|
30
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
31
|
+
const attachments = handle.attachments();
|
|
32
|
+
await attachments.write({ id: 'a1', contentType: 'text/plain', content });
|
|
33
|
+
|
|
34
|
+
const slice = await attachments.read('a1', { offset: 0, length: 5 });
|
|
35
|
+
expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('hello');
|
|
36
|
+
await handle.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lists the attachments belonging to a record', async () => {
|
|
40
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
41
|
+
const attachments = handle.attachments();
|
|
42
|
+
await attachments.write({
|
|
43
|
+
id: 'a1',
|
|
44
|
+
owner: { collection: 'notes', id: 'n1' },
|
|
45
|
+
contentType: 'text/plain',
|
|
46
|
+
content,
|
|
47
|
+
});
|
|
48
|
+
await attachments.write({ id: 'a2', contentType: 'text/plain', content });
|
|
49
|
+
|
|
50
|
+
const owned = await attachments.listFor({ collection: 'notes', id: 'n1' });
|
|
51
|
+
expect(owned.map(r => r.id)).toEqual(['a1']);
|
|
52
|
+
await handle.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reports a missing artifact without making the record unreadable', async () => {
|
|
56
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
57
|
+
await handle.collection('notes').insert({ id: 'n1', data: { title: 'has media' } });
|
|
58
|
+
const attachments = handle.attachments();
|
|
59
|
+
await attachments.write({
|
|
60
|
+
id: 'a1',
|
|
61
|
+
owner: { collection: 'notes', id: 'n1' },
|
|
62
|
+
contentType: 'text/plain',
|
|
63
|
+
content,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Remove the artifact behind the package's back.
|
|
67
|
+
await context.removeArtifactBehindTheScenes(handle.scopeKey, 'a1');
|
|
68
|
+
|
|
69
|
+
expect((await attachments.availability('a1')).state).toBe('missing');
|
|
70
|
+
expect((await handle.collection('notes').get('n1')).data.title).toBe('has media');
|
|
71
|
+
await handle.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('finds an orphaned artifact and a dangling reference', async () => {
|
|
75
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
76
|
+
const attachments = handle.attachments();
|
|
77
|
+
await attachments.write({ id: 'a1', contentType: 'text/plain', content });
|
|
78
|
+
await context.removeReferenceBehindTheScenes(handle.scopeKey, 'a1');
|
|
79
|
+
|
|
80
|
+
await attachments.write({ id: 'a2', contentType: 'text/plain', content });
|
|
81
|
+
await context.removeArtifactBehindTheScenes(handle.scopeKey, 'a2');
|
|
82
|
+
|
|
83
|
+
const report = await attachments.reconcile();
|
|
84
|
+
expect(report.orphanedArtifacts).toEqual(['a1']);
|
|
85
|
+
expect(report.danglingReferences).toEqual(['a2']);
|
|
86
|
+
|
|
87
|
+
const removed = await attachments.reconcile({ removeOrphans: true });
|
|
88
|
+
expect(removed.removedArtifacts).toEqual(['a1']);
|
|
89
|
+
expect((await attachments.reconcile()).orphanedArtifacts).toEqual([]);
|
|
90
|
+
await handle.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rolls a reference back with its transaction', async () => {
|
|
94
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
95
|
+
try {
|
|
96
|
+
await handle.transaction(async tx => {
|
|
97
|
+
await tx.collection('notes').insert({ id: 'n1', data: { title: 'x' } });
|
|
98
|
+
await tx.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
99
|
+
throw new Error('injected');
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
// Expected.
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(await handle.collection('notes').find('n1')).toBeNull();
|
|
106
|
+
// The reference is gone; the artifact on disk is not, which is
|
|
107
|
+
// exactly why reconciliation exists.
|
|
108
|
+
const report = await handle.attachments().reconcile();
|
|
109
|
+
expect(report.orphanedArtifacts).toEqual(['a1']);
|
|
110
|
+
await handle.close();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('rejects an artifact over the size budget', async () => {
|
|
114
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
115
|
+
const huge = Buffer.alloc(101 * 1024 * 1024).toString('base64');
|
|
116
|
+
|
|
117
|
+
let thrown: unknown;
|
|
118
|
+
try {
|
|
119
|
+
await handle
|
|
120
|
+
.attachments()
|
|
121
|
+
.write({ id: 'big', contentType: 'application/octet-stream', content: huge });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
thrown = error;
|
|
124
|
+
}
|
|
125
|
+
expect(isCode(thrown, DiagnosticCodes.ATTACHMENT_WRITE_FAILED)).toBe(true);
|
|
126
|
+
await handle.close();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('removes artifacts when the scope is deleted', async () => {
|
|
130
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
131
|
+
await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
132
|
+
await handle.close();
|
|
133
|
+
|
|
134
|
+
const outcome = await context.adapter.deleteScopeData(context.scopeTargetFor(scope));
|
|
135
|
+
expect(outcome.removedArtifacts).toContain('a1');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -1,150 +1,150 @@
|
|
|
1
|
-
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
-
import type { KeyProvider } from '../../contract/encryption';
|
|
3
|
-
import { isCode } from '../../diagnostics/errors';
|
|
4
|
-
import type { ConformanceContext } from './index';
|
|
5
|
-
|
|
6
|
-
/** US10: opt-in encryption, and failures that stay apart from each other. */
|
|
7
|
-
export function registerEncryptionConformance(context: ConformanceContext): void {
|
|
8
|
-
describe('conformance: encryption', () => {
|
|
9
|
-
beforeEach(() => context.reset());
|
|
10
|
-
|
|
11
|
-
const scope = { kind: 'identity', id: 'encrypted' } as const;
|
|
12
|
-
const keyProvider: KeyProvider = { getKey: async () => 'a-correct-key-32-bytes-long-value' };
|
|
13
|
-
|
|
14
|
-
const encrypted = { enabled: true as const, keyProvider };
|
|
15
|
-
|
|
16
|
-
it('reports its own posture', async () => {
|
|
17
|
-
const handle = await context.adapter.open(
|
|
18
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
19
|
-
);
|
|
20
|
-
expect(handle.isEncrypted).toBe(true);
|
|
21
|
-
await handle.close();
|
|
22
|
-
|
|
23
|
-
const plain = await context.adapter.open(
|
|
24
|
-
context.configFor({ kind: 'identity', id: 'plain' }, { encryption: undefined }),
|
|
25
|
-
);
|
|
26
|
-
expect(plain.isEncrypted).toBe(false);
|
|
27
|
-
await plain.close();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('stores and returns records unchanged under encryption', async () => {
|
|
31
|
-
const handle = await context.adapter.open(
|
|
32
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
33
|
-
);
|
|
34
|
-
const data = { title: 'secret', score: 1, nested: { deep: [1, null, true] } };
|
|
35
|
-
await handle.collection('notes').insert({ id: 'a', data });
|
|
36
|
-
expect((await handle.collection('notes').get('a')).data).toEqual(data);
|
|
37
|
-
await handle.close();
|
|
38
|
-
|
|
39
|
-
const reopened = await context.adapter.open(
|
|
40
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
41
|
-
);
|
|
42
|
-
expect((await reopened.collection('notes').get('a')).data).toEqual(data);
|
|
43
|
-
await reopened.close();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('round-trips an artifact under encryption, including a byte range', async () => {
|
|
47
|
-
const handle = await context.adapter.open(
|
|
48
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
49
|
-
);
|
|
50
|
-
const content = Buffer.from('hello encrypted attachment', 'utf8').toString('base64');
|
|
51
|
-
await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
52
|
-
|
|
53
|
-
expect(await handle.attachments().read('a1')).toBe(content);
|
|
54
|
-
const slice = await handle.attachments().read('a1', { offset: 6, length: 9 });
|
|
55
|
-
expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('encrypted');
|
|
56
|
-
expect((await handle.attachments().availability('a1')).state).toBe('available');
|
|
57
|
-
await handle.close();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('reports a key-provider failure separately from an invalid key', async () => {
|
|
61
|
-
let thrown: unknown;
|
|
62
|
-
try {
|
|
63
|
-
await context.adapter.open(
|
|
64
|
-
context.configFor(scope, {
|
|
65
|
-
encryption: {
|
|
66
|
-
enabled: true,
|
|
67
|
-
keyProvider: {
|
|
68
|
-
getKey: async () => {
|
|
69
|
-
throw new Error('device is locked');
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
}),
|
|
74
|
-
);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
thrown = error;
|
|
77
|
-
}
|
|
78
|
-
expect(isCode(thrown, DiagnosticCodes.KEY_PROVIDER_FAILED)).toBe(true);
|
|
79
|
-
|
|
80
|
-
let empty: unknown;
|
|
81
|
-
try {
|
|
82
|
-
await context.adapter.open(
|
|
83
|
-
context.configFor(scope, {
|
|
84
|
-
encryption: { enabled: true, keyProvider: { getKey: async () => '' } },
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
87
|
-
} catch (error) {
|
|
88
|
-
empty = error;
|
|
89
|
-
}
|
|
90
|
-
expect(isCode(empty, DiagnosticCodes.KEY_INVALID)).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('reports a posture mismatch rather than opening or converting', async () => {
|
|
94
|
-
const plain = await context.adapter.open(context.configFor(scope));
|
|
95
|
-
await plain.collection('notes').insert({ id: 'a', data: { title: 'plaintext' } });
|
|
96
|
-
await plain.close();
|
|
97
|
-
|
|
98
|
-
let thrown: unknown;
|
|
99
|
-
try {
|
|
100
|
-
await context.adapter.open(context.configFor(scope, { encryption: encrypted }));
|
|
101
|
-
} catch (error) {
|
|
102
|
-
thrown = error;
|
|
103
|
-
}
|
|
104
|
-
expect(isCode(thrown, DiagnosticCodes.ENCRYPTION_MISMATCH)).toBe(true);
|
|
105
|
-
|
|
106
|
-
// Nothing was converted, truncated, or discarded.
|
|
107
|
-
const reopened = await context.adapter.open(context.configFor(scope));
|
|
108
|
-
expect((await reopened.collection('notes').get('a')).data.title).toBe('plaintext');
|
|
109
|
-
await reopened.close();
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('gives the key provider the scope, so an app can key per identity', async () => {
|
|
113
|
-
const seen: string[] = [];
|
|
114
|
-
const recording: KeyProvider = {
|
|
115
|
-
getKey: async resolved => {
|
|
116
|
-
seen.push(resolved.key);
|
|
117
|
-
return `key-for-${resolved.key}`;
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
for (const id of ['alice', 'bob']) {
|
|
122
|
-
const handle = await context.adapter.open(
|
|
123
|
-
context.configFor(
|
|
124
|
-
{ kind: 'identity', id },
|
|
125
|
-
{ encryption: { enabled: true, keyProvider: recording } },
|
|
126
|
-
),
|
|
127
|
-
);
|
|
128
|
-
await handle.close();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
expect(seen).toHaveLength(2);
|
|
132
|
-
expect(new Set(seen).size).toBe(2);
|
|
133
|
-
for (const key of seen) {
|
|
134
|
-
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('keeps an encrypted scope isolated from an unencrypted sibling', async () => {
|
|
139
|
-
const secure = await context.adapter.open(
|
|
140
|
-
context.configFor({ kind: 'identity', id: 'secure' }, { encryption: encrypted }),
|
|
141
|
-
);
|
|
142
|
-
await secure.collection('notes').insert({ id: 'shared', data: { title: 'secure' } });
|
|
143
|
-
await secure.close();
|
|
144
|
-
|
|
145
|
-
const open = await context.adapter.open(context.configFor({ kind: 'identity', id: 'open' }));
|
|
146
|
-
expect(await open.collection('notes').find('shared')).toBeNull();
|
|
147
|
-
await open.close();
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
}
|
|
1
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
+
import type { KeyProvider } from '../../contract/encryption';
|
|
3
|
+
import { isCode } from '../../diagnostics/errors';
|
|
4
|
+
import type { ConformanceContext } from './index';
|
|
5
|
+
|
|
6
|
+
/** US10: opt-in encryption, and failures that stay apart from each other. */
|
|
7
|
+
export function registerEncryptionConformance(context: ConformanceContext): void {
|
|
8
|
+
describe('conformance: encryption', () => {
|
|
9
|
+
beforeEach(() => context.reset());
|
|
10
|
+
|
|
11
|
+
const scope = { kind: 'identity', id: 'encrypted' } as const;
|
|
12
|
+
const keyProvider: KeyProvider = { getKey: async () => 'a-correct-key-32-bytes-long-value' };
|
|
13
|
+
|
|
14
|
+
const encrypted = { enabled: true as const, keyProvider };
|
|
15
|
+
|
|
16
|
+
it('reports its own posture', async () => {
|
|
17
|
+
const handle = await context.adapter.open(
|
|
18
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
19
|
+
);
|
|
20
|
+
expect(handle.isEncrypted).toBe(true);
|
|
21
|
+
await handle.close();
|
|
22
|
+
|
|
23
|
+
const plain = await context.adapter.open(
|
|
24
|
+
context.configFor({ kind: 'identity', id: 'plain' }, { encryption: undefined }),
|
|
25
|
+
);
|
|
26
|
+
expect(plain.isEncrypted).toBe(false);
|
|
27
|
+
await plain.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('stores and returns records unchanged under encryption', async () => {
|
|
31
|
+
const handle = await context.adapter.open(
|
|
32
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
33
|
+
);
|
|
34
|
+
const data = { title: 'secret', score: 1, nested: { deep: [1, null, true] } };
|
|
35
|
+
await handle.collection('notes').insert({ id: 'a', data });
|
|
36
|
+
expect((await handle.collection('notes').get('a')).data).toEqual(data);
|
|
37
|
+
await handle.close();
|
|
38
|
+
|
|
39
|
+
const reopened = await context.adapter.open(
|
|
40
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
41
|
+
);
|
|
42
|
+
expect((await reopened.collection('notes').get('a')).data).toEqual(data);
|
|
43
|
+
await reopened.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('round-trips an artifact under encryption, including a byte range', async () => {
|
|
47
|
+
const handle = await context.adapter.open(
|
|
48
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
49
|
+
);
|
|
50
|
+
const content = Buffer.from('hello encrypted attachment', 'utf8').toString('base64');
|
|
51
|
+
await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
52
|
+
|
|
53
|
+
expect(await handle.attachments().read('a1')).toBe(content);
|
|
54
|
+
const slice = await handle.attachments().read('a1', { offset: 6, length: 9 });
|
|
55
|
+
expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('encrypted');
|
|
56
|
+
expect((await handle.attachments().availability('a1')).state).toBe('available');
|
|
57
|
+
await handle.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('reports a key-provider failure separately from an invalid key', async () => {
|
|
61
|
+
let thrown: unknown;
|
|
62
|
+
try {
|
|
63
|
+
await context.adapter.open(
|
|
64
|
+
context.configFor(scope, {
|
|
65
|
+
encryption: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
keyProvider: {
|
|
68
|
+
getKey: async () => {
|
|
69
|
+
throw new Error('device is locked');
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
thrown = error;
|
|
77
|
+
}
|
|
78
|
+
expect(isCode(thrown, DiagnosticCodes.KEY_PROVIDER_FAILED)).toBe(true);
|
|
79
|
+
|
|
80
|
+
let empty: unknown;
|
|
81
|
+
try {
|
|
82
|
+
await context.adapter.open(
|
|
83
|
+
context.configFor(scope, {
|
|
84
|
+
encryption: { enabled: true, keyProvider: { getKey: async () => '' } },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
empty = error;
|
|
89
|
+
}
|
|
90
|
+
expect(isCode(empty, DiagnosticCodes.KEY_INVALID)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports a posture mismatch rather than opening or converting', async () => {
|
|
94
|
+
const plain = await context.adapter.open(context.configFor(scope));
|
|
95
|
+
await plain.collection('notes').insert({ id: 'a', data: { title: 'plaintext' } });
|
|
96
|
+
await plain.close();
|
|
97
|
+
|
|
98
|
+
let thrown: unknown;
|
|
99
|
+
try {
|
|
100
|
+
await context.adapter.open(context.configFor(scope, { encryption: encrypted }));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
thrown = error;
|
|
103
|
+
}
|
|
104
|
+
expect(isCode(thrown, DiagnosticCodes.ENCRYPTION_MISMATCH)).toBe(true);
|
|
105
|
+
|
|
106
|
+
// Nothing was converted, truncated, or discarded.
|
|
107
|
+
const reopened = await context.adapter.open(context.configFor(scope));
|
|
108
|
+
expect((await reopened.collection('notes').get('a')).data.title).toBe('plaintext');
|
|
109
|
+
await reopened.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('gives the key provider the scope, so an app can key per identity', async () => {
|
|
113
|
+
const seen: string[] = [];
|
|
114
|
+
const recording: KeyProvider = {
|
|
115
|
+
getKey: async resolved => {
|
|
116
|
+
seen.push(resolved.key);
|
|
117
|
+
return `key-for-${resolved.key}`;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const id of ['alice', 'bob']) {
|
|
122
|
+
const handle = await context.adapter.open(
|
|
123
|
+
context.configFor(
|
|
124
|
+
{ kind: 'identity', id },
|
|
125
|
+
{ encryption: { enabled: true, keyProvider: recording } },
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
await handle.close();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
expect(seen).toHaveLength(2);
|
|
132
|
+
expect(new Set(seen).size).toBe(2);
|
|
133
|
+
for (const key of seen) {
|
|
134
|
+
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps an encrypted scope isolated from an unencrypted sibling', async () => {
|
|
139
|
+
const secure = await context.adapter.open(
|
|
140
|
+
context.configFor({ kind: 'identity', id: 'secure' }, { encryption: encrypted }),
|
|
141
|
+
);
|
|
142
|
+
await secure.collection('notes').insert({ id: 'shared', data: { title: 'secure' } });
|
|
143
|
+
await secure.close();
|
|
144
|
+
|
|
145
|
+
const open = await context.adapter.open(context.configFor({ kind: 'identity', id: 'open' }));
|
|
146
|
+
expect(await open.collection('notes').find('shared')).toBeNull();
|
|
147
|
+
await open.close();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|