@learncard/network-brain-service 3.19.0 → 3.19.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/.env.example +34 -2
- package/CHANGELOG.md +38 -0
- package/README.md +42 -0
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/presentation/read.d.ts.map +1 -1
- package/dist/accesslayer/presentation/relationships/create.d.ts +2 -2
- package/dist/accesslayer/presentation/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/presentation/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts +3 -0
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -0
- package/dist/accesslayer/share-link/cleanup.d.ts +6 -0
- package/dist/accesslayer/share-link/cleanup.d.ts.map +1 -0
- package/dist/accesslayer/share-link/errors.d.ts +8 -0
- package/dist/accesslayer/share-link/errors.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts +3 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -0
- package/dist/accesslayer/share-link/helpers.d.ts +56 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -0
- package/dist/accesslayer/share-link/index.d.ts +12 -0
- package/dist/accesslayer/share-link/index.d.ts.map +1 -0
- package/dist/accesslayer/share-link/list.d.ts +5 -0
- package/dist/accesslayer/share-link/list.d.ts.map +1 -0
- package/dist/accesslayer/share-link/read.d.ts +5 -0
- package/dist/accesslayer/share-link/read.d.ts.map +1 -0
- package/dist/accesslayer/share-link/receipt.d.ts +11 -0
- package/dist/accesslayer/share-link/receipt.d.ts.map +1 -0
- package/dist/accesslayer/share-link/recovery.d.ts +6 -0
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -0
- package/dist/accesslayer/share-link/reserve.d.ts +4 -0
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -0
- package/dist/accesslayer/share-link/revoke.d.ts +3 -0
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -0
- package/dist/accesslayer/share-link/transaction-execution.d.ts +18 -0
- package/dist/accesslayer/share-link/transaction-execution.d.ts.map +1 -0
- package/dist/accesslayer/share-link/transaction.d.ts +15 -0
- package/dist/accesslayer/share-link/transaction.d.ts.map +1 -0
- package/dist/accesslayer/share-link/types.d.ts +370 -0
- package/dist/accesslayer/share-link/types.d.ts.map +1 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/config/environment.d.ts +9 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/constants/auth-grant.d.ts +2 -0
- package/dist/constants/auth-grant.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/credentialSubject.helpers.d.ts +3 -0
- package/dist/helpers/credentialSubject.helpers.d.ts.map +1 -0
- package/dist/helpers/credentialSubject.helpers.test.d.ts +2 -0
- package/dist/helpers/credentialSubject.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/presentation-metadata.d.ts +4 -0
- package/dist/helpers/presentation-metadata.d.ts.map +1 -0
- package/dist/helpers/presentation.helpers.d.ts +1 -1
- package/dist/helpers/presentation.helpers.d.ts.map +1 -1
- package/dist/helpers/profile.helpers.d.ts +1 -0
- package/dist/helpers/profile.helpers.d.ts.map +1 -1
- package/dist/helpers/share-content-client/adapters.d.ts +10 -0
- package/dist/helpers/share-content-client/adapters.d.ts.map +1 -0
- package/dist/helpers/share-content-client/canonical.d.ts +7 -0
- package/dist/helpers/share-content-client/canonical.d.ts.map +1 -0
- package/dist/helpers/share-content-client/client.d.ts +5 -0
- package/dist/helpers/share-content-client/client.d.ts.map +1 -0
- package/dist/helpers/share-content-client/config.d.ts +11 -0
- package/dist/helpers/share-content-client/config.d.ts.map +1 -0
- package/dist/helpers/share-content-client/index.d.ts +6 -0
- package/dist/helpers/share-content-client/index.d.ts.map +1 -0
- package/dist/helpers/share-content-client/responses.d.ts +16 -0
- package/dist/helpers/share-content-client/responses.d.ts.map +1 -0
- package/dist/helpers/share-content-client/types.d.ts +145 -0
- package/dist/helpers/share-content-client/types.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/budget-helpers.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/budget-helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts +3 -0
- package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts +4 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/index.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/index.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts +9 -0
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/runtime.d.ts +11 -0
- package/dist/helpers/share-link-coordinator/runtime.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/stat.d.ts +4 -0
- package/dist/helpers/share-link-coordinator/stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/types.d.ts +160 -0
- package/dist/helpers/share-link-coordinator/types.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/index.d.ts +5 -0
- package/dist/helpers/share-link-lifecycle/index.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts +4 -0
- package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts +21 -0
- package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts +9 -0
- package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/types.d.ts +14 -0
- package/dist/helpers/share-link-lifecycle/types.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/budget.d.ts +12 -0
- package/dist/helpers/share-link-maintenance/budget.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/config.d.ts +48 -0
- package/dist/helpers/share-link-maintenance/config.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/index.d.ts +8 -0
- package/dist/helpers/share-link-maintenance/index.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts +19 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts +36 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/scheduler.d.ts +18 -0
- package/dist/helpers/share-link-maintenance/scheduler.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/telemetry.d.ts +5 -0
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/types.d.ts +53 -0
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/config.d.ts +16 -0
- package/dist/helpers/share-link-owner/config.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/diagnostics.d.ts +26 -0
- package/dist/helpers/share-link-owner/diagnostics.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/lazy-initializer.d.ts +2 -0
- package/dist/helpers/share-link-owner/lazy-initializer.d.ts.map +1 -0
- package/dist/helpers/share-link-owner-projection.d.ts +4 -0
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -0
- package/dist/helpers/share-link-passcode-abuse.d.ts +2 -0
- package/dist/helpers/share-link-passcode-abuse.d.ts.map +1 -0
- package/dist/helpers/share-link-passcode.d.ts +6 -0
- package/dist/helpers/share-link-passcode.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/index.d.ts +4 -0
- package/dist/helpers/share-link-policy/index.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/production.d.ts +13 -0
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/resolver.d.ts +6 -0
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/types.d.ts +16 -0
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -0
- package/dist/helpers/share-link-public/config.d.ts +13 -0
- package/dist/helpers/share-link-public/config.d.ts.map +1 -0
- package/dist/helpers/share-link-view-notification.d.ts +2 -0
- package/dist/helpers/share-link-view-notification.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Presentation.d.ts +2 -0
- package/dist/models/Presentation.d.ts.map +1 -1
- package/dist/models/Profile.d.ts +2 -0
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +44 -0
- package/dist/models/ShareLink.d.ts.map +1 -0
- package/dist/models/ShareViewReceipt.d.ts +22 -0
- package/dist/models/ShareViewReceipt.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/share-link-constraints.d.ts +3 -0
- package/dist/models/share-link-constraints.d.ts.map +1 -0
- package/dist/public-share-link-http.d.ts +4 -0
- package/dist/public-share-link-http.d.ts.map +1 -0
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +2 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/index.d.ts +153 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +1 -0
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +230 -0
- package/dist/routes/public-share-links.d.ts.map +1 -0
- package/dist/routes/share-links.d.ts +844 -0
- package/dist/routes/share-links.d.ts.map +1 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/lambda.ts +4 -1
- package/package.json +22 -16
- package/scripts/seed-demo-persona.ts +425 -0
- package/serverless.yml +36 -0
- package/shareLinkMaintenanceLambda.ts +49 -0
- package/src/accesslayer/consentflowcontract/relationships/create.ts +9 -16
- package/src/accesslayer/consentflowcontract/relationships/update.ts +15 -28
- package/src/accesslayer/presentation/read.ts +57 -25
- package/src/accesslayer/presentation/relationships/create.ts +54 -11
- package/src/accesslayer/presentation/relationships/read.ts +21 -6
- package/src/accesslayer/share-link/abandon.ts +100 -0
- package/src/accesslayer/share-link/cleanup.ts +271 -0
- package/src/accesslayer/share-link/errors.ts +32 -0
- package/src/accesslayer/share-link/finalize.ts +265 -0
- package/src/accesslayer/share-link/helpers.ts +419 -0
- package/src/accesslayer/share-link/index.ts +11 -0
- package/src/accesslayer/share-link/list.ts +162 -0
- package/src/accesslayer/share-link/read.ts +107 -0
- package/src/accesslayer/share-link/receipt.ts +548 -0
- package/src/accesslayer/share-link/recovery.ts +545 -0
- package/src/accesslayer/share-link/reserve.ts +684 -0
- package/src/accesslayer/share-link/revoke.ts +197 -0
- package/src/accesslayer/share-link/transaction-execution.ts +72 -0
- package/src/accesslayer/share-link/transaction.ts +126 -0
- package/src/accesslayer/share-link/types.ts +601 -0
- package/src/app.ts +6 -0
- package/src/config/environment.ts +14 -0
- package/src/constants/auth-grant.ts +5 -0
- package/src/docker-entry.ts +57 -0
- package/src/helpers/boost.helpers.ts +4 -31
- package/src/helpers/credentialSubject.helpers.test.ts +114 -0
- package/src/helpers/credentialSubject.helpers.ts +40 -0
- package/src/helpers/inbox.helpers.ts +7 -4
- package/src/helpers/notificationMessages.ts +18 -1
- package/src/helpers/presentation-metadata.ts +41 -0
- package/src/helpers/presentation.helpers.ts +27 -20
- package/src/helpers/profile.helpers.ts +7 -0
- package/src/helpers/share-content-client/adapters.ts +55 -0
- package/src/helpers/share-content-client/canonical.ts +25 -0
- package/src/helpers/share-content-client/client.ts +440 -0
- package/src/helpers/share-content-client/config.ts +189 -0
- package/src/helpers/share-content-client/index.ts +27 -0
- package/src/helpers/share-content-client/responses.ts +273 -0
- package/src/helpers/share-content-client/types.ts +253 -0
- package/src/helpers/share-link-coordinator/budget-helpers.ts +53 -0
- package/src/helpers/share-link-coordinator/cleanup-runner.ts +286 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +714 -0
- package/src/helpers/share-link-coordinator/index.ts +8 -0
- package/src/helpers/share-link-coordinator/recovery-runner.ts +428 -0
- package/src/helpers/share-link-coordinator/runtime.ts +88 -0
- package/src/helpers/share-link-coordinator/stat.ts +30 -0
- package/src/helpers/share-link-coordinator/types.ts +353 -0
- package/src/helpers/share-link-lifecycle/index.ts +4 -0
- package/src/helpers/share-link-lifecycle/lease.helpers.ts +35 -0
- package/src/helpers/share-link-lifecycle/recovery.helpers.ts +173 -0
- package/src/helpers/share-link-lifecycle/request-hash.helpers.ts +130 -0
- package/src/helpers/share-link-lifecycle/types.ts +58 -0
- package/src/helpers/share-link-maintenance/budget.ts +63 -0
- package/src/helpers/share-link-maintenance/config.ts +312 -0
- package/src/helpers/share-link-maintenance/index.ts +24 -0
- package/src/helpers/share-link-maintenance/runner.ts +223 -0
- package/src/helpers/share-link-maintenance/runtime.ts +334 -0
- package/src/helpers/share-link-maintenance/scheduler.ts +126 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +70 -0
- package/src/helpers/share-link-maintenance/types.ts +89 -0
- package/src/helpers/share-link-owner/config.ts +48 -0
- package/src/helpers/share-link-owner/diagnostics.ts +89 -0
- package/src/helpers/share-link-owner/lazy-initializer.ts +29 -0
- package/src/helpers/share-link-owner-projection.ts +52 -0
- package/src/helpers/share-link-passcode-abuse.ts +36 -0
- package/src/helpers/share-link-passcode.ts +47 -0
- package/src/helpers/share-link-policy/index.ts +7 -0
- package/src/helpers/share-link-policy/production.ts +88 -0
- package/src/helpers/share-link-policy/resolver.ts +108 -0
- package/src/helpers/share-link-policy/types.ts +48 -0
- package/src/helpers/share-link-public/config.ts +41 -0
- package/src/helpers/share-link-view-notification.ts +18 -0
- package/src/models/Presentation.ts +2 -2
- package/src/models/Profile.ts +3 -2
- package/src/models/ShareLink.ts +130 -0
- package/src/models/ShareViewReceipt.ts +69 -0
- package/src/models/index.ts +2 -0
- package/src/models/share-link-constraints.ts +93 -0
- package/src/public-share-link-http.ts +49 -0
- package/src/routes/app-store.ts +2 -11
- package/src/routes/boosts.ts +2 -11
- package/src/routes/contracts.ts +2 -11
- package/src/routes/index.ts +139 -44
- package/src/routes/presentations.ts +19 -3
- package/src/routes/profile-manager.ts +10 -2
- package/src/routes/profiles.ts +17 -6
- package/src/routes/public-share-links.ts +814 -0
- package/src/routes/share-links.ts +640 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +4 -1
- package/test/dids.spec.ts +10 -1
- package/test/guardian-gated-routes.spec.ts +13 -0
- package/test/helpers/share-link-fixtures.ts +94 -0
- package/test/managed-recipient-inbox.spec.ts +130 -0
- package/test/notificationMessages.spec.ts +13 -0
- package/test/openapi.spec.ts +20 -0
- package/test/presentations.spec.ts +54 -6
- package/test/profiles.spec.ts +67 -1
- package/test/share-content-client.contract.spec.ts +365 -0
- package/test/share-content-client.unit.spec.ts +462 -0
- package/test/share-link-abuse.unit.spec.ts +65 -0
- package/test/share-link-coordinator.neo4j.spec.ts +684 -0
- package/test/share-link-coordinator.unit.spec.ts +990 -0
- package/test/share-link-dependency-diagnostics.unit.spec.ts +127 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +1268 -0
- package/test/share-link-lifecycle.unit.spec.ts +205 -0
- package/test/share-link-list-cursor.unit.spec.ts +86 -0
- package/test/share-link-list.neo4j.spec.ts +324 -0
- package/test/share-link-maintenance.neo4j.spec.ts +355 -0
- package/test/share-link-maintenance.unit.spec.ts +1113 -0
- package/test/share-link-owner-policy.unit.spec.ts +210 -0
- package/test/share-link-owner-route.unit.spec.ts +752 -0
- package/test/share-link-owner-runtime.unit.spec.ts +102 -0
- package/test/share-link-passcode.unit.spec.ts +29 -0
- package/test/share-link-policy.unit.spec.ts +135 -0
- package/test/share-link-presentation-metadata.unit.spec.ts +49 -0
- package/test/share-link-production-policy.unit.spec.ts +77 -0
- package/test/share-link-public-config.unit.spec.ts +64 -0
- package/test/share-link-public-http-adapter.unit.spec.ts +315 -0
- package/test/share-link-public-route.unit.spec.ts +800 -0
- package/test/share-link-receipt.neo4j.spec.ts +943 -0
- package/test/share-link-recovery.neo4j.spec.ts +935 -0
- package/test/share-link-recovery.unit.spec.ts +726 -0
- package/vitest.config.ts +1 -0
- package/vitest.share-links-neo4j.config.ts +19 -0
- package/vitest.share-links-unit.config.ts +28 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
canonicalizeJson,
|
|
6
|
+
computeCleanupBackoffMs,
|
|
7
|
+
computeLeaseExpiry,
|
|
8
|
+
computeShareLinkRequestHash,
|
|
9
|
+
getShareLinkRequestHashSecret,
|
|
10
|
+
isLeaseActive,
|
|
11
|
+
} from '@helpers/share-link-lifecycle';
|
|
12
|
+
|
|
13
|
+
const mocks = vi.hoisted(() => ({
|
|
14
|
+
run: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('@instance', () => ({
|
|
18
|
+
neogma: {
|
|
19
|
+
queryRunner: {
|
|
20
|
+
run: mocks.run,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
mocks.run.mockReset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('share-link canonical request hash', () => {
|
|
31
|
+
it('is stable across object key insertion order', () => {
|
|
32
|
+
const first = computeShareLinkRequestHash('update', {
|
|
33
|
+
id: 'share-id',
|
|
34
|
+
envelope: { iv: 'nonce', ct: 'ciphertext' },
|
|
35
|
+
ownerEncryptedRecovery: { protected: 'header', ciphertext: 'recovery' },
|
|
36
|
+
});
|
|
37
|
+
const second = computeShareLinkRequestHash('update', {
|
|
38
|
+
ownerEncryptedRecovery: { ciphertext: 'recovery', protected: 'header' },
|
|
39
|
+
envelope: { ct: 'ciphertext', iv: 'nonce' },
|
|
40
|
+
id: 'share-id',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(first).toBe(second);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('covers the encrypted owner recovery', () => {
|
|
47
|
+
const first = computeShareLinkRequestHash('create', {
|
|
48
|
+
id: 'share-id',
|
|
49
|
+
ownerEncryptedRecovery: { ciphertext: 'recovery-a' },
|
|
50
|
+
});
|
|
51
|
+
const second = computeShareLinkRequestHash('create', {
|
|
52
|
+
id: 'share-id',
|
|
53
|
+
ownerEncryptedRecovery: { ciphertext: 'recovery-b' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(first).not.toBe(second);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('binds the operation kind', () => {
|
|
60
|
+
const payload = { id: 'share-id', title: 'Shared' };
|
|
61
|
+
|
|
62
|
+
expect(computeShareLinkRequestHash('create', payload)).not.toBe(
|
|
63
|
+
computeShareLinkRequestHash('update', payload)
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('excludes the client request id that forms the operation key', () => {
|
|
68
|
+
const first = computeShareLinkRequestHash('create', {
|
|
69
|
+
id: 'share-id',
|
|
70
|
+
clientRequestId: '11111111-1111-4111-8111-111111111111',
|
|
71
|
+
});
|
|
72
|
+
const second = computeShareLinkRequestHash('create', {
|
|
73
|
+
id: 'share-id',
|
|
74
|
+
clientRequestId: '22222222-2222-4222-8222-222222222222',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(first).toBe(second);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('binds different passcodes while making the stored fingerprint secret-keyed', () => {
|
|
81
|
+
const request = { id: 'share-id', title: 'Shared', passcode: '1234' };
|
|
82
|
+
const first = computeShareLinkRequestHash('create', request);
|
|
83
|
+
expect(computeShareLinkRequestHash('create', request)).toBe(first);
|
|
84
|
+
expect(computeShareLinkRequestHash('create', { ...request, passcode: '1235' })).not.toBe(
|
|
85
|
+
first
|
|
86
|
+
);
|
|
87
|
+
expect(first).not.toBe(
|
|
88
|
+
createHash('sha256')
|
|
89
|
+
.update(canonicalizeJson({ opKind: 'create', request }))
|
|
90
|
+
.digest('hex')
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps the existing fingerprint for unprotected in-flight retries', () => {
|
|
95
|
+
const request = { id: 'share-id', title: 'Shared' };
|
|
96
|
+
expect(computeShareLinkRequestHash('create', request)).toBe(
|
|
97
|
+
createHash('sha256')
|
|
98
|
+
.update(canonicalizeJson({ opKind: 'create', request }))
|
|
99
|
+
.digest('hex')
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('requires an independently provisioned secret outside tests', () => {
|
|
104
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
105
|
+
vi.stubEnv('SHARE_LINK_REQUEST_HASH_SECRET', '');
|
|
106
|
+
try {
|
|
107
|
+
expect(getShareLinkRequestHashSecret).toThrow('SHARE_LINK_REQUEST_HASH_SECRET');
|
|
108
|
+
vi.stubEnv('SHARE_LINK_REQUEST_HASH_SECRET', 'x'.repeat(32));
|
|
109
|
+
expect(getShareLinkRequestHashSecret()).toBe('x'.repeat(32));
|
|
110
|
+
} finally {
|
|
111
|
+
vi.unstubAllEnvs();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects values that cannot be encoded unambiguously', () => {
|
|
116
|
+
expect(() => canonicalizeJson({ bad: () => undefined })).toThrow();
|
|
117
|
+
expect(() => canonicalizeJson({ bad: Number.NaN })).toThrow();
|
|
118
|
+
expect(() => canonicalizeJson({ bad: new Date() })).toThrow();
|
|
119
|
+
expect(() => canonicalizeJson({ bad: new Array(2) })).toThrow();
|
|
120
|
+
const cyclic: Record<string, unknown> = {};
|
|
121
|
+
cyclic.self = cyclic;
|
|
122
|
+
expect(() => canonicalizeJson(cyclic)).toThrow();
|
|
123
|
+
expect(() => canonicalizeJson({ bad: 0.5 })).toThrow();
|
|
124
|
+
expect(canonicalizeJson({ keep: 1, drop: undefined })).toBe('{"keep":1}');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('share-link lease and backoff helpers', () => {
|
|
129
|
+
it('treats a lease as inactive at the exact expiry instant', () => {
|
|
130
|
+
const now = new Date('2026-01-01T00:00:00.000Z');
|
|
131
|
+
const expiry = computeLeaseExpiry(now, 1000);
|
|
132
|
+
|
|
133
|
+
expect(expiry).toBe('2026-01-01T00:00:01.000Z');
|
|
134
|
+
expect(isLeaseActive(expiry, new Date('2026-01-01T00:00:00.999Z'))).toBe(true);
|
|
135
|
+
expect(isLeaseActive(expiry, new Date('2026-01-01T00:00:01.000Z'))).toBe(false);
|
|
136
|
+
expect(isLeaseActive(null, now)).toBe(false);
|
|
137
|
+
expect(isLeaseActive('not-a-date', now)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('backs off exponentially and caps at one day', () => {
|
|
141
|
+
expect(computeCleanupBackoffMs(0)).toBe(30_000);
|
|
142
|
+
expect(computeCleanupBackoffMs(1)).toBe(60_000);
|
|
143
|
+
expect(computeCleanupBackoffMs(-5)).toBe(30_000);
|
|
144
|
+
expect(computeCleanupBackoffMs(100)).toBe(24 * 60 * 60 * 1000);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('share-link constraint readiness', () => {
|
|
149
|
+
it('creates every constraint and index once across concurrent callers', async () => {
|
|
150
|
+
let releaseFirst!: () => void;
|
|
151
|
+
const gate = new Promise<void>(resolve => {
|
|
152
|
+
releaseFirst = resolve;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
mocks.run.mockReturnValueOnce(gate).mockResolvedValue(undefined);
|
|
156
|
+
|
|
157
|
+
const { ensureShareLinkConstraints, SHARE_LINK_SCHEMA_QUERIES } =
|
|
158
|
+
await import('../src/models/share-link-constraints');
|
|
159
|
+
|
|
160
|
+
const first = ensureShareLinkConstraints();
|
|
161
|
+
const second = ensureShareLinkConstraints();
|
|
162
|
+
|
|
163
|
+
expect(second).toBe(first);
|
|
164
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
165
|
+
|
|
166
|
+
releaseFirst();
|
|
167
|
+
await Promise.all([first, second]);
|
|
168
|
+
|
|
169
|
+
expect(mocks.run).toHaveBeenCalledTimes(SHARE_LINK_SCHEMA_QUERIES.length);
|
|
170
|
+
expect(mocks.run.mock.calls.map(([query]) => query)).toEqual([
|
|
171
|
+
...SHARE_LINK_SCHEMA_QUERIES,
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const queries = mocks.run.mock.calls.map(([query]) => String(query));
|
|
175
|
+
expect(queries.some(query => query.includes('share_link_operation_key_unique'))).toBe(true);
|
|
176
|
+
expect(queries.some(query => query.includes('share_link_reservation_share_unique'))).toBe(
|
|
177
|
+
true
|
|
178
|
+
);
|
|
179
|
+
expect(queries.some(query => query.includes('share_content_cleanup_object_unique'))).toBe(
|
|
180
|
+
true
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('accepts an equivalent-schema-rule race as ready', async () => {
|
|
185
|
+
mocks.run
|
|
186
|
+
.mockRejectedValueOnce({
|
|
187
|
+
code: 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists',
|
|
188
|
+
})
|
|
189
|
+
.mockResolvedValue(undefined);
|
|
190
|
+
|
|
191
|
+
const { ensureShareLinkConstraints } = await import('../src/models/share-link-constraints');
|
|
192
|
+
|
|
193
|
+
await expect(ensureShareLinkConstraints()).resolves.toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('clears failed readiness so a later request can retry', async () => {
|
|
197
|
+
const failure = new Error('neo4j unavailable');
|
|
198
|
+
mocks.run.mockRejectedValueOnce(failure).mockResolvedValue(undefined);
|
|
199
|
+
|
|
200
|
+
const { ensureShareLinkConstraints } = await import('../src/models/share-link-constraints');
|
|
201
|
+
|
|
202
|
+
await expect(ensureShareLinkConstraints()).rejects.toBe(failure);
|
|
203
|
+
await expect(ensureShareLinkConstraints()).resolves.toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { SHARE_LINK_ID_BYTES } from '@learncard/types';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
decodeShareLinkListCursor,
|
|
7
|
+
encodeShareLinkListCursor,
|
|
8
|
+
} from '../src/accesslayer/share-link/list';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical, versioned owner-list cursor codec (DB-free).
|
|
12
|
+
*
|
|
13
|
+
* The cursor is an ordering hint over exactly `(createdAt, id)`; it must reject
|
|
14
|
+
* everything malformed, non-canonical, oversized or from another version without
|
|
15
|
+
* throwing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ID = Buffer.alloc(SHARE_LINK_ID_BYTES, 7).toString('base64url');
|
|
19
|
+
|
|
20
|
+
describe('share-link list cursor codec', () => {
|
|
21
|
+
it('round-trips exactly the two ordering fields', () => {
|
|
22
|
+
const cursor = { createdAt: '2026-09-20T00:00:00.000Z', id: ID };
|
|
23
|
+
const encoded = encodeShareLinkListCursor(cursor);
|
|
24
|
+
|
|
25
|
+
expect(encoded.startsWith('v1.')).toBe(true);
|
|
26
|
+
expect(decodeShareLinkListCursor(encoded)).toEqual(cursor);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects non-string, empty and oversized input', () => {
|
|
30
|
+
expect(decodeShareLinkListCursor(undefined)).toBeNull();
|
|
31
|
+
expect(decodeShareLinkListCursor(123)).toBeNull();
|
|
32
|
+
expect(decodeShareLinkListCursor('')).toBeNull();
|
|
33
|
+
expect(decodeShareLinkListCursor(`v1.${'A'.repeat(600)}`)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects an unknown version prefix', () => {
|
|
37
|
+
expect(
|
|
38
|
+
decodeShareLinkListCursor(`v2.${Buffer.from('["a","b"]').toString('base64url')}`)
|
|
39
|
+
).toBeNull();
|
|
40
|
+
expect(decodeShareLinkListCursor('list.abc')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects non-canonical base64url and non-JSON payloads', () => {
|
|
44
|
+
expect(decodeShareLinkListCursor('v1.@@@@')).toBeNull();
|
|
45
|
+
expect(decodeShareLinkListCursor('v1.====')).toBeNull();
|
|
46
|
+
expect(
|
|
47
|
+
decodeShareLinkListCursor(`v1.${Buffer.from('not json', 'utf8').toString('base64url')}`)
|
|
48
|
+
).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects wrong arity or non-string fields', () => {
|
|
52
|
+
const payload = (value: unknown) =>
|
|
53
|
+
`v1.${Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')}`;
|
|
54
|
+
|
|
55
|
+
expect(decodeShareLinkListCursor(payload(['2026-09-20T00:00:00.000Z']))).toBeNull();
|
|
56
|
+
expect(
|
|
57
|
+
decodeShareLinkListCursor(payload(['2026-09-20T00:00:00.000Z', ID, 'extra']))
|
|
58
|
+
).toBeNull();
|
|
59
|
+
expect(decodeShareLinkListCursor(payload([1, ID]))).toBeNull();
|
|
60
|
+
expect(decodeShareLinkListCursor(payload(['2026-09-20T00:00:00.000Z', 1]))).toBeNull();
|
|
61
|
+
expect(decodeShareLinkListCursor(payload({ createdAt: 'x', id: ID }))).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects impossible timestamps and non-canonical ISO spellings', () => {
|
|
65
|
+
const payload = (value: unknown) =>
|
|
66
|
+
`v1.${Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')}`;
|
|
67
|
+
|
|
68
|
+
expect(decodeShareLinkListCursor(payload(['not-a-date', ID]))).toBeNull();
|
|
69
|
+
expect(decodeShareLinkListCursor(payload(['2026-13-40T00:00:00.000Z', ID]))).toBeNull();
|
|
70
|
+
// Valid instant but not the exact canonical `toISOString` spelling.
|
|
71
|
+
expect(decodeShareLinkListCursor(payload(['2026-09-20T00:00:00Z', ID]))).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects an id that is not canonical base64url for exactly 16 bytes', () => {
|
|
75
|
+
const payload = (createdAt: string, id: string) =>
|
|
76
|
+
`v1.${Buffer.from(JSON.stringify([createdAt, id]), 'utf8').toString('base64url')}`;
|
|
77
|
+
|
|
78
|
+
expect(decodeShareLinkListCursor(payload('2026-09-20T00:00:00.000Z', 'short'))).toBeNull();
|
|
79
|
+
expect(decodeShareLinkListCursor(payload('2026-09-20T00:00:00.000Z', `${ID}=`))).toBeNull();
|
|
80
|
+
expect(
|
|
81
|
+
decodeShareLinkListCursor(
|
|
82
|
+
payload('2026-09-20T00:00:00.000Z', Buffer.alloc(8, 1).toString('base64url'))
|
|
83
|
+
)
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { neogma } from '@instance';
|
|
6
|
+
|
|
7
|
+
import { decodeShareLinkListCursor, listShareLinks } from '../src/accesslayer/share-link/list';
|
|
8
|
+
import { ensureShareLinkConstraints } from '../src/models/share-link-constraints';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LC-2187 bounded owner-list repository against a REAL Neo4j (disposable
|
|
12
|
+
* testcontainer through the shared harness).
|
|
13
|
+
*
|
|
14
|
+
* The graph ordering, keyset `WHERE` clause, limit+1 fetch and namespace/owner
|
|
15
|
+
* scoping are real. There is no remote store, policy network or route here.
|
|
16
|
+
* Every seeded share id is a canonical 16-byte base64url value so the cursor
|
|
17
|
+
* codec accepts it exactly as production does.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const NAMESPACE = 'list-test-ns';
|
|
21
|
+
const OTHER_NAMESPACE = 'list-test-ns-2';
|
|
22
|
+
const OWNER = 'owner-1';
|
|
23
|
+
const OTHER_OWNER = 'owner-2';
|
|
24
|
+
|
|
25
|
+
const newId = (): string => randomBytes(16).toString('base64url');
|
|
26
|
+
|
|
27
|
+
type SeedInput = {
|
|
28
|
+
id?: string;
|
|
29
|
+
namespace?: string;
|
|
30
|
+
ownerProfileId?: string;
|
|
31
|
+
createdAt?: string;
|
|
32
|
+
status?: 'pending' | 'active' | 'stopped';
|
|
33
|
+
contentState?: 'staging' | 'finalized' | 'content_missing';
|
|
34
|
+
expiresAt?: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const seedShare = async (input: SeedInput = {}): Promise<string> => {
|
|
38
|
+
const id = input.id ?? newId();
|
|
39
|
+
|
|
40
|
+
const props: Record<string, unknown> = {
|
|
41
|
+
id,
|
|
42
|
+
namespace: input.namespace ?? NAMESPACE,
|
|
43
|
+
ownerProfileId: input.ownerProfileId ?? OWNER,
|
|
44
|
+
version: 1,
|
|
45
|
+
contentVersion: 1,
|
|
46
|
+
generation: 1,
|
|
47
|
+
status: input.status ?? 'active',
|
|
48
|
+
contentState: input.contentState ?? 'finalized',
|
|
49
|
+
title: 'Shared credentials',
|
|
50
|
+
note: null,
|
|
51
|
+
selectedCount: 1,
|
|
52
|
+
expiresAt: input.expiresAt ?? null,
|
|
53
|
+
stoppedAt: null,
|
|
54
|
+
viewCount: 0,
|
|
55
|
+
lastViewedAt: null,
|
|
56
|
+
minorPolicyIsMinor: null,
|
|
57
|
+
minorPolicyResolved: false,
|
|
58
|
+
minorPolicyDefaultExpiryDays: 30,
|
|
59
|
+
minorPolicyViewCountingEnabled: false,
|
|
60
|
+
createdAt: input.createdAt ?? '2026-09-20T00:00:00.000Z',
|
|
61
|
+
updatedAt: input.createdAt ?? '2026-09-20T00:00:00.000Z',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await neogma.queryRunner.run('MERGE (s:ShareLink {id: $id}) SET s = $props', { id, props });
|
|
65
|
+
|
|
66
|
+
return id;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const clearGraph = async (): Promise<void> => {
|
|
70
|
+
await neogma.queryRunner.run('MATCH (n:ShareLink) DETACH DELETE n');
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const collectAll = async ({
|
|
74
|
+
namespace = NAMESPACE,
|
|
75
|
+
ownerProfileId = OWNER,
|
|
76
|
+
limit = 2,
|
|
77
|
+
}: {
|
|
78
|
+
namespace?: string;
|
|
79
|
+
ownerProfileId?: string;
|
|
80
|
+
limit?: number;
|
|
81
|
+
} = {}): Promise<{ ids: string[]; pages: number }> => {
|
|
82
|
+
const ids: string[] = [];
|
|
83
|
+
let pages = 0;
|
|
84
|
+
let cursor: { createdAt: string; id: string } | null = null;
|
|
85
|
+
|
|
86
|
+
do {
|
|
87
|
+
const page = await listShareLinks({
|
|
88
|
+
namespace,
|
|
89
|
+
ownerProfileId,
|
|
90
|
+
limit,
|
|
91
|
+
cursor,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ids.push(...page.records.map(record => record.id));
|
|
95
|
+
pages += 1;
|
|
96
|
+
cursor = page.nextCursor ? decodeShareLinkListCursor(page.nextCursor) : null;
|
|
97
|
+
|
|
98
|
+
if (page.nextCursor) expect(cursor).not.toBeNull();
|
|
99
|
+
} while (cursor !== null);
|
|
100
|
+
|
|
101
|
+
return { ids, pages };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
describe('share-link owner list repository (real Neo4j)', () => {
|
|
105
|
+
beforeAll(async () => {
|
|
106
|
+
await ensureShareLinkConstraints();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
beforeEach(async () => {
|
|
110
|
+
await clearGraph();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await clearGraph();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('paginates by the immutable (createdAt, id) key with limit+1 and a next cursor', async () => {
|
|
118
|
+
const older = await seedShare({ createdAt: '2026-09-19T00:00:00.000Z' });
|
|
119
|
+
const middle = await seedShare({ createdAt: '2026-09-20T00:00:00.000Z' });
|
|
120
|
+
const newest = await seedShare({ createdAt: '2026-09-21T00:00:00.000Z' });
|
|
121
|
+
|
|
122
|
+
const first = await listShareLinks({
|
|
123
|
+
namespace: NAMESPACE,
|
|
124
|
+
ownerProfileId: OWNER,
|
|
125
|
+
limit: 2,
|
|
126
|
+
cursor: null,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(first.records.map(record => record.id)).toEqual([newest, middle]);
|
|
130
|
+
expect(first.hasMore).toBe(true);
|
|
131
|
+
expect(first.nextCursor).not.toBeNull();
|
|
132
|
+
|
|
133
|
+
const decoded = decodeShareLinkListCursor(first.nextCursor);
|
|
134
|
+
expect(decoded).toEqual({ createdAt: '2026-09-20T00:00:00.000Z', id: middle });
|
|
135
|
+
|
|
136
|
+
const second = await listShareLinks({
|
|
137
|
+
namespace: NAMESPACE,
|
|
138
|
+
ownerProfileId: OWNER,
|
|
139
|
+
limit: 2,
|
|
140
|
+
cursor: decoded,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(second.records.map(record => record.id)).toEqual([older]);
|
|
144
|
+
expect(second.hasMore).toBe(false);
|
|
145
|
+
expect(second.nextCursor).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not duplicate or skip records when createdAt ties', async () => {
|
|
149
|
+
const createdAt = '2026-09-20T00:00:00.000Z';
|
|
150
|
+
const seeded = await Promise.all(Array.from({ length: 7 }, () => seedShare({ createdAt })));
|
|
151
|
+
|
|
152
|
+
const { ids, pages } = await collectAll({ limit: 2 });
|
|
153
|
+
|
|
154
|
+
expect(ids).toHaveLength(seeded.length);
|
|
155
|
+
expect(new Set(ids).size).toBe(seeded.length);
|
|
156
|
+
expect(ids.every(id => seeded.includes(id))).toBe(true);
|
|
157
|
+
// Deterministic descending id tie-break, independent of seed order.
|
|
158
|
+
expect(ids).toEqual([...seeded].sort((a, b) => (a < b ? 1 : -1)));
|
|
159
|
+
expect(pages).toBe(4);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('returns every page exactly once across page boundaries', async () => {
|
|
163
|
+
const seeded: string[] = [];
|
|
164
|
+
|
|
165
|
+
for (let index = 0; index < 10; index += 1) {
|
|
166
|
+
seeded.push(
|
|
167
|
+
await seedShare({
|
|
168
|
+
createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, index)).toISOString(),
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { ids } = await collectAll({ limit: 3 });
|
|
174
|
+
|
|
175
|
+
expect(new Set(ids).size).toBe(seeded.length);
|
|
176
|
+
expect(ids).toHaveLength(seeded.length);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('lists pending, stopped and retained-expired records without triggering cleanup', async () => {
|
|
180
|
+
const pending = await seedShare({ status: 'pending', contentState: 'staging' });
|
|
181
|
+
const stopped = await seedShare({ status: 'stopped' });
|
|
182
|
+
const expired = await seedShare({
|
|
183
|
+
status: 'active',
|
|
184
|
+
expiresAt: '2020-01-01T00:00:00.000Z',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = await listShareLinks({
|
|
188
|
+
namespace: NAMESPACE,
|
|
189
|
+
ownerProfileId: OWNER,
|
|
190
|
+
limit: 25,
|
|
191
|
+
cursor: null,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(new Set(result.records.map(record => record.id))).toEqual(
|
|
195
|
+
new Set([pending, stopped, expired])
|
|
196
|
+
);
|
|
197
|
+
// Read-only: no cleanup jobs are created by listing.
|
|
198
|
+
const jobs = await neogma.queryRunner.run(
|
|
199
|
+
'MATCH (c:ShareContentCleanupJob) RETURN count(c) AS count'
|
|
200
|
+
);
|
|
201
|
+
expect(Number(jobs.records[0]?.get('count') ?? 0)).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('isolates owners in the same namespace', async () => {
|
|
205
|
+
const mine = await seedShare({ createdAt: '2026-09-20T00:00:00.000Z' });
|
|
206
|
+
await seedShare({
|
|
207
|
+
ownerProfileId: OTHER_OWNER,
|
|
208
|
+
createdAt: '2026-09-21T00:00:00.000Z',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const result = await listShareLinks({
|
|
212
|
+
namespace: NAMESPACE,
|
|
213
|
+
ownerProfileId: OWNER,
|
|
214
|
+
limit: 25,
|
|
215
|
+
cursor: null,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result.records.map(record => record.id)).toEqual([mine]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('isolates namespaces for the same owner', async () => {
|
|
222
|
+
const mine = await seedShare({ createdAt: '2026-09-20T00:00:00.000Z' });
|
|
223
|
+
await seedShare({
|
|
224
|
+
namespace: OTHER_NAMESPACE,
|
|
225
|
+
createdAt: '2026-09-21T00:00:00.000Z',
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const result = await listShareLinks({
|
|
229
|
+
namespace: NAMESPACE,
|
|
230
|
+
ownerProfileId: OWNER,
|
|
231
|
+
limit: 25,
|
|
232
|
+
cursor: null,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.records.map(record => record.id)).toEqual([mine]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('never reveals records through another owner cursor', async () => {
|
|
239
|
+
const mineNew = await seedShare({
|
|
240
|
+
ownerProfileId: OWNER,
|
|
241
|
+
createdAt: '2026-09-25T00:00:00.000Z',
|
|
242
|
+
});
|
|
243
|
+
const mineOld = await seedShare({
|
|
244
|
+
ownerProfileId: OWNER,
|
|
245
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
246
|
+
});
|
|
247
|
+
const foreignNew = await seedShare({
|
|
248
|
+
ownerProfileId: OTHER_OWNER,
|
|
249
|
+
createdAt: '2026-09-30T00:00:00.000Z',
|
|
250
|
+
});
|
|
251
|
+
const foreignOld = await seedShare({
|
|
252
|
+
ownerProfileId: OTHER_OWNER,
|
|
253
|
+
createdAt: '2026-09-02T00:00:00.000Z',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// A cursor minted from the other owner's page.
|
|
257
|
+
const otherOwnerPage = await listShareLinks({
|
|
258
|
+
namespace: NAMESPACE,
|
|
259
|
+
ownerProfileId: OTHER_OWNER,
|
|
260
|
+
limit: 1,
|
|
261
|
+
cursor: null,
|
|
262
|
+
});
|
|
263
|
+
const foreignCursor = decodeShareLinkListCursor(otherOwnerPage.nextCursor);
|
|
264
|
+
|
|
265
|
+
const page = await listShareLinks({
|
|
266
|
+
namespace: NAMESPACE,
|
|
267
|
+
ownerProfileId: OWNER,
|
|
268
|
+
limit: 25,
|
|
269
|
+
cursor: foreignCursor,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// The cursor is only an ordering hint: the caller's own scope is still
|
|
273
|
+
// applied, so the foreign cursor cannot surface foreign records, and no
|
|
274
|
+
// foreign id appears.
|
|
275
|
+
expect(page.records.map(record => record.id)).not.toContain(foreignNew);
|
|
276
|
+
expect(page.records.map(record => record.id)).not.toContain(foreignOld);
|
|
277
|
+
expect(page.records.every(record => [mineNew, mineOld].includes(record.id))).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('bounds an oversized limit to the shared maximum without unbounded fetch', async () => {
|
|
281
|
+
for (let index = 0; index < 5; index += 1) {
|
|
282
|
+
await seedShare({
|
|
283
|
+
createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, index)).toISOString(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const result = await listShareLinks({
|
|
288
|
+
namespace: NAMESPACE,
|
|
289
|
+
ownerProfileId: OWNER,
|
|
290
|
+
limit: 500,
|
|
291
|
+
cursor: null,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.records).toHaveLength(5);
|
|
295
|
+
expect(result.hasMore).toBe(false);
|
|
296
|
+
expect(result.nextCursor).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('treats namespace/owner as literal bound parameters', async () => {
|
|
300
|
+
const trickyNamespace = "weird'ns--";
|
|
301
|
+
const trickyOwner = "o'wner--";
|
|
302
|
+
const id = await seedShare({
|
|
303
|
+
namespace: trickyNamespace,
|
|
304
|
+
ownerProfileId: trickyOwner,
|
|
305
|
+
createdAt: '2026-09-20T00:00:00.000Z',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const hit = await listShareLinks({
|
|
309
|
+
namespace: trickyNamespace,
|
|
310
|
+
ownerProfileId: trickyOwner,
|
|
311
|
+
limit: 25,
|
|
312
|
+
cursor: null,
|
|
313
|
+
});
|
|
314
|
+
expect(hit.records.map(record => record.id)).toEqual([id]);
|
|
315
|
+
|
|
316
|
+
const miss = await listShareLinks({
|
|
317
|
+
namespace: `${trickyNamespace}x`,
|
|
318
|
+
ownerProfileId: trickyOwner,
|
|
319
|
+
limit: 25,
|
|
320
|
+
cursor: null,
|
|
321
|
+
});
|
|
322
|
+
expect(miss.records).toHaveLength(0);
|
|
323
|
+
});
|
|
324
|
+
});
|