@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,1113 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createMaintenancePassBudget,
|
|
8
|
+
createShareLinkMaintenanceRuntime,
|
|
9
|
+
createShareLinkMaintenanceScheduler,
|
|
10
|
+
resolveShareLinkMaintenanceConfig,
|
|
11
|
+
runShareLinkMaintenancePass,
|
|
12
|
+
toMaintenanceLogEvent,
|
|
13
|
+
} from '@helpers/share-link-maintenance';
|
|
14
|
+
import {
|
|
15
|
+
ShareLinkCoordinatorError,
|
|
16
|
+
runShareContentCleanupOnce,
|
|
17
|
+
runShareLinkRecoveryOnce,
|
|
18
|
+
} from '@helpers/share-link-coordinator';
|
|
19
|
+
import type {
|
|
20
|
+
CleanupRunnerDependencies,
|
|
21
|
+
RecoveryRunnerDependencies,
|
|
22
|
+
} from '@helpers/share-link-coordinator';
|
|
23
|
+
|
|
24
|
+
import { executeExplicitTransaction } from '../src/accesslayer/share-link/transaction-execution';
|
|
25
|
+
|
|
26
|
+
const NAMESPACE = 'test-namespace';
|
|
27
|
+
const OWNER = 'owner-1';
|
|
28
|
+
const SHARE_ID = 's'.repeat(22);
|
|
29
|
+
const OBJECT_REF = 'o'.repeat(43);
|
|
30
|
+
const OPERATION_ID = '11111111-1111-4111-8111-111111111111';
|
|
31
|
+
const NOW_ISO = '2026-09-20T12:00:00.000Z';
|
|
32
|
+
|
|
33
|
+
const silentLogger = { info: vi.fn(), error: vi.fn() };
|
|
34
|
+
|
|
35
|
+
const enabledEnv = (overrides: Record<string, unknown> = {}) => ({
|
|
36
|
+
SHARE_LINK_MAINTENANCE_NAMESPACE: NAMESPACE,
|
|
37
|
+
SHARE_LINK_MAINTENANCE_ORIGIN: 'https://learncloud.example',
|
|
38
|
+
SHARE_LINK_MAINTENANCE_AUDIENCE: 'did:web:learncloud.example',
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const enabledConfig = (overrides: Record<string, unknown> = {}) => {
|
|
43
|
+
const resolved = resolveShareLinkMaintenanceConfig(enabledEnv(overrides));
|
|
44
|
+
|
|
45
|
+
if (resolved.status !== 'enabled') throw new Error('expected an enabled config');
|
|
46
|
+
|
|
47
|
+
return resolved.config;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const cleanupJob = (overrides: Record<string, unknown> = {}) => ({
|
|
51
|
+
objectRef: OBJECT_REF,
|
|
52
|
+
operationId: OPERATION_ID,
|
|
53
|
+
namespace: NAMESPACE,
|
|
54
|
+
ownerProfileId: OWNER,
|
|
55
|
+
shareId: SHARE_ID,
|
|
56
|
+
contentVersion: 1,
|
|
57
|
+
reason: 'stopped' as const,
|
|
58
|
+
status: 'claimed' as const,
|
|
59
|
+
attempts: 0,
|
|
60
|
+
nextAttemptAt: NOW_ISO,
|
|
61
|
+
claimToken: 'token-1',
|
|
62
|
+
claimedBy: 'cleaner',
|
|
63
|
+
claimExpiresAt: '2099-01-01T00:00:00.000Z',
|
|
64
|
+
lastError: null,
|
|
65
|
+
createdAt: NOW_ISO,
|
|
66
|
+
updatedAt: NOW_ISO,
|
|
67
|
+
completedAt: null,
|
|
68
|
+
...overrides,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const makeCleanupRepository = (
|
|
72
|
+
jobs: ReturnType<typeof cleanupJob>[]
|
|
73
|
+
): CleanupRunnerDependencies['repository'] =>
|
|
74
|
+
({
|
|
75
|
+
claimCleanupJobs: vi.fn().mockResolvedValue({ claimToken: 'token-1', jobs }),
|
|
76
|
+
completeCleanupJob: vi.fn().mockResolvedValue({
|
|
77
|
+
outcome: 'completed',
|
|
78
|
+
job: cleanupJob({ status: 'completed' }),
|
|
79
|
+
}),
|
|
80
|
+
}) as unknown as CleanupRunnerDependencies['repository'];
|
|
81
|
+
|
|
82
|
+
const makeDeleteClient = (
|
|
83
|
+
result: unknown = { ok: true, value: {} }
|
|
84
|
+
): CleanupRunnerDependencies['client'] =>
|
|
85
|
+
({
|
|
86
|
+
delete: vi.fn().mockResolvedValue(result),
|
|
87
|
+
}) as unknown as CleanupRunnerDependencies['client'];
|
|
88
|
+
|
|
89
|
+
const makeRecoveryRepository = (): RecoveryRunnerDependencies['repository'] =>
|
|
90
|
+
({
|
|
91
|
+
discoverRecoverableReservations: vi.fn().mockResolvedValue({ keys: [] }),
|
|
92
|
+
readShareLinkRecoveryTarget: vi.fn(),
|
|
93
|
+
claimRecoverableReservation: vi.fn(),
|
|
94
|
+
abandonRecoveredReservation: vi.fn(),
|
|
95
|
+
finalizeReservation: vi.fn(),
|
|
96
|
+
}) as unknown as RecoveryRunnerDependencies['repository'];
|
|
97
|
+
|
|
98
|
+
describe('share-link maintenance config', () => {
|
|
99
|
+
it('does no setup without service wiring', () => {
|
|
100
|
+
expect(resolveShareLinkMaintenanceConfig({})).toEqual({ status: 'disabled' });
|
|
101
|
+
expect(
|
|
102
|
+
resolveShareLinkMaintenanceConfig({
|
|
103
|
+
SHARE_LINK_MAINTENANCE_NAMESPACE: '',
|
|
104
|
+
SHARE_LINK_MAINTENANCE_ORIGIN: '',
|
|
105
|
+
SHARE_LINK_MAINTENANCE_AUDIENCE: '',
|
|
106
|
+
})
|
|
107
|
+
).toEqual({ status: 'disabled' });
|
|
108
|
+
});
|
|
109
|
+
it('runs automatically with valid configuration despite retired rollout flags', () => {
|
|
110
|
+
expect(
|
|
111
|
+
resolveShareLinkMaintenanceConfig(
|
|
112
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_ENABLED: 'false' })
|
|
113
|
+
).status
|
|
114
|
+
).toBe('enabled');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects a malformed boolean instead of coercing it', () => {
|
|
118
|
+
expect(
|
|
119
|
+
resolveShareLinkMaintenanceConfig(
|
|
120
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_ALLOW_INSECURE_LOOPBACK: 'yes' })
|
|
121
|
+
)
|
|
122
|
+
).toEqual({
|
|
123
|
+
status: 'invalid',
|
|
124
|
+
category: 'share_link_maintenance_configuration_invalid',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('rejects missing or malformed required values without echoing them', () => {
|
|
129
|
+
const invalidCases: Record<string, unknown>[] = [
|
|
130
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_NAMESPACE: 'bad:namespace' }),
|
|
131
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_NAMESPACE: '' }),
|
|
132
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_ORIGIN: 'http://example.com' }),
|
|
133
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_ORIGIN: 'not a url' }),
|
|
134
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_AUDIENCE: 'https://learncloud.example' }),
|
|
135
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_CLEANUP_LIMIT: 'lots' }),
|
|
136
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_CLEANUP_LIMIT: '0' }),
|
|
137
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_CLEANUP_LIMIT: '101' }),
|
|
138
|
+
enabledEnv({
|
|
139
|
+
SHARE_LINK_MAINTENANCE_BUDGET_MS: '1000',
|
|
140
|
+
SHARE_LINK_MAINTENANCE_FINALIZE_RESERVE_MS: '2000',
|
|
141
|
+
}),
|
|
142
|
+
enabledEnv({ SHARE_LINK_MAINTENANCE_ALLOW_INSECURE_LOOPBACK: '1' }),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
for (const raw of invalidCases) {
|
|
146
|
+
const resolved = resolveShareLinkMaintenanceConfig(raw);
|
|
147
|
+
expect(resolved).toEqual({
|
|
148
|
+
status: 'invalid',
|
|
149
|
+
category: 'share_link_maintenance_configuration_invalid',
|
|
150
|
+
});
|
|
151
|
+
expect(JSON.stringify(resolved)).not.toContain('bad:namespace');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('accepts a loopback origin only with an explicit insecure opt-in', () => {
|
|
156
|
+
expect(
|
|
157
|
+
resolveShareLinkMaintenanceConfig(
|
|
158
|
+
enabledEnv({
|
|
159
|
+
SHARE_LINK_MAINTENANCE_ORIGIN: 'http://localhost:4000',
|
|
160
|
+
SHARE_LINK_MAINTENANCE_ALLOW_INSECURE_LOOPBACK: 'true',
|
|
161
|
+
})
|
|
162
|
+
).status
|
|
163
|
+
).toBe('enabled');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('applies bounded defaults to a valid enabled configuration', () => {
|
|
167
|
+
const config = enabledConfig();
|
|
168
|
+
|
|
169
|
+
expect(config).toMatchObject({
|
|
170
|
+
namespace: NAMESPACE,
|
|
171
|
+
origin: 'https://learncloud.example',
|
|
172
|
+
audience: 'did:web:learncloud.example',
|
|
173
|
+
claimant: 'brain-share-link-maintenance',
|
|
174
|
+
recoveryLimit: 25,
|
|
175
|
+
cleanupLimit: 25,
|
|
176
|
+
receiptPruneLimit: 200,
|
|
177
|
+
leaseMs: 120_000,
|
|
178
|
+
claimMs: 300_000,
|
|
179
|
+
budgetMs: 60_000,
|
|
180
|
+
intervalMs: 300_000,
|
|
181
|
+
requestTimeoutMs: 10_000,
|
|
182
|
+
finalizeReserveMs: 5_000,
|
|
183
|
+
graphTimeoutMs: 15_000,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('share-link maintenance runtime', () => {
|
|
189
|
+
it('stays inert when disabled and never constructs the client or repositories', async () => {
|
|
190
|
+
const createClient = vi.fn();
|
|
191
|
+
const createRepositories = vi.fn();
|
|
192
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
193
|
+
rawEnvironment: {},
|
|
194
|
+
logger: silentLogger,
|
|
195
|
+
createClient,
|
|
196
|
+
createRepositories,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const summary = await runtime.runOnce();
|
|
200
|
+
|
|
201
|
+
expect(summary.status).toBe('disabled');
|
|
202
|
+
expect(createClient).not.toHaveBeenCalled();
|
|
203
|
+
expect(createRepositories).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('returns a fixed invalid category without constructing anything', async () => {
|
|
207
|
+
const createClient = vi.fn();
|
|
208
|
+
const createRepositories = vi.fn();
|
|
209
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
210
|
+
rawEnvironment: enabledEnv({ SHARE_LINK_MAINTENANCE_NAMESPACE: 'bad:namespace' }),
|
|
211
|
+
logger: silentLogger,
|
|
212
|
+
createClient,
|
|
213
|
+
createRepositories,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const summary = await runtime.runOnce();
|
|
217
|
+
|
|
218
|
+
expect(summary.status).toBe('invalid_configuration');
|
|
219
|
+
expect(summary.categories).toEqual({
|
|
220
|
+
share_link_maintenance_configuration_invalid: 1,
|
|
221
|
+
});
|
|
222
|
+
expect(createClient).not.toHaveBeenCalled();
|
|
223
|
+
expect(createRepositories).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('share-link maintenance pass', () => {
|
|
228
|
+
const runnerDependencies = (
|
|
229
|
+
config: ReturnType<typeof enabledConfig>,
|
|
230
|
+
overrides: Partial<Parameters<typeof runShareLinkMaintenancePass>[0]> = {}
|
|
231
|
+
) => ({
|
|
232
|
+
config,
|
|
233
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
234
|
+
cleanupRepository: makeCleanupRepository([]),
|
|
235
|
+
pruneReceipts: vi.fn().mockResolvedValue(0),
|
|
236
|
+
client: makeDeleteClient(),
|
|
237
|
+
logger: silentLogger,
|
|
238
|
+
...overrides,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('does no work at all when the budget is already exhausted', async () => {
|
|
242
|
+
const config = enabledConfig({
|
|
243
|
+
SHARE_LINK_MAINTENANCE_BUDGET_MS: '1000',
|
|
244
|
+
SHARE_LINK_MAINTENANCE_FINALIZE_RESERVE_MS: '100',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Direct construction: this asserts the runner stops before any claim when
|
|
248
|
+
// its remaining budget is below the finalization reserve.
|
|
249
|
+
const dependencies = runnerDependencies({ ...config, finalizeReserveMs: 2_000 });
|
|
250
|
+
const summary = await runShareLinkMaintenancePass(dependencies);
|
|
251
|
+
|
|
252
|
+
expect(summary.status).toBe('deadline_exhausted');
|
|
253
|
+
expect(
|
|
254
|
+
dependencies.recoveryRepository.discoverRecoverableReservations
|
|
255
|
+
).not.toHaveBeenCalled();
|
|
256
|
+
expect(dependencies.cleanupRepository.claimCleanupJobs).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('caps the budget by the remaining Lambda time', async () => {
|
|
260
|
+
const config = enabledConfig();
|
|
261
|
+
const dependencies = runnerDependencies(config, { remainingTimeMs: () => 2_500 });
|
|
262
|
+
|
|
263
|
+
const summary = await runShareLinkMaintenancePass(dependencies);
|
|
264
|
+
|
|
265
|
+
expect(summary.budgetMs).toBe(2_500);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('runs recovery and cleanup with explicit namespace and independent limits', async () => {
|
|
269
|
+
const config = enabledConfig({
|
|
270
|
+
SHARE_LINK_MAINTENANCE_RECOVERY_LIMIT: '3',
|
|
271
|
+
SHARE_LINK_MAINTENANCE_CLEANUP_LIMIT: '7',
|
|
272
|
+
});
|
|
273
|
+
const recoveryRepository = makeRecoveryRepository();
|
|
274
|
+
const cleanupRepository = makeCleanupRepository([]);
|
|
275
|
+
const dependencies = runnerDependencies(config, { recoveryRepository, cleanupRepository });
|
|
276
|
+
|
|
277
|
+
const summary = await runShareLinkMaintenancePass(dependencies);
|
|
278
|
+
|
|
279
|
+
expect(summary.status).toBe('completed');
|
|
280
|
+
expect(recoveryRepository.discoverRecoverableReservations).toHaveBeenCalledWith(
|
|
281
|
+
expect.objectContaining({ namespace: NAMESPACE, limit: 3 })
|
|
282
|
+
);
|
|
283
|
+
expect(cleanupRepository.claimCleanupJobs).toHaveBeenCalledWith(
|
|
284
|
+
expect.objectContaining({ namespace: NAMESPACE })
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('invokes the namespace-scoped bounded receipt prune with no-inline-retry semantics', async () => {
|
|
289
|
+
const config = enabledConfig({
|
|
290
|
+
SHARE_LINK_MAINTENANCE_RECEIPT_PRUNE_LIMIT: '7',
|
|
291
|
+
SHARE_LINK_MAINTENANCE_GRAPH_TIMEOUT_MS: '2000',
|
|
292
|
+
});
|
|
293
|
+
const pruneReceipts = vi.fn().mockResolvedValue(4);
|
|
294
|
+
const now = new Date(NOW_ISO);
|
|
295
|
+
|
|
296
|
+
const summary = await runShareLinkMaintenancePass(
|
|
297
|
+
runnerDependencies(config, { pruneReceipts, now: () => now })
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(pruneReceipts).toHaveBeenCalledWith({
|
|
301
|
+
namespace: NAMESPACE,
|
|
302
|
+
limit: 7,
|
|
303
|
+
now,
|
|
304
|
+
transaction: { timeoutMs: 2_000, noInlineRetry: true },
|
|
305
|
+
});
|
|
306
|
+
expect(summary.receiptPrune).toEqual({ pruned: 4 });
|
|
307
|
+
expect(summary.categories).toEqual({});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('does not start receipt pruning when the graph budget is exhausted', async () => {
|
|
311
|
+
let clock = 0;
|
|
312
|
+
// budget 1000ms, reserve 100ms. Recovery discovery needs 500ms and cleanup
|
|
313
|
+
// needs request(100)+2*graph(500)=1100ms, so consume the budget after
|
|
314
|
+
// recovery before admission.
|
|
315
|
+
const config = enabledConfig({
|
|
316
|
+
SHARE_LINK_MAINTENANCE_BUDGET_MS: '1000',
|
|
317
|
+
SHARE_LINK_MAINTENANCE_FINALIZE_RESERVE_MS: '100',
|
|
318
|
+
SHARE_LINK_MAINTENANCE_GRAPH_TIMEOUT_MS: '500',
|
|
319
|
+
SHARE_LINK_MAINTENANCE_REQUEST_TIMEOUT_MS: '100',
|
|
320
|
+
});
|
|
321
|
+
const pruneReceipts = vi.fn().mockResolvedValue(0);
|
|
322
|
+
const dependencies = runnerDependencies(config, {
|
|
323
|
+
pruneReceipts,
|
|
324
|
+
monotonicNow: () => clock,
|
|
325
|
+
remainingTimeMs: undefined,
|
|
326
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
327
|
+
});
|
|
328
|
+
// Force the clock past the budget as soon as recovery is admitted.
|
|
329
|
+
const originalDiscover = dependencies.recoveryRepository
|
|
330
|
+
.discoverRecoverableReservations as ReturnType<typeof vi.fn>;
|
|
331
|
+
originalDiscover.mockImplementationOnce(async () => {
|
|
332
|
+
clock = 10_000;
|
|
333
|
+
return { keys: [] };
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const summary = await runShareLinkMaintenancePass(dependencies);
|
|
337
|
+
|
|
338
|
+
expect(pruneReceipts).not.toHaveBeenCalled();
|
|
339
|
+
expect(summary.status).toBe('deadline_exhausted');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('folds a transient receipt-prune failure into a fixed category and succeeds next pass', async () => {
|
|
343
|
+
const config = enabledConfig();
|
|
344
|
+
const pruneReceipts = vi
|
|
345
|
+
.fn()
|
|
346
|
+
.mockRejectedValueOnce(new Error('graph down'))
|
|
347
|
+
.mockResolvedValue(2);
|
|
348
|
+
|
|
349
|
+
const first = await runShareLinkMaintenancePass(
|
|
350
|
+
runnerDependencies(config, { pruneReceipts })
|
|
351
|
+
);
|
|
352
|
+
expect(first.status).toBe('failed');
|
|
353
|
+
expect(first.categories.share_link_maintenance_receipt_prune_failed).toBe(1);
|
|
354
|
+
expect(first.receiptPrune).toBeNull();
|
|
355
|
+
|
|
356
|
+
const second = await runShareLinkMaintenancePass(
|
|
357
|
+
runnerDependencies(config, { pruneReceipts })
|
|
358
|
+
);
|
|
359
|
+
expect(second.status).toBe('completed');
|
|
360
|
+
expect(second.receiptPrune).toEqual({ pruned: 2 });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('keeps a throwing logger from rejecting the pass', async () => {
|
|
364
|
+
const config = enabledConfig();
|
|
365
|
+
const logger = {
|
|
366
|
+
info: () => {
|
|
367
|
+
throw new Error('logger down');
|
|
368
|
+
},
|
|
369
|
+
error: () => {
|
|
370
|
+
throw new Error('logger down');
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
await expect(
|
|
375
|
+
runShareLinkMaintenancePass(runnerDependencies(config, { logger }))
|
|
376
|
+
).resolves.toMatchObject({ status: 'completed' });
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('bounded cleanup runner scope and budget', () => {
|
|
381
|
+
it('passes the explicit namespace and omits production time', async () => {
|
|
382
|
+
const repository = makeCleanupRepository([]);
|
|
383
|
+
|
|
384
|
+
await runShareContentCleanupOnce({
|
|
385
|
+
repository,
|
|
386
|
+
client: makeDeleteClient(),
|
|
387
|
+
claimant: 'cleaner',
|
|
388
|
+
namespace: NAMESPACE,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(repository.claimCleanupJobs).toHaveBeenCalledWith(
|
|
392
|
+
expect.objectContaining({ namespace: NAMESPACE, now: undefined })
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('rejects a malformed maintenance namespace before any graph call', async () => {
|
|
397
|
+
const repository = makeCleanupRepository([]);
|
|
398
|
+
|
|
399
|
+
await expect(
|
|
400
|
+
runShareContentCleanupOnce({
|
|
401
|
+
repository,
|
|
402
|
+
client: makeDeleteClient(),
|
|
403
|
+
claimant: 'cleaner',
|
|
404
|
+
namespace: 'bad:namespace',
|
|
405
|
+
})
|
|
406
|
+
).rejects.toBeInstanceOf(ShareLinkCoordinatorError);
|
|
407
|
+
|
|
408
|
+
expect(repository.claimCleanupJobs).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('stops before claiming again once the budget is exhausted', async () => {
|
|
412
|
+
const repository = makeCleanupRepository([cleanupJob()]);
|
|
413
|
+
const budget = {
|
|
414
|
+
remainingMs: () => 500,
|
|
415
|
+
exhausted: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const summary = await runShareContentCleanupOnce({
|
|
419
|
+
repository,
|
|
420
|
+
client: makeDeleteClient(),
|
|
421
|
+
claimant: 'cleaner',
|
|
422
|
+
namespace: NAMESPACE,
|
|
423
|
+
limit: 3,
|
|
424
|
+
claimBatchSize: 1,
|
|
425
|
+
budget,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(summary.claimed).toBe(1);
|
|
429
|
+
expect(summary.completed).toBe(1);
|
|
430
|
+
expect(repository.claimCleanupJobs).toHaveBeenCalledTimes(1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('reports a sanitized aggregate for a deterministic delete failure', async () => {
|
|
434
|
+
const repository = makeCleanupRepository([cleanupJob()]);
|
|
435
|
+
const client = makeDeleteClient({ ok: false, error: 'UNAUTHORIZED' });
|
|
436
|
+
|
|
437
|
+
const summary = await runShareContentCleanupOnce({
|
|
438
|
+
repository,
|
|
439
|
+
client,
|
|
440
|
+
claimant: 'cleaner',
|
|
441
|
+
namespace: NAMESPACE,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(summary).toMatchObject({ claimed: 1, skipped: 1, completed: 0 });
|
|
445
|
+
expect(summary.categories).toEqual({ UNAUTHORIZED: 1 });
|
|
446
|
+
const serialized = JSON.stringify(summary);
|
|
447
|
+
expect(serialized).not.toContain(OBJECT_REF);
|
|
448
|
+
expect(serialized).not.toContain(OWNER);
|
|
449
|
+
expect(serialized).not.toContain(SHARE_ID);
|
|
450
|
+
expect(repository.completeCleanupJob).not.toHaveBeenCalled();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('returns a transient delete to the queue without completing it', async () => {
|
|
454
|
+
const repository = makeCleanupRepository([cleanupJob()]);
|
|
455
|
+
(repository.completeCleanupJob as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
456
|
+
outcome: 'retry',
|
|
457
|
+
job: cleanupJob({ status: 'queued', attempts: 1 }),
|
|
458
|
+
});
|
|
459
|
+
const client = makeDeleteClient({ ok: false, error: 'TIMEOUT' });
|
|
460
|
+
|
|
461
|
+
const summary = await runShareContentCleanupOnce({
|
|
462
|
+
repository,
|
|
463
|
+
client,
|
|
464
|
+
claimant: 'cleaner',
|
|
465
|
+
namespace: NAMESPACE,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
expect(summary).toMatchObject({ claimed: 1, retried: 1, completed: 0, skipped: 0 });
|
|
469
|
+
expect(repository.completeCleanupJob).toHaveBeenCalledWith(
|
|
470
|
+
expect.objectContaining({ outcome: 'retry' })
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe('share-link maintenance scheduler', () => {
|
|
476
|
+
it('never runs two passes concurrently', async () => {
|
|
477
|
+
let release!: () => void;
|
|
478
|
+
const gate = new Promise<void>(resolve => {
|
|
479
|
+
release = resolve;
|
|
480
|
+
});
|
|
481
|
+
const runOnce = vi.fn().mockImplementation(() => gate);
|
|
482
|
+
const scheduler = createShareLinkMaintenanceScheduler({
|
|
483
|
+
runOnce,
|
|
484
|
+
intervalMs: 1_000,
|
|
485
|
+
logger: silentLogger,
|
|
486
|
+
setTimeoutFn: () => 1,
|
|
487
|
+
clearTimeoutFn: () => undefined,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const first = scheduler.trigger();
|
|
491
|
+
const second = scheduler.trigger();
|
|
492
|
+
|
|
493
|
+
expect(scheduler.isRunning()).toBe(true);
|
|
494
|
+
expect(runOnce).toHaveBeenCalledTimes(1);
|
|
495
|
+
|
|
496
|
+
release();
|
|
497
|
+
await Promise.all([first, second]);
|
|
498
|
+
|
|
499
|
+
expect(scheduler.isRunning()).toBe(false);
|
|
500
|
+
expect(runOnce).toHaveBeenCalledTimes(1);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('drains an in-flight pass on stop and schedules nothing further', async () => {
|
|
504
|
+
let release!: () => void;
|
|
505
|
+
const gate = new Promise<void>(resolve => {
|
|
506
|
+
release = resolve;
|
|
507
|
+
});
|
|
508
|
+
const runOnce = vi.fn().mockImplementation(() => gate);
|
|
509
|
+
const clearTimeoutFn = vi.fn();
|
|
510
|
+
const scheduler = createShareLinkMaintenanceScheduler({
|
|
511
|
+
runOnce,
|
|
512
|
+
intervalMs: 1_000,
|
|
513
|
+
logger: silentLogger,
|
|
514
|
+
setTimeoutFn: () => 7,
|
|
515
|
+
clearTimeoutFn,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
scheduler.start();
|
|
519
|
+
const inFlight = scheduler.trigger();
|
|
520
|
+
const stopping = scheduler.stop();
|
|
521
|
+
|
|
522
|
+
let stopped = false;
|
|
523
|
+
void stopping.then(() => {
|
|
524
|
+
stopped = true;
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await Promise.resolve();
|
|
528
|
+
expect(stopped).toBe(false);
|
|
529
|
+
|
|
530
|
+
release();
|
|
531
|
+
await stopping;
|
|
532
|
+
|
|
533
|
+
expect(stopped).toBe(true);
|
|
534
|
+
expect(clearTimeoutFn).toHaveBeenCalledWith(7);
|
|
535
|
+
await inFlight;
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('does not reject when the scheduled pass fails', async () => {
|
|
539
|
+
const runOnce = vi.fn().mockRejectedValue(new Error('boom'));
|
|
540
|
+
const scheduler = createShareLinkMaintenanceScheduler({
|
|
541
|
+
runOnce,
|
|
542
|
+
intervalMs: 1_000,
|
|
543
|
+
logger: silentLogger,
|
|
544
|
+
setTimeoutFn: () => 1,
|
|
545
|
+
clearTimeoutFn: () => undefined,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await expect(scheduler.trigger()).resolves.toBeUndefined();
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('share-link maintenance telemetry', () => {
|
|
553
|
+
it('emits only allowlisted aggregate counters', () => {
|
|
554
|
+
const event = toMaintenanceLogEvent({
|
|
555
|
+
status: 'completed',
|
|
556
|
+
durationMs: 120,
|
|
557
|
+
budgetMs: 5_000,
|
|
558
|
+
recovery: {
|
|
559
|
+
discovered: 2,
|
|
560
|
+
claimed: 1,
|
|
561
|
+
finalized: 1,
|
|
562
|
+
abandoned: 0,
|
|
563
|
+
deferred: 0,
|
|
564
|
+
claimLost: 0,
|
|
565
|
+
anomalies: 0,
|
|
566
|
+
categories: { finalized: 1 },
|
|
567
|
+
},
|
|
568
|
+
cleanup: {
|
|
569
|
+
claimed: 1,
|
|
570
|
+
completed: 0,
|
|
571
|
+
retried: 0,
|
|
572
|
+
claimLost: 0,
|
|
573
|
+
skipped: 1,
|
|
574
|
+
categories: { UNAUTHORIZED: 1 },
|
|
575
|
+
},
|
|
576
|
+
receiptPrune: { pruned: 3 },
|
|
577
|
+
categories: {},
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
expect(event.counts).toMatchObject({
|
|
581
|
+
recoveryDiscovered: 2,
|
|
582
|
+
cleanupSkipped: 1,
|
|
583
|
+
receiptsPruned: 3,
|
|
584
|
+
});
|
|
585
|
+
expect(event.categories).toMatchObject({ finalized: 1, UNAUTHORIZED: 1 });
|
|
586
|
+
expect(Object.keys(event).sort()).toEqual([
|
|
587
|
+
'budgetMs',
|
|
588
|
+
'categories',
|
|
589
|
+
'counts',
|
|
590
|
+
'durationMs',
|
|
591
|
+
'event',
|
|
592
|
+
'status',
|
|
593
|
+
]);
|
|
594
|
+
|
|
595
|
+
const serialized = JSON.stringify(event);
|
|
596
|
+
for (const sensitive of [OBJECT_REF, OWNER, SHARE_ID, OPERATION_ID, NAMESPACE]) {
|
|
597
|
+
expect(serialized).not.toContain(sensitive);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe('maintenance budget helper', () => {
|
|
603
|
+
it('reports exhaustion against the finalization reserve', () => {
|
|
604
|
+
let now = 0;
|
|
605
|
+
const budget = createMaintenancePassBudget({
|
|
606
|
+
budgetMs: 1_000,
|
|
607
|
+
finalizeReserveMs: 400,
|
|
608
|
+
monotonicNow: () => now,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(budget.exhausted()).toBe(false);
|
|
612
|
+
|
|
613
|
+
now = 700;
|
|
614
|
+
expect(budget.exhausted()).toBe(true);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe('Lambda and Docker wiring is disabled by default', () => {
|
|
619
|
+
const serverless = readFileSync(join(__dirname, '..', 'serverless.yml'), 'utf8');
|
|
620
|
+
|
|
621
|
+
it('registers the automatic maintenance schedule', () => {
|
|
622
|
+
expect(serverless).toContain(
|
|
623
|
+
'handler: shareLinkMaintenanceLambda.shareLinkMaintenanceHandler'
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const functionBlock = serverless.slice(
|
|
627
|
+
serverless.indexOf('shareLinkMaintenance:'),
|
|
628
|
+
serverless.indexOf('trpc:')
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
expect(functionBlock).toContain('enabled: true');
|
|
632
|
+
expect(functionBlock).not.toContain('enabled: false');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('runs share-link maintenance every five minutes while inbox maintenance stays daily', () => {
|
|
636
|
+
const shareLinkBlock = serverless.slice(
|
|
637
|
+
serverless.indexOf('shareLinkMaintenance:'),
|
|
638
|
+
serverless.indexOf('trpc:')
|
|
639
|
+
);
|
|
640
|
+
expect(shareLinkBlock).toContain('rate: rate(5 minutes)');
|
|
641
|
+
expect(shareLinkBlock).not.toContain('rate: rate(1 day)');
|
|
642
|
+
|
|
643
|
+
const inboxBlock = serverless.slice(
|
|
644
|
+
serverless.indexOf('inboxMaintenance:'),
|
|
645
|
+
serverless.indexOf('shareLinkMaintenance:')
|
|
646
|
+
);
|
|
647
|
+
expect(inboxBlock).toContain('rate: rate(1 day)');
|
|
648
|
+
expect(inboxBlock).not.toContain('rate: rate(5 minutes)');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('does not ship a maintenance rollout flag', () => {
|
|
652
|
+
expect(serverless).not.toContain('SHARE_LINK_MAINTENANCE_ENABLED:');
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
describe('C7 independent review regressions', () => {
|
|
657
|
+
it('does not initialize dependencies when Lambda time is already exhausted', async () => {
|
|
658
|
+
const createClient = vi.fn().mockResolvedValue(makeDeleteClient());
|
|
659
|
+
const createRepositories = vi.fn().mockResolvedValue({
|
|
660
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
661
|
+
cleanupRepository: makeCleanupRepository([]),
|
|
662
|
+
pruneReceipts: vi.fn().mockResolvedValue(0),
|
|
663
|
+
});
|
|
664
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
665
|
+
rawEnvironment: enabledEnv(),
|
|
666
|
+
logger: silentLogger,
|
|
667
|
+
createClient,
|
|
668
|
+
createRepositories,
|
|
669
|
+
});
|
|
670
|
+
expect((await runtime.runOnce({ remainingTimeMs: 0 })).status).toBe('deadline_exhausted');
|
|
671
|
+
expect(createClient).not.toHaveBeenCalled();
|
|
672
|
+
expect(createRepositories).not.toHaveBeenCalled();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('includes cold dependency initialization in the original invocation deadline', async () => {
|
|
676
|
+
let clock = 0;
|
|
677
|
+
const recoveryRepository = makeRecoveryRepository();
|
|
678
|
+
const cleanupRepository = makeCleanupRepository([]);
|
|
679
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
680
|
+
rawEnvironment: enabledEnv(),
|
|
681
|
+
logger: silentLogger,
|
|
682
|
+
monotonicNow: () => clock,
|
|
683
|
+
createClient: async () => {
|
|
684
|
+
clock = 80_000;
|
|
685
|
+
return makeDeleteClient();
|
|
686
|
+
},
|
|
687
|
+
createRepositories: async () => ({
|
|
688
|
+
recoveryRepository,
|
|
689
|
+
cleanupRepository,
|
|
690
|
+
pruneReceipts: vi.fn().mockResolvedValue(0),
|
|
691
|
+
}),
|
|
692
|
+
});
|
|
693
|
+
expect((await runtime.runOnce({ remainingTimeMs: 60_000 })).status).toBe(
|
|
694
|
+
'deadline_exhausted'
|
|
695
|
+
);
|
|
696
|
+
expect(recoveryRepository.discoverRecoverableReservations).not.toHaveBeenCalled();
|
|
697
|
+
expect(cleanupRepository.claimCleanupJobs).not.toHaveBeenCalled();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('retries dependency initialization after a transient failure on the next pass', async () => {
|
|
701
|
+
const createClient = vi
|
|
702
|
+
.fn()
|
|
703
|
+
.mockRejectedValueOnce(new Error('temporary failure'))
|
|
704
|
+
.mockResolvedValue(makeDeleteClient());
|
|
705
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
706
|
+
rawEnvironment: enabledEnv(),
|
|
707
|
+
logger: silentLogger,
|
|
708
|
+
createClient,
|
|
709
|
+
createRepositories: async () => ({
|
|
710
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
711
|
+
cleanupRepository: makeCleanupRepository([]),
|
|
712
|
+
pruneReceipts: vi.fn().mockResolvedValue(0),
|
|
713
|
+
}),
|
|
714
|
+
});
|
|
715
|
+
await runtime.runOnce();
|
|
716
|
+
expect((await runtime.runOnce()).status).toBe('completed');
|
|
717
|
+
expect(createClient).toHaveBeenCalledTimes(2);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('does not claim a unit whose configured HTTP allowance already exceeds remaining Lambda time', async () => {
|
|
721
|
+
const cleanupRepository = makeCleanupRepository([cleanupJob()]);
|
|
722
|
+
const client = makeDeleteClient();
|
|
723
|
+
const summary = await runShareLinkMaintenancePass({
|
|
724
|
+
config: enabledConfig({ SHARE_LINK_MAINTENANCE_CLEANUP_LIMIT: '1' }),
|
|
725
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
726
|
+
cleanupRepository,
|
|
727
|
+
client,
|
|
728
|
+
logger: silentLogger,
|
|
729
|
+
remainingTimeMs: () => 6_000,
|
|
730
|
+
});
|
|
731
|
+
expect(summary.status).toBe('deadline_exhausted');
|
|
732
|
+
expect(cleanupRepository.claimCleanupJobs).not.toHaveBeenCalled();
|
|
733
|
+
expect(client.delete).not.toHaveBeenCalled();
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('does not allow an explicit trigger to restart work after stop has drained', async () => {
|
|
737
|
+
const runOnce = vi.fn().mockResolvedValue(undefined);
|
|
738
|
+
const scheduler = createShareLinkMaintenanceScheduler({
|
|
739
|
+
runOnce,
|
|
740
|
+
intervalMs: 1000,
|
|
741
|
+
logger: silentLogger,
|
|
742
|
+
});
|
|
743
|
+
await scheduler.stop();
|
|
744
|
+
await scheduler.trigger();
|
|
745
|
+
expect(runOnce).not.toHaveBeenCalled();
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
describe('C7a whole-unit admission and post-claim recheck', () => {
|
|
750
|
+
const budgetAt = (remaining: { value: number }, reserveMs = 5_000) => ({
|
|
751
|
+
remainingMs: () => remaining.value,
|
|
752
|
+
reserveMs: () => reserveMs,
|
|
753
|
+
exhausted: () => remaining.value <= reserveMs,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('does not start a cleanup unit when remaining cannot cover claim+delete+complete', async () => {
|
|
757
|
+
const repository = makeCleanupRepository([cleanupJob()]);
|
|
758
|
+
const client = makeDeleteClient();
|
|
759
|
+
|
|
760
|
+
const summary = await runShareContentCleanupOnce({
|
|
761
|
+
repository,
|
|
762
|
+
client,
|
|
763
|
+
claimant: 'cleaner',
|
|
764
|
+
namespace: NAMESPACE,
|
|
765
|
+
claimBatchSize: 1,
|
|
766
|
+
transactionTimeoutMs: 15_000,
|
|
767
|
+
requestTimeoutMs: 10_000,
|
|
768
|
+
budget: budgetAt({ value: 5_000 + 40_000 - 1 }),
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
expect(repository.claimCleanupJobs).not.toHaveBeenCalled();
|
|
772
|
+
expect(client.delete).not.toHaveBeenCalled();
|
|
773
|
+
expect(summary.claimed).toBe(0);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('admits a cleanup unit at the exact whole-unit boundary', async () => {
|
|
777
|
+
const repository = makeCleanupRepository([]);
|
|
778
|
+
|
|
779
|
+
const summary = await runShareContentCleanupOnce({
|
|
780
|
+
repository,
|
|
781
|
+
client: makeDeleteClient(),
|
|
782
|
+
claimant: 'cleaner',
|
|
783
|
+
namespace: NAMESPACE,
|
|
784
|
+
claimBatchSize: 1,
|
|
785
|
+
transactionTimeoutMs: 15_000,
|
|
786
|
+
requestTimeoutMs: 10_000,
|
|
787
|
+
budget: budgetAt({ value: 5_000 + 40_000 }),
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
expect(repository.claimCleanupJobs).toHaveBeenCalledTimes(1);
|
|
791
|
+
expect(summary.claimed).toBe(0);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('rechecks after the awaited claim and leaves the job fenced instead of deleting', async () => {
|
|
795
|
+
const remaining = { value: 5_000 + 40_000 };
|
|
796
|
+
const repository = makeCleanupRepository([cleanupJob()]);
|
|
797
|
+
(repository.claimCleanupJobs as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
798
|
+
// The claim waited on a lock; only the completion tail remains.
|
|
799
|
+
remaining.value = 5_000 + 15_000;
|
|
800
|
+
return { claimToken: 'token-1', jobs: [cleanupJob()] };
|
|
801
|
+
});
|
|
802
|
+
const client = makeDeleteClient();
|
|
803
|
+
|
|
804
|
+
const summary = await runShareContentCleanupOnce({
|
|
805
|
+
repository,
|
|
806
|
+
client,
|
|
807
|
+
claimant: 'cleaner',
|
|
808
|
+
namespace: NAMESPACE,
|
|
809
|
+
claimBatchSize: 1,
|
|
810
|
+
transactionTimeoutMs: 15_000,
|
|
811
|
+
requestTimeoutMs: 10_000,
|
|
812
|
+
budget: budgetAt(remaining),
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
expect(repository.claimCleanupJobs).toHaveBeenCalledTimes(1);
|
|
816
|
+
expect(client.delete).not.toHaveBeenCalled();
|
|
817
|
+
expect(repository.completeCleanupJob).not.toHaveBeenCalled();
|
|
818
|
+
expect(summary).toMatchObject({ claimed: 1, completed: 0, skipped: 0 });
|
|
819
|
+
expect(summary.categories).toMatchObject({ budget_exhausted: 1 });
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('clamps the completion graph transaction budget to the time left after the remote call', async () => {
|
|
823
|
+
const remaining = { value: 5_000 + 40_000 };
|
|
824
|
+
const repository = makeCleanupRepository([cleanupJob()]);
|
|
825
|
+
const client = makeDeleteClient();
|
|
826
|
+
(client.delete as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
827
|
+
// The remote delete consumed most of the pass budget.
|
|
828
|
+
remaining.value = 5_000 + 4_000;
|
|
829
|
+
return { ok: true, value: {} };
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await runShareContentCleanupOnce({
|
|
833
|
+
repository,
|
|
834
|
+
client,
|
|
835
|
+
claimant: 'cleaner',
|
|
836
|
+
namespace: NAMESPACE,
|
|
837
|
+
claimBatchSize: 1,
|
|
838
|
+
limit: 1,
|
|
839
|
+
transactionTimeoutMs: 15_000,
|
|
840
|
+
requestTimeoutMs: 10_000,
|
|
841
|
+
budget: budgetAt(remaining),
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
expect(repository.completeCleanupJob).toHaveBeenCalledWith(
|
|
845
|
+
expect.objectContaining({ transactionTimeoutMs: 4_000, noInlineRetry: undefined })
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('does not run recovery discovery once the remaining budget cannot cover the read', async () => {
|
|
850
|
+
const repository = makeRecoveryRepository();
|
|
851
|
+
|
|
852
|
+
const summary = await runShareLinkRecoveryOnce({
|
|
853
|
+
repository,
|
|
854
|
+
client: { stat: vi.fn() } as unknown as RecoveryRunnerDependencies['client'],
|
|
855
|
+
claimant: 'cleaner',
|
|
856
|
+
namespace: NAMESPACE,
|
|
857
|
+
transactionTimeoutMs: 15_000,
|
|
858
|
+
requestTimeoutMs: 10_000,
|
|
859
|
+
budget: budgetAt({ value: 5_000 + 15_000 - 1 }),
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
expect(repository.discoverRecoverableReservations).not.toHaveBeenCalled();
|
|
863
|
+
expect(summary.discovered).toBe(0);
|
|
864
|
+
expect(summary.categories).toMatchObject({ budget_exhausted: 1 });
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('defers missing-content abandonment when stat consumes the remaining graph allowance', async () => {
|
|
868
|
+
const remaining = { value: 45_000 };
|
|
869
|
+
const repository = makeRecoveryRepository();
|
|
870
|
+
const key = {
|
|
871
|
+
namespace: NAMESPACE,
|
|
872
|
+
ownerProfileId: OWNER,
|
|
873
|
+
shareId: SHARE_ID,
|
|
874
|
+
operationId: OPERATION_ID,
|
|
875
|
+
};
|
|
876
|
+
vi.mocked(repository.discoverRecoverableReservations).mockResolvedValue({ keys: [key] });
|
|
877
|
+
vi.mocked(repository.claimRecoverableReservation).mockResolvedValue({
|
|
878
|
+
outcome: 'claimed',
|
|
879
|
+
share: {} as never,
|
|
880
|
+
reservation: { ...key, objectRef: OBJECT_REF, contentVersion: 1 } as never,
|
|
881
|
+
});
|
|
882
|
+
const stat = vi.fn().mockImplementation(async () => {
|
|
883
|
+
remaining.value = 5_000 + 15_000 - 1;
|
|
884
|
+
return { ok: false, error: 'NOT_FOUND' };
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const summary = await runShareLinkRecoveryOnce({
|
|
888
|
+
repository,
|
|
889
|
+
client: { stat } as unknown as RecoveryRunnerDependencies['client'],
|
|
890
|
+
claimant: 'cleaner',
|
|
891
|
+
namespace: NAMESPACE,
|
|
892
|
+
transactionTimeoutMs: 15_000,
|
|
893
|
+
requestTimeoutMs: 10_000,
|
|
894
|
+
budget: budgetAt(remaining),
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
expect(stat).toHaveBeenCalledOnce();
|
|
898
|
+
expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
|
|
899
|
+
expect(summary).toMatchObject({ claimed: 1, deferred: 1 });
|
|
900
|
+
expect(summary.categories).toMatchObject({ budget_exhausted: 1 });
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('leaves a claimed reservation fenced instead of stat-ing when the tail no longer fits', async () => {
|
|
904
|
+
const remaining = { value: 5_000 + 40_000 };
|
|
905
|
+
const stat = vi.fn();
|
|
906
|
+
const repository = makeRecoveryRepository();
|
|
907
|
+
(repository.discoverRecoverableReservations as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
908
|
+
keys: [
|
|
909
|
+
{
|
|
910
|
+
namespace: NAMESPACE,
|
|
911
|
+
ownerProfileId: OWNER,
|
|
912
|
+
shareId: SHARE_ID,
|
|
913
|
+
operationId: OPERATION_ID,
|
|
914
|
+
},
|
|
915
|
+
],
|
|
916
|
+
});
|
|
917
|
+
(repository.claimRecoverableReservation as ReturnType<typeof vi.fn>).mockImplementation(
|
|
918
|
+
async () => {
|
|
919
|
+
// The claim waited on a lock; only the stat/finalize tail remains.
|
|
920
|
+
remaining.value = 5_000 + 10_000;
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
outcome: 'claimed' as const,
|
|
924
|
+
share: {} as never,
|
|
925
|
+
reservation: { objectRef: OBJECT_REF, contentVersion: 1 } as never,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
const summary = await runShareLinkRecoveryOnce({
|
|
931
|
+
repository,
|
|
932
|
+
client: { stat } as unknown as RecoveryRunnerDependencies['client'],
|
|
933
|
+
claimant: 'cleaner',
|
|
934
|
+
namespace: NAMESPACE,
|
|
935
|
+
transactionTimeoutMs: 15_000,
|
|
936
|
+
requestTimeoutMs: 10_000,
|
|
937
|
+
budget: budgetAt(remaining),
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
expect(stat).not.toHaveBeenCalled();
|
|
941
|
+
expect(repository.finalizeReservation).not.toHaveBeenCalled();
|
|
942
|
+
expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
|
|
943
|
+
expect(summary).toMatchObject({ claimed: 1, deferred: 1 });
|
|
944
|
+
expect(summary.categories).toMatchObject({ budget_exhausted: 1 });
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe('C7a dependency setup caching', () => {
|
|
949
|
+
it('shares one in-flight setup across concurrent invocations', async () => {
|
|
950
|
+
let resolveClient!: (client: ReturnType<typeof makeDeleteClient>) => void;
|
|
951
|
+
const pendingClient = new Promise<ReturnType<typeof makeDeleteClient>>(resolve => {
|
|
952
|
+
resolveClient = resolve;
|
|
953
|
+
});
|
|
954
|
+
const createClient = vi.fn().mockReturnValue(pendingClient);
|
|
955
|
+
const createRepositories = vi.fn().mockResolvedValue({
|
|
956
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
957
|
+
cleanupRepository: makeCleanupRepository([]),
|
|
958
|
+
pruneReceipts: vi.fn().mockResolvedValue(0),
|
|
959
|
+
});
|
|
960
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
961
|
+
rawEnvironment: enabledEnv(),
|
|
962
|
+
logger: silentLogger,
|
|
963
|
+
createClient,
|
|
964
|
+
createRepositories,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
const first = runtime.runOnce();
|
|
968
|
+
const second = runtime.runOnce();
|
|
969
|
+
|
|
970
|
+
expect(createClient).toHaveBeenCalledTimes(1);
|
|
971
|
+
|
|
972
|
+
resolveClient(makeDeleteClient());
|
|
973
|
+
|
|
974
|
+
const [firstSummary, secondSummary] = await Promise.all([first, second]);
|
|
975
|
+
|
|
976
|
+
expect(createClient).toHaveBeenCalledTimes(1);
|
|
977
|
+
expect(createRepositories).toHaveBeenCalledTimes(1);
|
|
978
|
+
expect(firstSummary.status).toBe('completed');
|
|
979
|
+
expect(secondSummary.status).toBe('completed');
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('clears a budget-aborted cold start so the next invocation retries setup', async () => {
|
|
983
|
+
let clock = 0;
|
|
984
|
+
let clientCalls = 0;
|
|
985
|
+
const createClient = vi.fn(async () => {
|
|
986
|
+
if (clientCalls++ === 0) clock = 80_000;
|
|
987
|
+
return makeDeleteClient();
|
|
988
|
+
});
|
|
989
|
+
const createRepositories = vi.fn().mockResolvedValue({
|
|
990
|
+
recoveryRepository: makeRecoveryRepository(),
|
|
991
|
+
cleanupRepository: makeCleanupRepository([]),
|
|
992
|
+
pruneReceipts: vi.fn().mockResolvedValue(0),
|
|
993
|
+
});
|
|
994
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
995
|
+
rawEnvironment: enabledEnv(),
|
|
996
|
+
logger: silentLogger,
|
|
997
|
+
monotonicNow: () => clock,
|
|
998
|
+
createClient,
|
|
999
|
+
createRepositories,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const first = await runtime.runOnce({ remainingTimeMs: 60_000 });
|
|
1003
|
+
|
|
1004
|
+
expect(first.status).toBe('deadline_exhausted');
|
|
1005
|
+
expect(createRepositories).not.toHaveBeenCalled();
|
|
1006
|
+
|
|
1007
|
+
const second = await runtime.runOnce({ remainingTimeMs: 60_000 });
|
|
1008
|
+
|
|
1009
|
+
expect(second.status).toBe('completed');
|
|
1010
|
+
expect(createClient).toHaveBeenCalledTimes(2);
|
|
1011
|
+
expect(createRepositories).toHaveBeenCalledTimes(1);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it('reports a distinct fixed initialization-failure category without echoing the error', async () => {
|
|
1015
|
+
const createClient = vi.fn().mockRejectedValue(new Error('secret-temporary-failure'));
|
|
1016
|
+
const createRepositories = vi.fn();
|
|
1017
|
+
const runtime = createShareLinkMaintenanceRuntime({
|
|
1018
|
+
rawEnvironment: enabledEnv(),
|
|
1019
|
+
logger: silentLogger,
|
|
1020
|
+
createClient,
|
|
1021
|
+
createRepositories,
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const summary = await runtime.runOnce();
|
|
1025
|
+
|
|
1026
|
+
expect(summary.status).toBe('initialization_failed');
|
|
1027
|
+
expect(summary.categories).toEqual({
|
|
1028
|
+
share_link_maintenance_initialization_failed: 1,
|
|
1029
|
+
});
|
|
1030
|
+
expect(JSON.stringify(summary)).not.toContain('secret-temporary-failure');
|
|
1031
|
+
expect(createRepositories).not.toHaveBeenCalled();
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
describe('C7a scheduler terminal stop and synchronous failure', () => {
|
|
1036
|
+
it('does not run a queued timer callback after stop', async () => {
|
|
1037
|
+
let queued: (() => void) | null = null;
|
|
1038
|
+
const runOnce = vi.fn().mockResolvedValue(undefined);
|
|
1039
|
+
const scheduler = createShareLinkMaintenanceScheduler({
|
|
1040
|
+
runOnce,
|
|
1041
|
+
intervalMs: 1_000,
|
|
1042
|
+
logger: silentLogger,
|
|
1043
|
+
setTimeoutFn: handler => {
|
|
1044
|
+
queued = handler;
|
|
1045
|
+
return 1;
|
|
1046
|
+
},
|
|
1047
|
+
clearTimeoutFn: () => undefined,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
scheduler.start();
|
|
1051
|
+
await scheduler.stop();
|
|
1052
|
+
|
|
1053
|
+
// Simulate a timer callback that was already queued when stop ran.
|
|
1054
|
+
queued?.();
|
|
1055
|
+
await Promise.resolve();
|
|
1056
|
+
await Promise.resolve();
|
|
1057
|
+
|
|
1058
|
+
expect(runOnce).not.toHaveBeenCalled();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('keeps inFlight clear when runOnce throws synchronously', async () => {
|
|
1062
|
+
const runOnce = vi.fn((): Promise<unknown> => {
|
|
1063
|
+
throw new Error('sync boom');
|
|
1064
|
+
});
|
|
1065
|
+
const scheduler = createShareLinkMaintenanceScheduler({
|
|
1066
|
+
runOnce,
|
|
1067
|
+
intervalMs: 1_000,
|
|
1068
|
+
logger: silentLogger,
|
|
1069
|
+
setTimeoutFn: () => 1,
|
|
1070
|
+
clearTimeoutFn: () => undefined,
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
await expect(scheduler.trigger()).resolves.toBeUndefined();
|
|
1074
|
+
expect(scheduler.isRunning()).toBe(false);
|
|
1075
|
+
|
|
1076
|
+
await expect(scheduler.trigger()).resolves.toBeUndefined();
|
|
1077
|
+
expect(runOnce).toHaveBeenCalledTimes(2);
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
describe('C7a bounded maintenance transaction execution', () => {
|
|
1082
|
+
const makeSession = () => {
|
|
1083
|
+
const transaction = {
|
|
1084
|
+
run: vi.fn(),
|
|
1085
|
+
commit: vi.fn().mockResolvedValue(undefined),
|
|
1086
|
+
rollback: vi.fn().mockResolvedValue(undefined),
|
|
1087
|
+
};
|
|
1088
|
+
const beginTransaction = vi.fn().mockReturnValue(transaction);
|
|
1089
|
+
|
|
1090
|
+
return { session: { beginTransaction }, transaction, beginTransaction };
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
it('makes exactly one explicit attempt with the bounded timeout', async () => {
|
|
1094
|
+
const { session, transaction, beginTransaction } = makeSession();
|
|
1095
|
+
const work = vi.fn().mockResolvedValue('done');
|
|
1096
|
+
|
|
1097
|
+
await expect(executeExplicitTransaction(session, work, 250)).resolves.toBe('done');
|
|
1098
|
+
expect(beginTransaction).toHaveBeenCalledTimes(1);
|
|
1099
|
+
expect(beginTransaction).toHaveBeenCalledWith({ timeout: 250 });
|
|
1100
|
+
expect(transaction.commit).toHaveBeenCalledTimes(1);
|
|
1101
|
+
expect(transaction.rollback).not.toHaveBeenCalled();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('does not retry a thrown unit and rolls the explicit transaction back once', async () => {
|
|
1105
|
+
const { session, transaction, beginTransaction } = makeSession();
|
|
1106
|
+
const work = vi.fn().mockRejectedValue(new Error('transient'));
|
|
1107
|
+
|
|
1108
|
+
await expect(executeExplicitTransaction(session, work, 250)).rejects.toThrow('transient');
|
|
1109
|
+
expect(beginTransaction).toHaveBeenCalledTimes(1);
|
|
1110
|
+
expect(transaction.rollback).toHaveBeenCalledTimes(1);
|
|
1111
|
+
expect(transaction.commit).not.toHaveBeenCalled();
|
|
1112
|
+
});
|
|
1113
|
+
});
|