@learncard/network-brain-service 3.19.1 → 4.0.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 +44 -111
- package/AGENTS.md +52 -0
- package/CHANGELOG.md +120 -0
- package/README.md +9 -2
- package/bootstrapLambda.test.ts +72 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +29 -0
- package/config/config.example.json +29 -0
- package/config/config.json +1 -0
- package/config/config.production.json +29 -0
- package/config/config.scouts.dev.json +24 -0
- package/config/config.scouts.production.json +24 -0
- package/contractEventsLambda.ts +18 -0
- package/contractEventsLambdaApp.ts +19 -0
- package/didWebLambda.ts +13 -18
- package/didWebLambdaApp.ts +22 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
- package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
- package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
- package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
- package/dist/accesslayer/share-link/attachment.d.ts +37 -0
- package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
- package/dist/accesslayer/share-link/helpers.d.ts +3 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
- package/dist/accesslayer/share-link/types.d.ts +8 -3
- package/dist/accesslayer/share-link/types.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +49 -0
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/environment.d.ts +5 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +112 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.d.ts.map +1 -1
- package/dist/helpers/app-manifest.helpers.d.ts +5 -0
- package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
- package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
- package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
- package/dist/helpers/consent-referral.helpers.d.ts +2 -0
- package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
- package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
- package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts +25 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-requests.helpers.d.ts +19 -0
- package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
- package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inline-template.helpers.d.ts +45 -0
- package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts +6 -0
- package/dist/helpers/notifications.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-link-attachment-access.d.ts +4 -0
- package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
- package/dist/helpers/share-link-attachment.d.ts +18 -0
- package/dist/helpers/share-link-attachment.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/production.d.ts +3 -5
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/types.d.ts +8 -2
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/smartResume.helpers.d.ts +3 -0
- package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AppManifestVersion.d.ts +20 -0
- package/dist/models/AppManifestVersion.d.ts.map +1 -0
- package/dist/models/AppStoreListing.d.ts +13 -0
- package/dist/models/AppStoreListing.d.ts.map +1 -1
- package/dist/models/ConsentFlowContract.d.ts +13 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +2 -0
- package/dist/models/Integration.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +2 -0
- package/dist/models/ShareLink.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/app-store.d.ts +619 -0
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +2 -2
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +146 -3
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +34 -0
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +52 -2
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.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 +55 -0
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +175 -14
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/test.d.ts +5 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/types/app-manifest.d.ts +339 -0
- package/dist/types/app-manifest.d.ts.map +1 -0
- package/dist/types/app-store-listing.d.ts +34 -0
- package/dist/types/app-store-listing.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +30 -3
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +1 -1
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +49 -195
- package/lambdaApp.ts +203 -0
- package/package.json +20 -19
- package/project.json +8 -1
- package/scripts/export-openapi.ts +20 -0
- package/scripts/offline-schema-environment.ts +18 -0
- package/scripts/verify-python-contracts.ts +72 -0
- package/serverless-local.yml +3 -6
- package/serverless.function-env.cjs +50 -0
- package/serverless.local.yml +0 -1
- package/serverless.yml +29 -74
- package/shareLinkMaintenanceLambda.ts +13 -44
- package/shareLinkMaintenanceLambdaApp.ts +50 -0
- package/src/accesslayer/app-manifest-version/create.ts +57 -0
- package/src/accesslayer/app-manifest-version/read.ts +129 -0
- package/src/accesslayer/app-manifest-version/update.ts +61 -0
- package/src/accesslayer/app-store-listing/read.ts +6 -0
- package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
- package/src/accesslayer/app-store-listing/update.ts +15 -0
- package/src/accesslayer/boost/relationships/create.ts +8 -2
- package/src/accesslayer/consentflowcontract/create.ts +81 -8
- package/src/accesslayer/consentflowcontract/read.ts +84 -10
- package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
- package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
- package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
- package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
- package/src/accesslayer/credential/create.test.ts +2 -115
- package/src/accesslayer/credential/create.ts +11 -6
- package/src/accesslayer/credential/relationships/create.ts +17 -10
- package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
- package/src/accesslayer/inbox-credential/update.ts +4 -2
- package/src/accesslayer/share-link/abandon.ts +20 -0
- package/src/accesslayer/share-link/attachment.ts +360 -0
- package/src/accesslayer/share-link/finalize.ts +55 -7
- package/src/accesslayer/share-link/helpers.ts +33 -2
- package/src/accesslayer/share-link/recovery.ts +20 -0
- package/src/accesslayer/share-link/reserve.ts +85 -6
- package/src/accesslayer/share-link/revoke.ts +35 -0
- package/src/accesslayer/share-link/types.ts +9 -4
- package/src/cache/app-store.caches.ts +35 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/environment.test.ts +17 -12
- package/src/config/environment.ts +1 -0
- package/src/config/serverlessFunctionEnv.test.ts +105 -0
- package/src/config/stageConfig.test.ts +25 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +24 -0
- package/src/helpers/app-manifest.helpers.ts +185 -0
- package/src/helpers/claim-hooks.helpers.ts +20 -7
- package/src/helpers/consent-referral.helpers.ts +6 -0
- package/src/helpers/consent-scopes.helpers.ts +58 -0
- package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
- package/src/helpers/contract-events.helpers.ts +540 -0
- package/src/helpers/contract-requests.helpers.ts +211 -0
- package/src/helpers/finalize-inbox.helpers.ts +163 -44
- package/src/helpers/inbox.helpers.ts +51 -66
- package/src/helpers/inline-template.helpers.ts +560 -0
- package/src/helpers/notificationMessages.ts +59 -0
- package/src/helpers/notificationQueue.helpers.ts +23 -5
- package/src/helpers/notifications.helpers.ts +25 -1
- package/src/helpers/profile.helpers.ts +6 -0
- package/src/helpers/share-link-attachment-access.ts +87 -0
- package/src/helpers/share-link-attachment.ts +115 -0
- package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
- package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
- package/src/helpers/share-link-maintenance/runner.ts +28 -0
- package/src/helpers/share-link-maintenance/runtime.ts +6 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
- package/src/helpers/share-link-maintenance/types.ts +2 -0
- package/src/helpers/share-link-owner-projection.ts +2 -0
- package/src/helpers/share-link-policy/production.ts +37 -17
- package/src/helpers/share-link-policy/resolver.ts +16 -11
- package/src/helpers/share-link-policy/types.ts +15 -7
- package/src/helpers/signingAuthority.helpers.ts +23 -14
- package/src/helpers/smartResume.helpers.ts +123 -0
- package/src/models/AppManifestVersion.ts +43 -0
- package/src/models/AppStoreListing.ts +32 -0
- package/src/models/ConsentFlowContract.ts +34 -3
- package/src/models/ConsentFlowTerms.ts +10 -0
- package/src/models/ConsentFlowTransaction.ts +3 -0
- package/src/models/Integration.ts +2 -0
- package/src/models/ShareLink.ts +4 -0
- package/src/models/index.ts +23 -0
- package/src/models/share-link-constraints.ts +5 -0
- package/src/openapi.ts +44 -8
- package/src/routes/app-store.ts +1054 -27
- package/src/routes/boosts.ts +12 -14
- package/src/routes/contracts.ts +382 -262
- package/src/routes/inbox.ts +4 -2
- package/src/routes/index.ts +4 -1
- package/src/routes/profile-manager.ts +19 -0
- package/src/routes/profiles.ts +12 -0
- package/src/routes/public-share-links.ts +169 -0
- package/src/routes/share-links.ts +69 -0
- package/src/routes/workflows.ts +3 -2
- package/src/types/app-manifest.ts +28 -0
- package/src/types/app-store-listing.ts +13 -0
- package/src/types/consentflowcontract.ts +25 -4
- package/test/app-manifest.helpers.spec.ts +170 -0
- package/test/app-manifest.routes.spec.ts +299 -0
- package/test/app-store-listing-updates.spec.ts +236 -0
- package/test/app-store-listing.spec.ts +65 -3
- package/test/consent-data-boundaries.spec.ts +352 -0
- package/test/consent-recipients.spec.ts +513 -0
- package/test/consent-scopes.helpers.spec.ts +67 -0
- package/test/consentflow.spec.ts +63 -2
- package/test/contract-requests.spec.ts +1588 -0
- package/test/credential-storage.neo4j.spec.ts +122 -0
- package/test/guardian-gated-routes.spec.ts +36 -0
- package/test/inbox-security.e2e.ts +26 -10
- package/test/inbox.spec.ts +184 -8
- package/test/inline-template.helpers.spec.ts +49 -0
- package/test/managed-recipient-inbox.spec.ts +24 -2
- package/test/openapi.spec.ts +14 -73
- package/test/profiles.spec.ts +30 -0
- package/test/share-link-attachment.neo4j.spec.ts +262 -0
- package/test/share-link-attachment.unit.spec.ts +244 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
- package/test/share-link-maintenance.unit.spec.ts +32 -5
- package/test/share-link-owner-route.unit.spec.ts +70 -2
- package/test/share-link-policy.unit.spec.ts +18 -4
- package/test/share-link-production-policy.unit.spec.ts +58 -9
- package/test/share-link-public-route.unit.spec.ts +279 -0
- package/test/share-link-service-policy.neo4j.spec.ts +403 -0
- package/test/signing-authority-encryption.spec.ts +75 -0
- package/test/smart-resume-consent.spec.ts +233 -0
- package/test/smart-resume-upload.spec.ts +65 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +2 -0
- package/workerLambda.test.ts +100 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
|
|
3
5
|
import type { ShareLinkPolicyResolver } from '@helpers/share-link-policy/types';
|
|
4
6
|
import type { ShareLinkRecord } from '../src/models/ShareLink';
|
|
5
7
|
import { SharePasscodeCapacityError } from '../src/helpers/share-link-passcode';
|
|
@@ -216,6 +218,283 @@ beforeEach(() => {
|
|
|
216
218
|
rateLimits.enforceRateLimits.mockResolvedValue(undefined);
|
|
217
219
|
});
|
|
218
220
|
|
|
221
|
+
describe('guarded managed attachment chunks', () => {
|
|
222
|
+
const attachmentId = '5d02e240-7cae-499d-9b18-d477e6b9ebd2';
|
|
223
|
+
const request = { id: SHARE_ID, contentVersion: 2, attachmentId, chunkIndex: 0 };
|
|
224
|
+
const setup = (overrides: Partial<ShareLinkRecord> = {}) => {
|
|
225
|
+
let record = shareRecord({ attachmentId, attachmentChunkCount: 16, ...overrides });
|
|
226
|
+
// This fixture deliberately changes password/clock dependencies between
|
|
227
|
+
// requests to exercise invalidation; production dependencies stay readonly.
|
|
228
|
+
const dependencies: {
|
|
229
|
+
-readonly [
|
|
230
|
+
Key in keyof PublicShareLinkRouterDependencies
|
|
231
|
+
]: PublicShareLinkRouterDependencies[Key];
|
|
232
|
+
} = { ...makeDependencies({ getShareLink: vi.fn(async () => record) }) };
|
|
233
|
+
const fetch = vi.fn(
|
|
234
|
+
async (tuple: {
|
|
235
|
+
namespace: string;
|
|
236
|
+
ownerProfileId: string;
|
|
237
|
+
shareId: string;
|
|
238
|
+
contentVersion: number;
|
|
239
|
+
objectId: string;
|
|
240
|
+
operationId: string;
|
|
241
|
+
}) => ({ ok: true as const, value: { ...contentProjection(record), ...tuple } })
|
|
242
|
+
);
|
|
243
|
+
dependencies.repository.fetchAttachmentChunk = fetch;
|
|
244
|
+
return {
|
|
245
|
+
dependencies,
|
|
246
|
+
fetch,
|
|
247
|
+
setRecord: (next: ShareLinkRecord) => {
|
|
248
|
+
record = next;
|
|
249
|
+
},
|
|
250
|
+
getRecord: () => record,
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
it('derives storage tuple from committed state without accepting caller capabilities', async () => {
|
|
255
|
+
const { dependencies, fetch } = setup();
|
|
256
|
+
await expect(makeCaller(dependencies).attachmentChunk(request)).resolves.toEqual({
|
|
257
|
+
...request,
|
|
258
|
+
envelope,
|
|
259
|
+
});
|
|
260
|
+
expect(fetch).toHaveBeenCalledWith({
|
|
261
|
+
namespace: NAME,
|
|
262
|
+
ownerProfileId: 'owner-1',
|
|
263
|
+
shareId: SHARE_ID,
|
|
264
|
+
contentVersion: 2,
|
|
265
|
+
objectId: `resume-pdf-${attachmentId}-0`,
|
|
266
|
+
operationId: attachmentId,
|
|
267
|
+
});
|
|
268
|
+
await expect(
|
|
269
|
+
makeCaller(dependencies).attachmentChunk({
|
|
270
|
+
...request,
|
|
271
|
+
ownerProfileId: 'someone-else',
|
|
272
|
+
} as never)
|
|
273
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
274
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
275
|
+
expect(dependencies.receipts.persist).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it.each([
|
|
279
|
+
{ status: 'stopped' },
|
|
280
|
+
{ status: 'pending' },
|
|
281
|
+
{ contentState: 'content_missing' },
|
|
282
|
+
{ expiresAt: '2026-09-20T00:00:00.000Z' },
|
|
283
|
+
{ contentVersion: 3 },
|
|
284
|
+
{ attachmentId: OPERATION_ID },
|
|
285
|
+
{ attachmentChunkCount: 0 },
|
|
286
|
+
{ namespace: 'other-deployment' },
|
|
287
|
+
] as Partial<ShareLinkRecord>[])(
|
|
288
|
+
'withholds chunks for inactive/mismatched state %#',
|
|
289
|
+
async override => {
|
|
290
|
+
const { dependencies, fetch } = setup(override);
|
|
291
|
+
await expect(makeCaller(dependencies).attachmentChunk(request)).rejects.toMatchObject({
|
|
292
|
+
code: 'NOT_FOUND',
|
|
293
|
+
});
|
|
294
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
it.each(['stop', 'replace', 'expire', 'passcode'])(
|
|
299
|
+
'denies state change during remote fetch: %s',
|
|
300
|
+
async change => {
|
|
301
|
+
const state = setup();
|
|
302
|
+
state.fetch.mockImplementationOnce(async tuple => {
|
|
303
|
+
const original = state.getRecord();
|
|
304
|
+
const value = { ...contentProjection(original), ...tuple };
|
|
305
|
+
state.setRecord({
|
|
306
|
+
...original,
|
|
307
|
+
...(change === 'stop'
|
|
308
|
+
? { status: 'stopped' }
|
|
309
|
+
: change === 'replace'
|
|
310
|
+
? { contentVersion: 3, version: 4 }
|
|
311
|
+
: change === 'expire'
|
|
312
|
+
? { expiresAt: '2026-09-20T00:00:00.000Z' }
|
|
313
|
+
: { passcodeHash: 'changed-verifier', version: 4 }),
|
|
314
|
+
});
|
|
315
|
+
return { ok: true as const, value };
|
|
316
|
+
});
|
|
317
|
+
await expect(
|
|
318
|
+
makeCaller(state.dependencies).attachmentChunk(request)
|
|
319
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
it('enforces passcode attempts once and uses a current-state bound grant for all 16 chunks', async () => {
|
|
324
|
+
const state = setup({ passcodeHash: 'secret-password-verifier' });
|
|
325
|
+
state.dependencies.verifyPasscode = vi.fn(
|
|
326
|
+
async (_hash, password) => password === 'correct-password'
|
|
327
|
+
);
|
|
328
|
+
const caller = makeCaller(state.dependencies);
|
|
329
|
+
await expect(
|
|
330
|
+
caller.attachmentChunk({ ...request, passcode: 'wrong-password' })
|
|
331
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
332
|
+
const first = await caller.attachmentChunk({ ...request, passcode: 'correct-password' });
|
|
333
|
+
expect(first.accessToken).toBeDefined();
|
|
334
|
+
expect(first.accessToken).not.toContain('secret-password-verifier');
|
|
335
|
+
for (let chunkIndex = 1; chunkIndex < 16; chunkIndex++) {
|
|
336
|
+
await expect(
|
|
337
|
+
caller.attachmentChunk({ ...request, chunkIndex, accessToken: first.accessToken })
|
|
338
|
+
).resolves.toMatchObject({ chunkIndex, envelope });
|
|
339
|
+
}
|
|
340
|
+
expect(state.dependencies.verifyPasscode).toHaveBeenCalledTimes(2);
|
|
341
|
+
expect(state.dependencies.passcodeAttempts?.reserve).toHaveBeenCalledTimes(2);
|
|
342
|
+
// Grant is bound to source IP and cannot replace a current password check from another source.
|
|
343
|
+
await expect(
|
|
344
|
+
makeCaller(state.dependencies, { sourceIp: '203.0.113.8' }).attachmentChunk({
|
|
345
|
+
...request,
|
|
346
|
+
accessToken: first.accessToken,
|
|
347
|
+
})
|
|
348
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
349
|
+
state.dependencies.now = () => new Date('2026-09-21T00:01:00.000Z');
|
|
350
|
+
await expect(
|
|
351
|
+
caller.attachmentChunk({ ...request, accessToken: first.accessToken })
|
|
352
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('renews grants across a five-minute download without repeating passcode work', async () => {
|
|
356
|
+
const state = setup({ passcodeHash: 'secret-password-verifier' });
|
|
357
|
+
state.dependencies.verifyPasscode = vi.fn(async () => true);
|
|
358
|
+
let elapsed = 0;
|
|
359
|
+
state.dependencies.now = () => new Date(Date.parse('2026-09-21T00:00:00Z') + elapsed);
|
|
360
|
+
const caller = makeCaller(state.dependencies);
|
|
361
|
+
const first = await caller.attachmentChunk({ ...request, passcode: 'correct-password' });
|
|
362
|
+
let accessToken = first.accessToken;
|
|
363
|
+
for (let chunkIndex = 1; chunkIndex < 16; chunkIndex++) {
|
|
364
|
+
elapsed += 20_000;
|
|
365
|
+
const next = await caller.attachmentChunk({ ...request, chunkIndex, accessToken });
|
|
366
|
+
expect(next.envelope).toEqual(envelope);
|
|
367
|
+
expect(next.accessToken).not.toBe(accessToken);
|
|
368
|
+
accessToken = next.accessToken;
|
|
369
|
+
}
|
|
370
|
+
expect(elapsed).toBe(300_000);
|
|
371
|
+
expect(state.dependencies.verifyPasscode).toHaveBeenCalledOnce();
|
|
372
|
+
expect(state.dependencies.passcodeAttempts?.reserve).toHaveBeenCalledOnce();
|
|
373
|
+
await expect(
|
|
374
|
+
makeCaller(state.dependencies, { sourceIp: '203.0.113.8' }).attachmentChunk({
|
|
375
|
+
...request,
|
|
376
|
+
accessToken,
|
|
377
|
+
passcode: 'correct-password',
|
|
378
|
+
})
|
|
379
|
+
).resolves.toMatchObject({ accessToken: expect.any(String) });
|
|
380
|
+
expect(state.dependencies.verifyPasscode).toHaveBeenCalledTimes(2);
|
|
381
|
+
await expect(
|
|
382
|
+
caller.attachmentChunk({ ...request, accessToken: first.accessToken })
|
|
383
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('withholds a grant that expires during retrieval and permits fresh passcode authorization', async () => {
|
|
387
|
+
const state = setup({ passcodeHash: 'verifier' });
|
|
388
|
+
state.dependencies.verifyPasscode = vi.fn(async () => true);
|
|
389
|
+
const caller = makeCaller(state.dependencies);
|
|
390
|
+
const first = await caller.attachmentChunk({ ...request, passcode: 'correct-password' });
|
|
391
|
+
state.fetch.mockImplementationOnce(async tuple => {
|
|
392
|
+
state.dependencies.now = () => new Date('2026-09-21T00:01:01Z');
|
|
393
|
+
return {
|
|
394
|
+
ok: true as const,
|
|
395
|
+
value: { ...contentProjection(state.getRecord()), ...tuple },
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
await expect(
|
|
399
|
+
caller.attachmentChunk({ ...request, chunkIndex: 1, accessToken: first.accessToken })
|
|
400
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
401
|
+
await expect(
|
|
402
|
+
caller.attachmentChunk({ ...request, chunkIndex: 1, passcode: 'correct-password' })
|
|
403
|
+
).resolves.toMatchObject({ chunkIndex: 1, accessToken: expect.any(String) });
|
|
404
|
+
// Reauthentication still observes owner policy and never revives a stopped link.
|
|
405
|
+
state.setRecord({ ...state.getRecord(), status: 'stopped' });
|
|
406
|
+
await expect(
|
|
407
|
+
caller.attachmentChunk({ ...request, passcode: 'correct-password' })
|
|
408
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('allows several full downloads behind one IP without exhausting content reads', async () => {
|
|
412
|
+
const state = setup();
|
|
413
|
+
const counts = new Map<string, number>();
|
|
414
|
+
state.dependencies.enforceRateLimit = vi.fn(async ({ key, limit }) => {
|
|
415
|
+
const count = (counts.get(key) ?? 0) + 1;
|
|
416
|
+
counts.set(key, count);
|
|
417
|
+
if (count > limit) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
|
|
418
|
+
});
|
|
419
|
+
const caller = makeCaller(state.dependencies);
|
|
420
|
+
// Five recipients share one NAT address; each resolves metadata and downloads 16 chunks.
|
|
421
|
+
for (let person = 0; person < 5; person++) {
|
|
422
|
+
await caller.resolve({ id: SHARE_ID });
|
|
423
|
+
await caller.content({ id: SHARE_ID });
|
|
424
|
+
for (let chunkIndex = 0; chunkIndex < 16; chunkIndex++)
|
|
425
|
+
await caller.attachmentChunk({ ...request, chunkIndex });
|
|
426
|
+
}
|
|
427
|
+
expect(state.fetch).toHaveBeenCalledTimes(80);
|
|
428
|
+
expect(Array.from(counts.values()).sort((a, b) => a - b)).toEqual([5, 5, 80]);
|
|
429
|
+
// The attachment budget remains finite and independent of the password budget.
|
|
430
|
+
for (let index = 80; index < 240; index++) await caller.attachmentChunk(request);
|
|
431
|
+
await expect(caller.attachmentChunk(request)).rejects.toMatchObject({
|
|
432
|
+
code: 'TOO_MANY_REQUESTS',
|
|
433
|
+
});
|
|
434
|
+
expect(state.fetch).toHaveBeenCalledTimes(240);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it.each([
|
|
438
|
+
[
|
|
439
|
+
new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'private detail' }),
|
|
440
|
+
'TOO_MANY_REQUESTS',
|
|
441
|
+
],
|
|
442
|
+
[new Error('private backend detail'), 'SERVICE_UNAVAILABLE'],
|
|
443
|
+
])(
|
|
444
|
+
'keeps rate-limit and backend failures distinct and fail-closed: %#',
|
|
445
|
+
async (error, code) => {
|
|
446
|
+
const state = setup();
|
|
447
|
+
state.dependencies.enforceRateLimit = vi.fn().mockRejectedValue(error);
|
|
448
|
+
await expect(
|
|
449
|
+
makeCaller(state.dependencies).attachmentChunk(request)
|
|
450
|
+
).rejects.toMatchObject({ code });
|
|
451
|
+
expect(state.fetch).not.toHaveBeenCalled();
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
it('rejects an oversized immutable attachment response', async () => {
|
|
456
|
+
const { dependencies, fetch, getRecord } = setup();
|
|
457
|
+
fetch.mockImplementationOnce(async tuple => ({
|
|
458
|
+
ok: true as const,
|
|
459
|
+
value: {
|
|
460
|
+
...contentProjection(getRecord()),
|
|
461
|
+
...tuple,
|
|
462
|
+
envelope: { ...envelope, ct: Buffer.alloc(256 * 1024 + 17).toString('base64url') },
|
|
463
|
+
},
|
|
464
|
+
}));
|
|
465
|
+
await expect(makeCaller(dependencies).attachmentChunk(request)).rejects.toMatchObject({
|
|
466
|
+
code: 'NOT_FOUND',
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('rejects tampered grants and foreign immutable response tuples', async () => {
|
|
471
|
+
const state = setup({ passcodeHash: 'verifier' });
|
|
472
|
+
state.dependencies.verifyPasscode = vi.fn(async () => true);
|
|
473
|
+
const first = await makeCaller(state.dependencies).attachmentChunk({
|
|
474
|
+
...request,
|
|
475
|
+
passcode: 'correct-password',
|
|
476
|
+
});
|
|
477
|
+
await expect(
|
|
478
|
+
makeCaller(state.dependencies).attachmentChunk({
|
|
479
|
+
...request,
|
|
480
|
+
accessToken: `${first.accessToken}x`,
|
|
481
|
+
})
|
|
482
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
483
|
+
state.setRecord({ ...state.getRecord(), passcodeHash: null });
|
|
484
|
+
state.fetch.mockImplementationOnce(async tuple => ({
|
|
485
|
+
ok: true as const,
|
|
486
|
+
value: {
|
|
487
|
+
...contentProjection(state.getRecord()),
|
|
488
|
+
...tuple,
|
|
489
|
+
ownerProfileId: 'wrong-owner',
|
|
490
|
+
},
|
|
491
|
+
}));
|
|
492
|
+
await expect(makeCaller(state.dependencies).attachmentChunk(request)).rejects.toMatchObject(
|
|
493
|
+
{ code: 'NOT_FOUND' }
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
219
498
|
describe('public share-link resolve', () => {
|
|
220
499
|
it('answers not_found and calls nothing when configuration is disabled', async () => {
|
|
221
500
|
const dependencies = makeDependencies();
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { v4 as uuid } from 'uuid';
|
|
3
|
+
import { neogma } from '@instance';
|
|
4
|
+
import { computeShareLinkRequestHash } from '@helpers/share-link-lifecycle';
|
|
5
|
+
import {
|
|
6
|
+
abandonReservation,
|
|
7
|
+
claimRecoverableReservation,
|
|
8
|
+
finalizeReservation,
|
|
9
|
+
reserveCreate,
|
|
10
|
+
reserveReplacement,
|
|
11
|
+
} from '../src/accesslayer/share-link';
|
|
12
|
+
import { composeShareLinkPolicy } from '../src/helpers/share-link-policy/resolver';
|
|
13
|
+
import { resolveCurrentShareLinkPolicy } from '../src/helpers/share-link-policy/production';
|
|
14
|
+
import { withShareLinkRead } from '../src/accesslayer/share-link/transaction';
|
|
15
|
+
import {
|
|
16
|
+
clearLifecycleGraph,
|
|
17
|
+
contentBinding,
|
|
18
|
+
nextShareId,
|
|
19
|
+
reserveShareFixture,
|
|
20
|
+
} from './helpers/share-link-fixtures';
|
|
21
|
+
import type { ShareLinkPolicySnapshot } from '../src/helpers/share-link-policy/types';
|
|
22
|
+
import type { ReserveShareLinkResult } from '../src/accesslayer/share-link/types';
|
|
23
|
+
|
|
24
|
+
const NOW = new Date('2026-10-05T12:00:00.000Z');
|
|
25
|
+
const EXPIRY = '2026-10-25T12:00:00.000Z';
|
|
26
|
+
const NAMESPACE = 'service-policy-test';
|
|
27
|
+
|
|
28
|
+
const cases = [
|
|
29
|
+
{
|
|
30
|
+
name: 'resolved adult service without notifications',
|
|
31
|
+
dob: '1990-01-01',
|
|
32
|
+
service: true,
|
|
33
|
+
type: 'organization',
|
|
34
|
+
managed: true,
|
|
35
|
+
oldMinor: false,
|
|
36
|
+
resolved: true,
|
|
37
|
+
notify: false,
|
|
38
|
+
expected: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'resolved minor-age service with notifications',
|
|
42
|
+
dob: '2020-01-01',
|
|
43
|
+
service: true,
|
|
44
|
+
type: 'organization',
|
|
45
|
+
managed: true,
|
|
46
|
+
oldMinor: true,
|
|
47
|
+
resolved: true,
|
|
48
|
+
notify: true,
|
|
49
|
+
expected: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'unknown service snapshot',
|
|
53
|
+
dob: null,
|
|
54
|
+
service: true,
|
|
55
|
+
type: 'organization',
|
|
56
|
+
managed: true,
|
|
57
|
+
oldMinor: null,
|
|
58
|
+
resolved: false,
|
|
59
|
+
notify: true,
|
|
60
|
+
expected: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'legacy explicit child with service flag',
|
|
64
|
+
dob: '1990-01-01',
|
|
65
|
+
service: true,
|
|
66
|
+
type: 'child',
|
|
67
|
+
managed: true,
|
|
68
|
+
oldMinor: true,
|
|
69
|
+
resolved: true,
|
|
70
|
+
notify: true,
|
|
71
|
+
expected: false,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'managed personal adult',
|
|
75
|
+
dob: '1990-01-01',
|
|
76
|
+
service: false,
|
|
77
|
+
type: 'adult',
|
|
78
|
+
managed: true,
|
|
79
|
+
oldMinor: false,
|
|
80
|
+
resolved: true,
|
|
81
|
+
notify: true,
|
|
82
|
+
expected: false,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'unmanaged personal adult with resolved restriction',
|
|
86
|
+
dob: '1990-01-01',
|
|
87
|
+
service: false,
|
|
88
|
+
type: 'adult',
|
|
89
|
+
managed: false,
|
|
90
|
+
oldMinor: false,
|
|
91
|
+
resolved: true,
|
|
92
|
+
notify: true,
|
|
93
|
+
expected: false,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const createLegacyShare = async (
|
|
98
|
+
ownerProfileId: string,
|
|
99
|
+
oldMinor: boolean | null,
|
|
100
|
+
resolved: boolean
|
|
101
|
+
) => {
|
|
102
|
+
const created = await reserveShareFixture({
|
|
103
|
+
namespace: NAMESPACE,
|
|
104
|
+
ownerProfileId,
|
|
105
|
+
expiresAt: EXPIRY,
|
|
106
|
+
now: NOW,
|
|
107
|
+
});
|
|
108
|
+
const finalized = await finalizeReservation({ ...created, now: NOW });
|
|
109
|
+
await neogma.queryRunner.run(
|
|
110
|
+
`MATCH (s:ShareLink {id: $shareId})
|
|
111
|
+
SET s.minorPolicyIsMinor = $oldMinor, s.minorPolicyResolved = $resolved,
|
|
112
|
+
s.minorPolicyViewCountingEnabled = false, s.minorPolicyDefaultExpiryDays = 30`,
|
|
113
|
+
{ shareId: created.shareId, oldMinor, resolved }
|
|
114
|
+
);
|
|
115
|
+
return { ...created, committed: finalized.share };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const createOwner = async (
|
|
119
|
+
ownerProfileId: string,
|
|
120
|
+
dob: string | null,
|
|
121
|
+
service: boolean,
|
|
122
|
+
type: string,
|
|
123
|
+
managed: boolean
|
|
124
|
+
) => {
|
|
125
|
+
await neogma.queryRunner.run(
|
|
126
|
+
'CREATE (:Profile {profileId: $profileId, dob: $dob, isServiceProfile: $service, type: $type})',
|
|
127
|
+
{ profileId: ownerProfileId, dob, service, type }
|
|
128
|
+
);
|
|
129
|
+
if (managed) {
|
|
130
|
+
await neogma.queryRunner.run(
|
|
131
|
+
'MATCH (p:Profile {profileId: $profileId}) CREATE (p)-[:MANAGED_BY]->(:Profile {profileId: $managerId})',
|
|
132
|
+
{ profileId: ownerProfileId, managerId: `${ownerProfileId}-manager` }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const deleteOwner = async (ownerProfileId: string) => {
|
|
137
|
+
await neogma.queryRunner.run(
|
|
138
|
+
'MATCH (p:Profile) WHERE p.profileId IN [$profileId, $managerId] DETACH DELETE p',
|
|
139
|
+
{ profileId: ownerProfileId, managerId: `${ownerProfileId}-manager` }
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
describe('explicit service share policy refresh', () => {
|
|
144
|
+
beforeEach(clearLifecycleGraph);
|
|
145
|
+
afterAll(clearLifecycleGraph);
|
|
146
|
+
|
|
147
|
+
it.each(cases)('$name preserves expiry and applies only eligible tracking', async row => {
|
|
148
|
+
const ownerProfileId = `service-owner-${uuid()}`;
|
|
149
|
+
await createOwner(ownerProfileId, row.dob, row.service, row.type, row.managed);
|
|
150
|
+
try {
|
|
151
|
+
const created = await createLegacyShare(ownerProfileId, row.oldMinor, row.resolved);
|
|
152
|
+
const policy = await withShareLinkRead(tx =>
|
|
153
|
+
resolveCurrentShareLinkPolicy(tx, ownerProfileId, NOW)
|
|
154
|
+
);
|
|
155
|
+
const request = {
|
|
156
|
+
namespace: NAMESPACE,
|
|
157
|
+
ownerProfileId,
|
|
158
|
+
shareId: created.shareId,
|
|
159
|
+
expectedVersion: created.committed.version,
|
|
160
|
+
clientRequestId: uuid(),
|
|
161
|
+
requestHash: 'explicit-service-edit',
|
|
162
|
+
title: 'Updated title',
|
|
163
|
+
notifyOnView: row.notify,
|
|
164
|
+
leaseOwner: 'owner-edit',
|
|
165
|
+
policy,
|
|
166
|
+
now: NOW,
|
|
167
|
+
};
|
|
168
|
+
const reserved = await reserveReplacement(request);
|
|
169
|
+
if (reserved.outcome !== 'reserved')
|
|
170
|
+
throw new Error('expected explicit edit reservation');
|
|
171
|
+
const finalized = await finalizeReservation({
|
|
172
|
+
...reserved.reservation,
|
|
173
|
+
resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
|
|
174
|
+
now: NOW,
|
|
175
|
+
});
|
|
176
|
+
expect(finalized.share.minorPolicyViewCountingEnabled).toBe(row.expected);
|
|
177
|
+
expect(finalized.share.notifyOnView).toBe(row.expected && row.notify);
|
|
178
|
+
expect(finalized.share.expiresAt).toBe(EXPIRY);
|
|
179
|
+
expect(finalized.share.minorPolicyDefaultExpiryDays).toBe(30);
|
|
180
|
+
const replayed = await reserveReplacement({
|
|
181
|
+
...request,
|
|
182
|
+
policy: composeShareLinkPolicy('adult', false, true),
|
|
183
|
+
});
|
|
184
|
+
expect(replayed.outcome).toBe('already_committed');
|
|
185
|
+
if (replayed.outcome === 'already_committed') {
|
|
186
|
+
expect(replayed.current.minorPolicyViewCountingEnabled).toBe(row.expected);
|
|
187
|
+
expect(replayed.current.expiresAt).toBe(EXPIRY);
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
await deleteOwner(ownerProfileId);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it.each([
|
|
195
|
+
'replay',
|
|
196
|
+
'recovery',
|
|
197
|
+
'expired-replay',
|
|
198
|
+
'abandoned-replay',
|
|
199
|
+
'legacy-expired-replay',
|
|
200
|
+
'legacy-abandoned-replay',
|
|
201
|
+
] as const)(
|
|
202
|
+
'keeps a restricted reservation restricted during %s even if the owner is now a service',
|
|
203
|
+
async mode => {
|
|
204
|
+
const ownerProfileId = `service-owner-${uuid()}`;
|
|
205
|
+
await createOwner(ownerProfileId, '2020-01-01', false, 'adult', true);
|
|
206
|
+
try {
|
|
207
|
+
const created = await createLegacyShare(ownerProfileId, true, true);
|
|
208
|
+
const request = {
|
|
209
|
+
namespace: NAMESPACE,
|
|
210
|
+
ownerProfileId,
|
|
211
|
+
shareId: created.shareId,
|
|
212
|
+
expectedVersion: created.committed.version,
|
|
213
|
+
clientRequestId: uuid(),
|
|
214
|
+
requestHash: computeShareLinkRequestHash('update', {
|
|
215
|
+
id: created.shareId,
|
|
216
|
+
notifyOnView: true,
|
|
217
|
+
}),
|
|
218
|
+
notifyOnView: true,
|
|
219
|
+
leaseOwner: 'owner-edit',
|
|
220
|
+
leaseMs: 1000,
|
|
221
|
+
policy: composeShareLinkPolicy('minor', true),
|
|
222
|
+
now: NOW,
|
|
223
|
+
};
|
|
224
|
+
const reserved = await reserveReplacement(request);
|
|
225
|
+
if (reserved.outcome !== 'reserved')
|
|
226
|
+
throw new Error('expected restricted reservation');
|
|
227
|
+
await neogma.queryRunner.run(
|
|
228
|
+
'MATCH (p:Profile {profileId: $profileId}) SET p.isServiceProfile = true, p.type = $type',
|
|
229
|
+
{ profileId: ownerProfileId, type: 'organization' }
|
|
230
|
+
);
|
|
231
|
+
if (mode.startsWith('legacy-')) {
|
|
232
|
+
await neogma.queryRunner.run(
|
|
233
|
+
`MATCH (o:ShareLinkOperation {operationId: $operationId})
|
|
234
|
+
REMOVE o.policyIsMinor, o.policyResolved,
|
|
235
|
+
o.policyDefaultExpiryDays, o.policyViewCountingEnabled`,
|
|
236
|
+
{ operationId: reserved.reservation.operationId }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const finalizeAt = mode === 'replay' ? NOW : new Date(NOW.getTime() + 2000);
|
|
240
|
+
if (mode.endsWith('abandoned-replay')) {
|
|
241
|
+
expect(
|
|
242
|
+
(await abandonReservation({ ...reserved.reservation, now: finalizeAt }))
|
|
243
|
+
.outcome
|
|
244
|
+
).toBe('abandoned');
|
|
245
|
+
}
|
|
246
|
+
const resumed =
|
|
247
|
+
mode === 'recovery'
|
|
248
|
+
? await claimRecoverableReservation({
|
|
249
|
+
namespace: NAMESPACE,
|
|
250
|
+
ownerProfileId,
|
|
251
|
+
shareId: created.shareId,
|
|
252
|
+
operationId: reserved.reservation.operationId,
|
|
253
|
+
leaseOwner: 'recovery-worker',
|
|
254
|
+
now: finalizeAt,
|
|
255
|
+
})
|
|
256
|
+
: await reserveReplacement({
|
|
257
|
+
...request,
|
|
258
|
+
policy: composeShareLinkPolicy('minor', true, true),
|
|
259
|
+
now: finalizeAt,
|
|
260
|
+
});
|
|
261
|
+
if (resumed.outcome !== 'claimed' && resumed.outcome !== 'reserved')
|
|
262
|
+
throw new Error('expected existing reservation');
|
|
263
|
+
const finalized = await finalizeReservation({
|
|
264
|
+
...resumed.reservation,
|
|
265
|
+
resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
|
|
266
|
+
now: finalizeAt,
|
|
267
|
+
});
|
|
268
|
+
expect(finalized.share.minorPolicyViewCountingEnabled).toBe(false);
|
|
269
|
+
expect(finalized.share.notifyOnView).toBe(false);
|
|
270
|
+
expect(finalized.share.expiresAt).toBe(EXPIRY);
|
|
271
|
+
|
|
272
|
+
// A new explicit edit may refresh the service policy; only retries
|
|
273
|
+
// of the original logical request must retain its restriction.
|
|
274
|
+
const fresh = await reserveReplacement({
|
|
275
|
+
...request,
|
|
276
|
+
expectedVersion: finalized.share.version,
|
|
277
|
+
clientRequestId: uuid(),
|
|
278
|
+
policy: composeShareLinkPolicy('minor', true, true),
|
|
279
|
+
now: finalizeAt,
|
|
280
|
+
});
|
|
281
|
+
if (fresh.outcome !== 'reserved') throw new Error('expected fresh reservation');
|
|
282
|
+
const refreshed = await finalizeReservation({
|
|
283
|
+
...fresh.reservation,
|
|
284
|
+
resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
|
|
285
|
+
now: finalizeAt,
|
|
286
|
+
});
|
|
287
|
+
expect(refreshed.share.minorPolicyViewCountingEnabled).toBe(true);
|
|
288
|
+
expect(refreshed.share.notifyOnView).toBe(true);
|
|
289
|
+
expect(refreshed.share.expiresAt).toBe(EXPIRY);
|
|
290
|
+
} finally {
|
|
291
|
+
await deleteOwner(ownerProfileId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
it.each(['create', 'update'] as const)(
|
|
297
|
+
'retains a tightened %s policy across expired replacement and subsequent cleanup',
|
|
298
|
+
async kind => {
|
|
299
|
+
const ownerProfileId = `service-owner-${uuid()}`;
|
|
300
|
+
await createOwner(ownerProfileId, '1990-01-01', true, 'organization', true);
|
|
301
|
+
try {
|
|
302
|
+
const created =
|
|
303
|
+
kind === 'update' ? await createLegacyShare(ownerProfileId, false, true) : null;
|
|
304
|
+
const shareId = created?.shareId ?? nextShareId();
|
|
305
|
+
const request = {
|
|
306
|
+
namespace: NAMESPACE,
|
|
307
|
+
ownerProfileId,
|
|
308
|
+
shareId,
|
|
309
|
+
clientRequestId: uuid(),
|
|
310
|
+
requestHash: computeShareLinkRequestHash(kind, {
|
|
311
|
+
id: shareId,
|
|
312
|
+
notifyOnView: true,
|
|
313
|
+
}),
|
|
314
|
+
title: 'Repeated retry',
|
|
315
|
+
expiresAt: EXPIRY,
|
|
316
|
+
notifyOnView: true,
|
|
317
|
+
leaseOwner: 'owner-edit',
|
|
318
|
+
leaseMs: 1000,
|
|
319
|
+
};
|
|
320
|
+
const reserve = (
|
|
321
|
+
policy: ShareLinkPolicySnapshot,
|
|
322
|
+
now: Date
|
|
323
|
+
): Promise<ReserveShareLinkResult> =>
|
|
324
|
+
kind === 'create'
|
|
325
|
+
? reserveCreate({
|
|
326
|
+
...request,
|
|
327
|
+
policy,
|
|
328
|
+
now,
|
|
329
|
+
selectedCount: 1,
|
|
330
|
+
content: contentBinding(shareId),
|
|
331
|
+
})
|
|
332
|
+
: reserveReplacement({
|
|
333
|
+
...request,
|
|
334
|
+
policy,
|
|
335
|
+
now,
|
|
336
|
+
expectedVersion: created?.committed.version ?? 1,
|
|
337
|
+
});
|
|
338
|
+
const permissive = composeShareLinkPolicy('adult', false, true);
|
|
339
|
+
const restrictive = composeShareLinkPolicy('minor', true);
|
|
340
|
+
const initial = await reserve(permissive, NOW);
|
|
341
|
+
if (initial.outcome !== 'reserved') throw new Error('expected initial reservation');
|
|
342
|
+
const retryAt = new Date(NOW.getTime() + 2000);
|
|
343
|
+
const tightened = await reserve(restrictive, retryAt);
|
|
344
|
+
if (tightened.outcome !== 'reserved')
|
|
345
|
+
throw new Error('expected replacement reservation');
|
|
346
|
+
expect(tightened.reservation.operationId).not.toBe(initial.reservation.operationId);
|
|
347
|
+
expect(tightened.reservation.policy).toEqual(restrictive);
|
|
348
|
+
expect(
|
|
349
|
+
(await abandonReservation({ ...tightened.reservation, now: retryAt })).outcome
|
|
350
|
+
).toBe('abandoned');
|
|
351
|
+
const retried = await reserve(permissive, retryAt);
|
|
352
|
+
if (retried.outcome !== 'reserved')
|
|
353
|
+
throw new Error('expected cleanup retry reservation');
|
|
354
|
+
expect(retried.reservation.policy).toEqual(restrictive);
|
|
355
|
+
const finalized = await finalizeReservation({
|
|
356
|
+
...retried.reservation,
|
|
357
|
+
resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
|
|
358
|
+
now: retryAt,
|
|
359
|
+
});
|
|
360
|
+
expect(finalized.share.minorPolicyViewCountingEnabled).toBe(false);
|
|
361
|
+
expect(finalized.share.notifyOnView).toBe(false);
|
|
362
|
+
expect(finalized.share.expiresAt).toBe(EXPIRY);
|
|
363
|
+
} finally {
|
|
364
|
+
await deleteOwner(ownerProfileId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
it('tightens a stale permissive service reservation when the locked owner is explicitly a child', async () => {
|
|
370
|
+
const ownerProfileId = `service-owner-${uuid()}`;
|
|
371
|
+
await createOwner(ownerProfileId, '1990-01-01', true, 'organization', true);
|
|
372
|
+
try {
|
|
373
|
+
const created = await createLegacyShare(ownerProfileId, false, true);
|
|
374
|
+
const reserved = await reserveReplacement({
|
|
375
|
+
namespace: NAMESPACE,
|
|
376
|
+
ownerProfileId,
|
|
377
|
+
shareId: created.shareId,
|
|
378
|
+
expectedVersion: created.committed.version,
|
|
379
|
+
clientRequestId: uuid(),
|
|
380
|
+
requestHash: 'stale-service-edit',
|
|
381
|
+
notifyOnView: true,
|
|
382
|
+
leaseOwner: 'owner-edit',
|
|
383
|
+
policy: composeShareLinkPolicy('adult', true, true),
|
|
384
|
+
now: NOW,
|
|
385
|
+
});
|
|
386
|
+
if (reserved.outcome !== 'reserved') throw new Error('expected reservation');
|
|
387
|
+
await neogma.queryRunner.run(
|
|
388
|
+
'MATCH (p:Profile {profileId: $profileId}) SET p.type = $type',
|
|
389
|
+
{ profileId: ownerProfileId, type: 'child' }
|
|
390
|
+
);
|
|
391
|
+
const finalized = await finalizeReservation({
|
|
392
|
+
...reserved.reservation,
|
|
393
|
+
resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
|
|
394
|
+
now: NOW,
|
|
395
|
+
});
|
|
396
|
+
expect(finalized.share.minorPolicyViewCountingEnabled).toBe(false);
|
|
397
|
+
expect(finalized.share.notifyOnView).toBe(false);
|
|
398
|
+
expect(finalized.share.minorPolicyIsMinor).toBe(true);
|
|
399
|
+
} finally {
|
|
400
|
+
await deleteOwner(ownerProfileId);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|