@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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/dist/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/vault-client.test.ts
DELETED
|
@@ -1,1380 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VaultClient unit tests (VLT-7 US-001).
|
|
3
|
-
*
|
|
4
|
-
* Uses mocked fetch to assert retry behavior, error mapping, and auth header injection.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
|
|
8
|
-
import {
|
|
9
|
-
VaultClient,
|
|
10
|
-
VaultAuthError,
|
|
11
|
-
VaultPermissionDeniedError,
|
|
12
|
-
VaultNotFoundError,
|
|
13
|
-
VaultConflictError,
|
|
14
|
-
VaultClientError,
|
|
15
|
-
pickCanonicalPersonEntity,
|
|
16
|
-
type EntityInfo,
|
|
17
|
-
type ExplicitGrant,
|
|
18
|
-
type MembershipSyncConfig,
|
|
19
|
-
type TelemetryEventsBatch,
|
|
20
|
-
} from "./vault-client.js";
|
|
21
|
-
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Helpers
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
function jsonResponse(status: number, body: unknown): Response {
|
|
27
|
-
return new Response(JSON.stringify(body), {
|
|
28
|
-
status,
|
|
29
|
-
headers: { "Content-Type": "application/json" },
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function textResponse(status: number, body: string): Response {
|
|
34
|
-
return new Response(body, { status });
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const TEST_CONFIG = {
|
|
38
|
-
apiUrl: "https://vault.test.example.com",
|
|
39
|
-
authToken: "test-jwt-token-123",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
let client: VaultClient;
|
|
43
|
-
let fetchSpy: MockInstance<typeof fetch>;
|
|
44
|
-
|
|
45
|
-
beforeEach(() => {
|
|
46
|
-
client = new VaultClient(TEST_CONFIG);
|
|
47
|
-
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
vi.restoreAllMocks();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Auth header injection
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
describe("auth header injection", () => {
|
|
59
|
-
it("sends Bearer token on every request", async () => {
|
|
60
|
-
fetchSpy.mockResolvedValueOnce(
|
|
61
|
-
jsonResponse(200, { members: [] }),
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
await client.listMembersOfCompany("cmp_abc");
|
|
65
|
-
|
|
66
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
67
|
-
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
68
|
-
const headers = init.headers as Record<string, string>;
|
|
69
|
-
expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("sets Content-Type on POST requests", async () => {
|
|
73
|
-
fetchSpy.mockResolvedValueOnce(
|
|
74
|
-
jsonResponse(200, { membership: {}, inviteToken: "tok" }),
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
await client.createInvite({
|
|
78
|
-
companyUid: "cmp_abc",
|
|
79
|
-
role: "member",
|
|
80
|
-
invitedBy: "psn_xyz",
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
84
|
-
const headers = init.headers as Record<string, string>;
|
|
85
|
-
expect(headers["Content-Type"]).toBe("application/json");
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
// Error mapping
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
describe("error mapping", () => {
|
|
94
|
-
it("maps 401 to VaultAuthError", async () => {
|
|
95
|
-
fetchSpy.mockImplementation(() =>
|
|
96
|
-
Promise.resolve(jsonResponse(401, { message: "Token expired" })),
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("maps 403 to VaultPermissionDeniedError", async () => {
|
|
103
|
-
fetchSpy.mockResolvedValueOnce(
|
|
104
|
-
jsonResponse(403, { message: "Admin required" }),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultPermissionDeniedError);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("maps 404 to VaultNotFoundError", async () => {
|
|
111
|
-
fetchSpy.mockResolvedValueOnce(
|
|
112
|
-
jsonResponse(404, { message: "Not found" }),
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
await expect(client.entity.get("cmp_missing")).rejects.toThrow(VaultNotFoundError);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("maps 409 to VaultConflictError", async () => {
|
|
119
|
-
fetchSpy.mockResolvedValueOnce(
|
|
120
|
-
jsonResponse(409, { message: "Already accepted" }),
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
await expect(client.acceptInvite("tok", "psn_abc")).rejects.toThrow(VaultConflictError);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("preserves error message from response body", async () => {
|
|
127
|
-
fetchSpy.mockResolvedValueOnce(
|
|
128
|
-
jsonResponse(403, { message: "Only admins can invite" }),
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
await client.createInvite({
|
|
133
|
-
companyUid: "cmp_abc",
|
|
134
|
-
role: "member",
|
|
135
|
-
invitedBy: "psn_xyz",
|
|
136
|
-
});
|
|
137
|
-
expect.fail("Should have thrown");
|
|
138
|
-
} catch (err) {
|
|
139
|
-
expect(err).toBeInstanceOf(VaultPermissionDeniedError);
|
|
140
|
-
expect((err as VaultPermissionDeniedError).message).toBe("Only admins can invite");
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("handles non-JSON error bodies gracefully", async () => {
|
|
145
|
-
fetchSpy.mockResolvedValueOnce(
|
|
146
|
-
textResponse(404, "Not Found"),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
await client.entity.findBySlug("company", "test");
|
|
151
|
-
expect.fail("Should have thrown");
|
|
152
|
-
} catch (err) {
|
|
153
|
-
expect(err).toBeInstanceOf(VaultNotFoundError);
|
|
154
|
-
expect((err as VaultNotFoundError).message).toBe("Not Found");
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// Retry behavior
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
describe("retry behavior", () => {
|
|
164
|
-
it("retries on 429 and succeeds on second attempt", async () => {
|
|
165
|
-
fetchSpy
|
|
166
|
-
.mockResolvedValueOnce(jsonResponse(429, { message: "Rate limited" }))
|
|
167
|
-
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
168
|
-
|
|
169
|
-
const result = await client.listMembersOfCompany("cmp_abc");
|
|
170
|
-
expect(result).toHaveLength(1);
|
|
171
|
-
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("retries on 500 and succeeds on third attempt", async () => {
|
|
175
|
-
fetchSpy
|
|
176
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "Internal error" }))
|
|
177
|
-
.mockResolvedValueOnce(jsonResponse(502, { message: "Bad gateway" }))
|
|
178
|
-
.mockResolvedValueOnce(jsonResponse(200, { membership: { role: "admin" } }));
|
|
179
|
-
|
|
180
|
-
const result = await client.updateRole({
|
|
181
|
-
membershipKey: "psn_1#cmp_abc",
|
|
182
|
-
newRole: "admin",
|
|
183
|
-
updaterUid: "psn_owner",
|
|
184
|
-
companyUid: "cmp_abc",
|
|
185
|
-
});
|
|
186
|
-
expect(result.role).toBe("admin");
|
|
187
|
-
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("throws after exhausting all retries on persistent 500", async () => {
|
|
191
|
-
fetchSpy.mockImplementation(() =>
|
|
192
|
-
Promise.resolve(jsonResponse(500, { message: "Down" })),
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultClientError);
|
|
196
|
-
// 1 initial + 3 retries = 4
|
|
197
|
-
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// Regression: the deploy access-gate 401 of 2026-07-19. A token that expired
|
|
201
|
-
// mid-request is recoverable — callers passing a getter can mint a fresh one —
|
|
202
|
-
// but the client threw instead of retrying, so a transient auth lapse surfaced
|
|
203
|
-
// as a hard failure and the caller degraded a members-only gate to a password.
|
|
204
|
-
it("retries a 401 once when the token getter yields a fresh token", async () => {
|
|
205
|
-
const tokens = ["stale-token", "fresh-token"];
|
|
206
|
-
const getToken = vi.fn(async () => tokens.shift() ?? "fresh-token");
|
|
207
|
-
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
208
|
-
|
|
209
|
-
fetchSpy
|
|
210
|
-
.mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
|
|
211
|
-
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
212
|
-
|
|
213
|
-
const result = await c.listMembersOfCompany("cmp_abc");
|
|
214
|
-
expect(result).toHaveLength(1);
|
|
215
|
-
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
216
|
-
|
|
217
|
-
const authHeader = (call: number) =>
|
|
218
|
-
(fetchSpy.mock.calls[call]![1] as RequestInit).headers as Record<string, string>;
|
|
219
|
-
expect(authHeader(0).Authorization).toBe("Bearer stale-token");
|
|
220
|
-
expect(authHeader(1).Authorization).toBe("Bearer fresh-token");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("does not retry a 401 when the getter returns the same token", async () => {
|
|
224
|
-
const getToken = vi.fn(async () => "unchanging-token");
|
|
225
|
-
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
226
|
-
|
|
227
|
-
fetchSpy.mockResolvedValue(jsonResponse(401, { message: "Nope" }));
|
|
228
|
-
|
|
229
|
-
await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
230
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
// Review finding: the auth retry used to share the transient budget, so a
|
|
234
|
-
// 401 on the LAST attempt set the flag, hit `continue`, and fell out of the
|
|
235
|
-
// loop having never actually retried — the recovery silently did nothing in
|
|
236
|
-
// exactly the case it was written for.
|
|
237
|
-
it("still retries when the 401 lands on the final attempt", async () => {
|
|
238
|
-
const tokens = ["stale", "stale", "stale", "stale", "fresh"];
|
|
239
|
-
const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
|
|
240
|
-
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
241
|
-
|
|
242
|
-
// Burn the whole transient budget first (attempts 0,1,2), so the 401 lands
|
|
243
|
-
// on attempt 3 — the last one the shared-budget loop would ever run.
|
|
244
|
-
fetchSpy
|
|
245
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
246
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
247
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
248
|
-
.mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
|
|
249
|
-
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
250
|
-
|
|
251
|
-
const result = await c.listMembersOfCompany("cmp_abc");
|
|
252
|
-
expect(result).toHaveLength(1);
|
|
253
|
-
// 5th call only happens if the auth retry got its own budget.
|
|
254
|
-
expect(fetchSpy).toHaveBeenCalledTimes(5);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Review finding: a 401 consumed a transient-retry slot, so a request that
|
|
258
|
-
// hit an auth lapse had fewer retries left for genuine 5xx afterwards.
|
|
259
|
-
it("does not spend transient-retry budget on the auth retry", async () => {
|
|
260
|
-
const tokens = ["stale", "fresh"];
|
|
261
|
-
const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
|
|
262
|
-
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
263
|
-
|
|
264
|
-
// 401 (free retry) then three 500s — the full transient budget must still
|
|
265
|
-
// be available, so the 4th call succeeds.
|
|
266
|
-
fetchSpy
|
|
267
|
-
.mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
|
|
268
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
269
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
270
|
-
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
271
|
-
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
272
|
-
|
|
273
|
-
const result = await c.listMembersOfCompany("cmp_abc");
|
|
274
|
-
expect(result).toHaveLength(1);
|
|
275
|
-
expect(fetchSpy).toHaveBeenCalledTimes(5);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("retries a 401 at most once even if the token keeps changing", async () => {
|
|
279
|
-
let n = 0;
|
|
280
|
-
const getToken = vi.fn(async () => `token-${n++}`);
|
|
281
|
-
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
282
|
-
|
|
283
|
-
// mockImplementation (not mockResolvedValue): each attempt needs its own
|
|
284
|
-
// Response, since the body can only be read once.
|
|
285
|
-
fetchSpy.mockImplementation(() =>
|
|
286
|
-
Promise.resolve(jsonResponse(401, { message: "Still no" })),
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
290
|
-
// 1 initial + exactly 1 auth retry — never a retry storm against the authorizer
|
|
291
|
-
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("does not retry on 403 (non-transient)", async () => {
|
|
295
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
|
|
296
|
-
|
|
297
|
-
await expect(client.createInvite({
|
|
298
|
-
companyUid: "cmp_abc",
|
|
299
|
-
role: "member",
|
|
300
|
-
invitedBy: "psn_xyz",
|
|
301
|
-
})).rejects.toThrow(VaultPermissionDeniedError);
|
|
302
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("retries on network errors (fetch throws)", async () => {
|
|
306
|
-
fetchSpy
|
|
307
|
-
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
|
308
|
-
.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
309
|
-
|
|
310
|
-
const result = await client.listMembersOfCompany("cmp_abc");
|
|
311
|
-
expect(result).toEqual([]);
|
|
312
|
-
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// API surface
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
describe("API surface", () => {
|
|
321
|
-
it("createInvite sends correct body and URL", async () => {
|
|
322
|
-
fetchSpy.mockResolvedValueOnce(
|
|
323
|
-
jsonResponse(200, {
|
|
324
|
-
membership: { membershipKey: "psn_1#cmp_abc", role: "member", status: "pending" },
|
|
325
|
-
inviteToken: "tok_secure_random",
|
|
326
|
-
}),
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
const result = await client.createInvite({
|
|
330
|
-
companyUid: "cmp_abc",
|
|
331
|
-
role: "member",
|
|
332
|
-
invitedBy: "psn_owner",
|
|
333
|
-
inviteeEmail: "alice@example.com",
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
expect(result.inviteToken).toBe("tok_secure_random");
|
|
337
|
-
|
|
338
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
339
|
-
expect(url).toBe("https://vault.test.example.com/membership/invite");
|
|
340
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
341
|
-
companyUid: "cmp_abc",
|
|
342
|
-
role: "member",
|
|
343
|
-
invitedBy: "psn_owner",
|
|
344
|
-
inviteeEmail: "alice@example.com",
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it("acceptInvite sends token and personUid", async () => {
|
|
349
|
-
fetchSpy.mockResolvedValueOnce(
|
|
350
|
-
jsonResponse(200, {
|
|
351
|
-
membership: { status: "active", role: "member" },
|
|
352
|
-
}),
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
const result = await client.acceptInvite("tok_abc", "psn_invitee");
|
|
356
|
-
expect(result.membership.status).toBe("active");
|
|
357
|
-
|
|
358
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
359
|
-
expect(url).toBe("https://vault.test.example.com/membership/accept");
|
|
360
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
361
|
-
token: "tok_abc",
|
|
362
|
-
personUid: "psn_invitee",
|
|
363
|
-
});
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it("postTelemetryEvents sends action events without personUid", async () => {
|
|
367
|
-
fetchSpy.mockResolvedValueOnce(
|
|
368
|
-
jsonResponse(200, { contractVersion: 1, ok: true, written: 1, skipped: [] }),
|
|
369
|
-
);
|
|
370
|
-
const batch: TelemetryEventsBatch = {
|
|
371
|
-
events: [
|
|
372
|
-
{
|
|
373
|
-
eventName: "sync_runner_completed",
|
|
374
|
-
app: "hq-cloud",
|
|
375
|
-
source: "sync-runner",
|
|
376
|
-
occurredAt: "2026-07-07T12:00:00.000Z",
|
|
377
|
-
companyUid: "cmp_abc",
|
|
378
|
-
sessionId: "sync_123",
|
|
379
|
-
schemaVersion: 1,
|
|
380
|
-
properties: {
|
|
381
|
-
result: "completed",
|
|
382
|
-
fileCount: 2,
|
|
383
|
-
},
|
|
384
|
-
},
|
|
385
|
-
],
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const result = await client.postTelemetryEvents(batch);
|
|
389
|
-
|
|
390
|
-
expect(result).toEqual({ ok: true, written: 1, skipped: [] });
|
|
391
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
392
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
393
|
-
expect(url).toBe("https://vault.test.example.com/v1/telemetry/events");
|
|
394
|
-
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
395
|
-
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
396
|
-
expect(body).toEqual(batch);
|
|
397
|
-
expect(JSON.stringify(body)).not.toContain("personUid");
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
it("postTelemetryEvents normalizes the compatible pre-v1 accepted response", async () => {
|
|
401
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { accepted: 2, deduped: 1 }));
|
|
402
|
-
const batch: TelemetryEventsBatch = { events: [] };
|
|
403
|
-
|
|
404
|
-
await expect(client.postTelemetryEvents(batch)).resolves.toEqual({
|
|
405
|
-
ok: true,
|
|
406
|
-
written: 2,
|
|
407
|
-
skipped: [],
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it("updateRole sends correct payload", async () => {
|
|
412
|
-
fetchSpy.mockResolvedValueOnce(
|
|
413
|
-
jsonResponse(200, {
|
|
414
|
-
membership: { role: "guest", allowedPrefixes: ["docs/"] },
|
|
415
|
-
}),
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const result = await client.updateRole({
|
|
419
|
-
membershipKey: "psn_1#cmp_abc",
|
|
420
|
-
newRole: "guest",
|
|
421
|
-
allowedPrefixes: ["docs/"],
|
|
422
|
-
updaterUid: "psn_admin",
|
|
423
|
-
companyUid: "cmp_abc",
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
expect(result.role).toBe("guest");
|
|
427
|
-
expect(result.allowedPrefixes).toEqual(["docs/"]);
|
|
428
|
-
|
|
429
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
430
|
-
expect(url).toBe("https://vault.test.example.com/membership/role");
|
|
431
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
432
|
-
membershipKey: "psn_1#cmp_abc",
|
|
433
|
-
newRole: "guest",
|
|
434
|
-
allowedPrefixes: ["docs/"],
|
|
435
|
-
updaterUid: "psn_admin",
|
|
436
|
-
companyUid: "cmp_abc",
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("entity.get calls correct URL", async () => {
|
|
441
|
-
fetchSpy.mockResolvedValueOnce(
|
|
442
|
-
jsonResponse(200, {
|
|
443
|
-
entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
|
|
444
|
-
}),
|
|
445
|
-
);
|
|
446
|
-
|
|
447
|
-
const entity = await client.entity.get("cmp_abc");
|
|
448
|
-
expect(entity.slug).toBe("acme");
|
|
449
|
-
|
|
450
|
-
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
451
|
-
expect(url).toBe("https://vault.test.example.com/entity/cmp_abc");
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it("entity.findBySlug calls correct URL", async () => {
|
|
455
|
-
fetchSpy.mockResolvedValueOnce(
|
|
456
|
-
jsonResponse(200, {
|
|
457
|
-
entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
|
|
458
|
-
}),
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
const entity = await client.entity.findBySlug("company", "acme");
|
|
462
|
-
expect(entity.uid).toBe("cmp_abc");
|
|
463
|
-
|
|
464
|
-
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
465
|
-
expect(url).toBe("https://vault.test.example.com/entity/by-slug/company/acme");
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it("revokeMembership calls POST /membership/revoke with companyUid", async () => {
|
|
469
|
-
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
470
|
-
|
|
471
|
-
await client.revokeMembership("psn_1#cmp_abc", "cmp_abc");
|
|
472
|
-
|
|
473
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
474
|
-
expect(url).toBe("https://vault.test.example.com/membership/revoke");
|
|
475
|
-
expect(init.method).toBe("POST");
|
|
476
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
477
|
-
membershipKey: "psn_1#cmp_abc",
|
|
478
|
-
companyUid: "cmp_abc",
|
|
479
|
-
});
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
it("listPendingInvites calls correct URL", async () => {
|
|
483
|
-
fetchSpy.mockResolvedValueOnce(
|
|
484
|
-
jsonResponse(200, { invites: [{ status: "pending" }] }),
|
|
485
|
-
);
|
|
486
|
-
|
|
487
|
-
const invites = await client.listPendingInvites("cmp_abc");
|
|
488
|
-
expect(invites).toHaveLength(1);
|
|
489
|
-
|
|
490
|
-
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
491
|
-
expect(url).toBe("https://vault.test.example.com/membership/company/cmp_abc/pending");
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
it("listMyMemberships hits GET /membership/me and unwraps memberships[]", async () => {
|
|
495
|
-
fetchSpy.mockResolvedValueOnce(
|
|
496
|
-
jsonResponse(200, {
|
|
497
|
-
memberships: [
|
|
498
|
-
{ membershipKey: "psn_1#cmp_a", role: "owner", status: "active" },
|
|
499
|
-
{ membershipKey: "psn_1#cmp_b", role: "member", status: "active" },
|
|
500
|
-
],
|
|
501
|
-
}),
|
|
502
|
-
);
|
|
503
|
-
|
|
504
|
-
const memberships = await client.listMyMemberships();
|
|
505
|
-
expect(memberships).toHaveLength(2);
|
|
506
|
-
expect(memberships[0].membershipKey).toBe("psn_1#cmp_a");
|
|
507
|
-
|
|
508
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
509
|
-
expect(url).toBe("https://vault.test.example.com/membership/me");
|
|
510
|
-
expect(init.method).toBe("GET");
|
|
511
|
-
// No body on GET.
|
|
512
|
-
expect(init.body).toBeUndefined();
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
it("listMyMemberships returns [] for callers with no person entity (bootstrap case)", async () => {
|
|
516
|
-
// Server returns 200 + { memberships: [] } rather than 404 when the
|
|
517
|
-
// caller is signed in but hasn't been provisioned yet. The SDK must
|
|
518
|
-
// surface an empty array, NOT throw — hq-sync-runner relies on this
|
|
519
|
-
// to emit `setup-needed` without catching HTTP errors.
|
|
520
|
-
fetchSpy.mockResolvedValueOnce(
|
|
521
|
-
jsonResponse(200, { memberships: [] }),
|
|
522
|
-
);
|
|
523
|
-
|
|
524
|
-
const memberships = await client.listMyMemberships();
|
|
525
|
-
expect(memberships).toEqual([]);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
it("listMyPendingInvitesByEmail hits GET /membership/pending-by-email", async () => {
|
|
529
|
-
fetchSpy.mockResolvedValueOnce(
|
|
530
|
-
jsonResponse(200, {
|
|
531
|
-
invites: [
|
|
532
|
-
{
|
|
533
|
-
membershipKey: "email:stefan@getindigo.ai#cmp_abc",
|
|
534
|
-
companyUid: "cmp_abc",
|
|
535
|
-
role: "owner",
|
|
536
|
-
invitedBy: "sub-admin",
|
|
537
|
-
invitedAt: "2026-04-20T00:00:00Z",
|
|
538
|
-
},
|
|
539
|
-
],
|
|
540
|
-
}),
|
|
541
|
-
);
|
|
542
|
-
|
|
543
|
-
const invites = await client.listMyPendingInvitesByEmail();
|
|
544
|
-
expect(invites).toHaveLength(1);
|
|
545
|
-
expect(invites[0].companyUid).toBe("cmp_abc");
|
|
546
|
-
|
|
547
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
548
|
-
expect(url).toBe(
|
|
549
|
-
"https://vault.test.example.com/membership/pending-by-email",
|
|
550
|
-
);
|
|
551
|
-
expect(init.method).toBe("GET");
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it("listMyPendingInvitesByEmail returns [] when server omits the key", async () => {
|
|
555
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
556
|
-
const invites = await client.listMyPendingInvitesByEmail();
|
|
557
|
-
expect(invites).toEqual([]);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it("claimPendingInvitesByEmail POSTs personUid to /membership/claim-by-email", async () => {
|
|
561
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
562
|
-
|
|
563
|
-
await client.claimPendingInvitesByEmail("ent_person_stefan");
|
|
564
|
-
|
|
565
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
566
|
-
expect(url).toBe(
|
|
567
|
-
"https://vault.test.example.com/membership/claim-by-email",
|
|
568
|
-
);
|
|
569
|
-
expect(init.method).toBe("POST");
|
|
570
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
571
|
-
personUid: "ent_person_stefan",
|
|
572
|
-
});
|
|
573
|
-
});
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
describe("response validation (F26)", () => {
|
|
577
|
-
it("F26: validates a valid files/list response and returns typed data", async () => {
|
|
578
|
-
fetchSpy.mockResolvedValueOnce(
|
|
579
|
-
jsonResponse(200, {
|
|
580
|
-
objects: [
|
|
581
|
-
{
|
|
582
|
-
key: "shared/docs/a.txt",
|
|
583
|
-
size: 123,
|
|
584
|
-
lastModified: "2026-05-20T12:00:00.000Z",
|
|
585
|
-
etag: "abc123",
|
|
586
|
-
permission: "read",
|
|
587
|
-
},
|
|
588
|
-
],
|
|
589
|
-
cursor: null,
|
|
590
|
-
truncated: false,
|
|
591
|
-
}),
|
|
592
|
-
);
|
|
593
|
-
|
|
594
|
-
const page = await client.listFiles("cmp_abc", "shared/docs/");
|
|
595
|
-
|
|
596
|
-
expect(page.objects[0]).toEqual({
|
|
597
|
-
key: "shared/docs/a.txt",
|
|
598
|
-
size: 123,
|
|
599
|
-
lastModified: "2026-05-20T12:00:00.000Z",
|
|
600
|
-
etag: "abc123",
|
|
601
|
-
permission: "read",
|
|
602
|
-
});
|
|
603
|
-
expect(page.cursor).toBeNull();
|
|
604
|
-
expect(page.truncated).toBe(false);
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it("F26: defaults omitted files/list paging fields", async () => {
|
|
608
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
609
|
-
|
|
610
|
-
const page = await client.listFiles("cmp_abc", "shared/docs/");
|
|
611
|
-
|
|
612
|
-
expect(page).toEqual({
|
|
613
|
-
objects: [],
|
|
614
|
-
cursor: null,
|
|
615
|
-
truncated: false,
|
|
616
|
-
});
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
it("F26: defaults omitted telemetry opt-in fields to disabled", async () => {
|
|
620
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
621
|
-
|
|
622
|
-
const optIn = await client.getTelemetryOptIn();
|
|
623
|
-
|
|
624
|
-
expect(optIn).toEqual({ enabled: false, updatedAt: null });
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it("F26: maps a malformed files/list response to VaultClientError", async () => {
|
|
628
|
-
fetchSpy.mockResolvedValueOnce(
|
|
629
|
-
jsonResponse(200, {
|
|
630
|
-
objects: [
|
|
631
|
-
{
|
|
632
|
-
key: "shared/docs/a.txt",
|
|
633
|
-
size: 123,
|
|
634
|
-
lastModified: "2026-05-20T12:00:00.000Z",
|
|
635
|
-
permission: "read",
|
|
636
|
-
},
|
|
637
|
-
],
|
|
638
|
-
cursor: null,
|
|
639
|
-
truncated: false,
|
|
640
|
-
}),
|
|
641
|
-
);
|
|
642
|
-
|
|
643
|
-
await expect(client.listFiles("cmp_abc")).rejects.toMatchObject({
|
|
644
|
-
name: "VaultClientError",
|
|
645
|
-
statusCode: 502,
|
|
646
|
-
});
|
|
647
|
-
});
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
describe("VaultClient identity bootstrap", () => {
|
|
651
|
-
let client: VaultClient;
|
|
652
|
-
let fetchSpy: MockInstance<typeof fetch>;
|
|
653
|
-
|
|
654
|
-
beforeEach(() => {
|
|
655
|
-
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
656
|
-
fetchSpy.mockResolvedValue(jsonResponse(200, {}));
|
|
657
|
-
client = new VaultClient({
|
|
658
|
-
apiUrl: "https://vault.test.example.com",
|
|
659
|
-
authToken: "test-token",
|
|
660
|
-
region: "us-east-1",
|
|
661
|
-
});
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
afterEach(() => {
|
|
665
|
-
fetchSpy.mockRestore();
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
it("entity.listByType GETs /entity/by-type/{type}", async () => {
|
|
669
|
-
fetchSpy.mockResolvedValueOnce(
|
|
670
|
-
jsonResponse(200, {
|
|
671
|
-
entities: [
|
|
672
|
-
{
|
|
673
|
-
uid: "ent_person_stefan",
|
|
674
|
-
slug: "stefan-johnson",
|
|
675
|
-
type: "person",
|
|
676
|
-
status: "active",
|
|
677
|
-
},
|
|
678
|
-
],
|
|
679
|
-
}),
|
|
680
|
-
);
|
|
681
|
-
|
|
682
|
-
const entities = await client.entity.listByType("person");
|
|
683
|
-
expect(entities).toHaveLength(1);
|
|
684
|
-
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
685
|
-
expect(url).toBe(
|
|
686
|
-
"https://vault.test.example.com/entity/by-type/person",
|
|
687
|
-
);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
it("entity.listByType returns [] when server omits the key", async () => {
|
|
691
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
692
|
-
const entities = await client.entity.listByType("person");
|
|
693
|
-
expect(entities).toEqual([]);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
it("ensureMyPersonEntity short-circuits when a person entity already exists", async () => {
|
|
697
|
-
fetchSpy.mockResolvedValueOnce(
|
|
698
|
-
jsonResponse(200, {
|
|
699
|
-
entities: [
|
|
700
|
-
{
|
|
701
|
-
uid: "ent_person_existing",
|
|
702
|
-
slug: "already-there",
|
|
703
|
-
type: "person",
|
|
704
|
-
status: "active",
|
|
705
|
-
},
|
|
706
|
-
],
|
|
707
|
-
}),
|
|
708
|
-
);
|
|
709
|
-
|
|
710
|
-
const person = await client.ensureMyPersonEntity({
|
|
711
|
-
ownerSub: "sub-abc",
|
|
712
|
-
displayName: "Stefan Johnson",
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
expect(person.uid).toBe("ent_person_existing");
|
|
716
|
-
// Only one HTTP call — list. No POST /entity.
|
|
717
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
it("ensureMyPersonEntity POSTs /entity with a slug derived from displayName when none exist", async () => {
|
|
721
|
-
fetchSpy
|
|
722
|
-
.mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
|
|
723
|
-
.mockResolvedValueOnce(
|
|
724
|
-
jsonResponse(200, {
|
|
725
|
-
entity: {
|
|
726
|
-
uid: "ent_person_new",
|
|
727
|
-
slug: "stefan-johnson",
|
|
728
|
-
type: "person",
|
|
729
|
-
status: "active",
|
|
730
|
-
},
|
|
731
|
-
}),
|
|
732
|
-
);
|
|
733
|
-
|
|
734
|
-
const person = await client.ensureMyPersonEntity({
|
|
735
|
-
ownerSub: "sub-abc",
|
|
736
|
-
displayName: "Stefan Johnson",
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
expect(person.uid).toBe("ent_person_new");
|
|
740
|
-
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
741
|
-
const [url, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
742
|
-
expect(url).toBe("https://vault.test.example.com/entity");
|
|
743
|
-
expect(init.method).toBe("POST");
|
|
744
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
745
|
-
type: "person",
|
|
746
|
-
name: "Stefan Johnson",
|
|
747
|
-
slug: "stefan-johnson",
|
|
748
|
-
});
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
it("ensureMyPersonEntity falls back to user-<sub-suffix> when displayName slugifies to empty", async () => {
|
|
752
|
-
fetchSpy
|
|
753
|
-
.mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
|
|
754
|
-
.mockResolvedValueOnce(
|
|
755
|
-
jsonResponse(200, {
|
|
756
|
-
entity: {
|
|
757
|
-
uid: "ent_person_new",
|
|
758
|
-
slug: "user-12345678",
|
|
759
|
-
type: "person",
|
|
760
|
-
status: "active",
|
|
761
|
-
},
|
|
762
|
-
}),
|
|
763
|
-
);
|
|
764
|
-
|
|
765
|
-
await client.ensureMyPersonEntity({
|
|
766
|
-
ownerSub: "sub-abcdef12345678",
|
|
767
|
-
displayName: "!!!",
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
const [, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
771
|
-
const body = JSON.parse(init.body as string);
|
|
772
|
-
expect(body.slug).toBe("user-12345678");
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
it("listByType_roundtrips_createdAt", async () => {
|
|
776
|
-
fetchSpy.mockResolvedValueOnce(
|
|
777
|
-
jsonResponse(200, {
|
|
778
|
-
entities: [
|
|
779
|
-
{
|
|
780
|
-
uid: "prs_x",
|
|
781
|
-
slug: "alice",
|
|
782
|
-
type: "person",
|
|
783
|
-
status: "active",
|
|
784
|
-
createdAt: "2026-01-01T00:00:00Z",
|
|
785
|
-
},
|
|
786
|
-
],
|
|
787
|
-
}),
|
|
788
|
-
);
|
|
789
|
-
|
|
790
|
-
const entities = await client.entity.listByType("person");
|
|
791
|
-
expect(entities).toHaveLength(1);
|
|
792
|
-
expect(entities[0].createdAt).toBe("2026-01-01T00:00:00Z");
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
it("ensureMyPersonEntity_picks_oldest_when_multiple", async () => {
|
|
796
|
-
fetchSpy.mockResolvedValueOnce(
|
|
797
|
-
jsonResponse(200, {
|
|
798
|
-
entities: [
|
|
799
|
-
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-03-01T00:00:00Z" },
|
|
800
|
-
{ uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" },
|
|
801
|
-
{ uid: "prs_c", slug: "c", type: "person", status: "active", createdAt: "2026-06-01T00:00:00Z" },
|
|
802
|
-
],
|
|
803
|
-
}),
|
|
804
|
-
);
|
|
805
|
-
|
|
806
|
-
const person = await client.ensureMyPersonEntity({
|
|
807
|
-
ownerSub: "sub-multi",
|
|
808
|
-
displayName: "Multi User",
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
expect(person.uid).toBe("prs_a");
|
|
812
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
it("ensureMyPersonEntity_handles_missing_createdAt_deterministically", async () => {
|
|
816
|
-
fetchSpy.mockResolvedValueOnce(
|
|
817
|
-
jsonResponse(200, {
|
|
818
|
-
entities: [
|
|
819
|
-
{ uid: "prs_z", slug: "z", type: "person", status: "active" },
|
|
820
|
-
{ uid: "prs_a", slug: "a", type: "person", status: "active" },
|
|
821
|
-
],
|
|
822
|
-
}),
|
|
823
|
-
);
|
|
824
|
-
|
|
825
|
-
const person = await client.ensureMyPersonEntity({
|
|
826
|
-
ownerSub: "sub-nodates",
|
|
827
|
-
displayName: "No Dates User",
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
// Both missing createdAt → "" tie, uid tiebreak selects prs_a
|
|
831
|
-
expect(person.uid).toBe("prs_a");
|
|
832
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
it("pickCanonicalPersonEntity_picks_oldest_tiebreak_uid", () => {
|
|
836
|
-
const list: EntityInfo[] = [
|
|
837
|
-
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
|
|
838
|
-
{ uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
839
|
-
];
|
|
840
|
-
// Older createdAt wins
|
|
841
|
-
expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
|
|
842
|
-
|
|
843
|
-
// Same createdAt: uid tiebreak (lexicographic ascending)
|
|
844
|
-
const tieList: EntityInfo[] = [
|
|
845
|
-
{ uid: "prs_z", slug: "z", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
846
|
-
{ uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
847
|
-
];
|
|
848
|
-
expect(pickCanonicalPersonEntity(tieList)?.uid).toBe("prs_a");
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
it("pickCanonicalPersonEntity_handles_missing_createdAt_deterministically", () => {
|
|
852
|
-
// undefined createdAt coalesces to "" which sorts before any ISO date string.
|
|
853
|
-
// So the entity with undefined createdAt wins on the createdAt comparison.
|
|
854
|
-
// When two entities both lack createdAt, uid tiebreak applies.
|
|
855
|
-
const list: EntityInfo[] = [
|
|
856
|
-
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
857
|
-
{ uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo, // no createdAt
|
|
858
|
-
];
|
|
859
|
-
// "" < "2026-..." so prs_a (undefined→"") wins
|
|
860
|
-
expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
|
|
861
|
-
|
|
862
|
-
// Both undefined: uid tiebreak
|
|
863
|
-
const bothUndefined: EntityInfo[] = [
|
|
864
|
-
{ uid: "prs_z", slug: "z", type: "person", status: "active" } as EntityInfo,
|
|
865
|
-
{ uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo,
|
|
866
|
-
];
|
|
867
|
-
expect(pickCanonicalPersonEntity(bothUndefined)?.uid).toBe("prs_a");
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
it("pickCanonicalPersonEntity_filters_out_non_person_types", () => {
|
|
871
|
-
const mixed: EntityInfo[] = [
|
|
872
|
-
{ uid: "cmp_x", slug: "x", type: "company", status: "active", createdAt: "2025-01-01T00:00:00Z" } as EntityInfo,
|
|
873
|
-
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
|
|
874
|
-
];
|
|
875
|
-
const result = pickCanonicalPersonEntity(mixed);
|
|
876
|
-
expect(result?.uid).toBe("prs_b");
|
|
877
|
-
expect(result?.type).toBe("person");
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
it("sts.vend roundtrips POST /sts/vend with companyUid", async () => {
|
|
881
|
-
fetchSpy.mockResolvedValueOnce(
|
|
882
|
-
jsonResponse(200, {
|
|
883
|
-
credentials: {
|
|
884
|
-
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
885
|
-
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
886
|
-
sessionToken: "FwoGZXIvYXdzEBY...",
|
|
887
|
-
},
|
|
888
|
-
expiresAt: "2026-01-01T01:00:00.000Z",
|
|
889
|
-
}),
|
|
890
|
-
);
|
|
891
|
-
|
|
892
|
-
const result = await client.sts.vend({ companyUid: "cmp_x" });
|
|
893
|
-
|
|
894
|
-
expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
|
|
895
|
-
expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
|
|
896
|
-
expect(typeof result.expiresAt).toBe("string");
|
|
897
|
-
|
|
898
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
899
|
-
expect(url).toBe("https://vault.test.example.com/sts/vend");
|
|
900
|
-
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
901
|
-
expect(JSON.parse(init.body as string)).toEqual({ companyUid: "cmp_x" });
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
it("vendSelf_roundtrip", async () => {
|
|
905
|
-
fetchSpy.mockResolvedValueOnce(
|
|
906
|
-
jsonResponse(200, {
|
|
907
|
-
credentials: {
|
|
908
|
-
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
909
|
-
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
910
|
-
sessionToken: "FwoGZXIvYXdzEBY...",
|
|
911
|
-
},
|
|
912
|
-
expiresAt: "2026-01-01T01:00:00.000Z",
|
|
913
|
-
}),
|
|
914
|
-
);
|
|
915
|
-
|
|
916
|
-
const result = await client.sts.vendSelf({ personUid: "prs_x" });
|
|
917
|
-
|
|
918
|
-
expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
|
|
919
|
-
expect(result.credentials.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
|
|
920
|
-
expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
|
|
921
|
-
expect(typeof result.expiresAt).toBe("string");
|
|
922
|
-
|
|
923
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
924
|
-
expect(url).toBe("https://vault.test.example.com/sts/vend-self");
|
|
925
|
-
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
926
|
-
expect(JSON.parse(init.body as string)).toEqual({ personUid: "prs_x" });
|
|
927
|
-
});
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
// ---------------------------------------------------------------------------
|
|
931
|
-
// Browse-vs-sync SDK methods (US-004)
|
|
932
|
-
//
|
|
933
|
-
// listMyExplicitGrants, getMembershipSyncConfig, setMembershipSyncConfig
|
|
934
|
-
// wrap the US-002/US-003 hq-pro endpoints. Tests assert URL shape, payload
|
|
935
|
-
// shape, error mapping (401/404/network), and the empty-grants fallback
|
|
936
|
-
// that lets call sites treat "no grants" as a normal state.
|
|
937
|
-
// ---------------------------------------------------------------------------
|
|
938
|
-
|
|
939
|
-
describe("listMyExplicitGrants (US-004)", () => {
|
|
940
|
-
it("GETs /v1/files/grants with the company query param and returns grants[]", async () => {
|
|
941
|
-
const grant: ExplicitGrant = {
|
|
942
|
-
companyUid: "cmp_abc",
|
|
943
|
-
path: "shared/docs",
|
|
944
|
-
permission: "read",
|
|
945
|
-
source: "person",
|
|
946
|
-
};
|
|
947
|
-
fetchSpy.mockResolvedValueOnce(
|
|
948
|
-
jsonResponse(200, {
|
|
949
|
-
grants: [grant],
|
|
950
|
-
computedAt: "2026-05-20T12:00:00.000Z",
|
|
951
|
-
}),
|
|
952
|
-
);
|
|
953
|
-
|
|
954
|
-
const grants = await client.listMyExplicitGrants("cmp_abc");
|
|
955
|
-
|
|
956
|
-
expect(grants).toEqual([grant]);
|
|
957
|
-
|
|
958
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
959
|
-
expect(url).toBe(
|
|
960
|
-
"https://vault.test.example.com/v1/files/grants?company=cmp_abc",
|
|
961
|
-
);
|
|
962
|
-
expect(init.method).toBe("GET");
|
|
963
|
-
expect(init.body).toBeUndefined();
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
it("parses creator-sourced grants", async () => {
|
|
967
|
-
const grant: ExplicitGrant = {
|
|
968
|
-
companyUid: "cmp_abc",
|
|
969
|
-
path: "personal/psn_creator",
|
|
970
|
-
permission: "admin",
|
|
971
|
-
source: "creator",
|
|
972
|
-
};
|
|
973
|
-
fetchSpy.mockResolvedValueOnce(
|
|
974
|
-
jsonResponse(200, {
|
|
975
|
-
grants: [grant],
|
|
976
|
-
computedAt: "2026-05-20T12:00:00.000Z",
|
|
977
|
-
}),
|
|
978
|
-
);
|
|
979
|
-
|
|
980
|
-
const grants = await client.listMyExplicitGrants("cmp_abc");
|
|
981
|
-
|
|
982
|
-
expect(grants[0]?.source).toBe("creator");
|
|
983
|
-
expect(grants).toEqual([grant]);
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
it("URL-encodes the companyUid query parameter", async () => {
|
|
987
|
-
fetchSpy.mockResolvedValueOnce(
|
|
988
|
-
jsonResponse(200, { grants: [], computedAt: "2026-05-20T12:00:00.000Z" }),
|
|
989
|
-
);
|
|
990
|
-
|
|
991
|
-
await client.listMyExplicitGrants("cmp/with spaces&weird=chars");
|
|
992
|
-
|
|
993
|
-
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
994
|
-
expect(url).toBe(
|
|
995
|
-
"https://vault.test.example.com/v1/files/grants?company=cmp%2Fwith%20spaces%26weird%3Dchars",
|
|
996
|
-
);
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
it("returns [] when the server omits the grants key (empty graph)", async () => {
|
|
1000
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1001
|
-
jsonResponse(200, { computedAt: "2026-05-20T12:00:00.000Z" }),
|
|
1002
|
-
);
|
|
1003
|
-
|
|
1004
|
-
const grants = await client.listMyExplicitGrants("cmp_abc");
|
|
1005
|
-
expect(grants).toEqual([]);
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
it("maps 401 to VaultAuthError", async () => {
|
|
1009
|
-
fetchSpy.mockImplementation(() =>
|
|
1010
|
-
Promise.resolve(
|
|
1011
|
-
jsonResponse(401, { error: "Missing or invalid authorization token" }),
|
|
1012
|
-
),
|
|
1013
|
-
);
|
|
1014
|
-
|
|
1015
|
-
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
1016
|
-
VaultAuthError,
|
|
1017
|
-
);
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
it("maps 404 to VaultNotFoundError", async () => {
|
|
1021
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1022
|
-
jsonResponse(404, { error: "Unknown route" }),
|
|
1023
|
-
);
|
|
1024
|
-
|
|
1025
|
-
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
1026
|
-
VaultNotFoundError,
|
|
1027
|
-
);
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
it("surfaces transport errors after exhausting retries", async () => {
|
|
1031
|
-
fetchSpy.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
1032
|
-
|
|
1033
|
-
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
1034
|
-
/ECONNREFUSED/,
|
|
1035
|
-
);
|
|
1036
|
-
// 1 initial + 3 retries = 4 attempts on a persistent network error.
|
|
1037
|
-
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
1038
|
-
});
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
describe("getMembershipSyncConfig (US-004)", () => {
|
|
1042
|
-
it("GETs /v1/memberships/{id}/sync-config and returns the row verbatim", async () => {
|
|
1043
|
-
const row: MembershipSyncConfig = {
|
|
1044
|
-
membershipId: "psn_1#cmp_abc",
|
|
1045
|
-
syncMode: "custom",
|
|
1046
|
-
customPaths: ["shared/docs", "personal/psn_1"],
|
|
1047
|
-
isDefault: false,
|
|
1048
|
-
updatedAt: "2026-05-20T12:00:00.000Z",
|
|
1049
|
-
updatedBy: "psn_admin",
|
|
1050
|
-
};
|
|
1051
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, row));
|
|
1052
|
-
|
|
1053
|
-
const result = await client.getMembershipSyncConfig("psn_1#cmp_abc");
|
|
1054
|
-
|
|
1055
|
-
expect(result).toEqual(row);
|
|
1056
|
-
|
|
1057
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1058
|
-
expect(url).toBe(
|
|
1059
|
-
// `#` in the membershipKey MUST be URL-encoded — otherwise the
|
|
1060
|
-
// server sees a fragment, not a path segment.
|
|
1061
|
-
"https://vault.test.example.com/v1/memberships/psn_1%23cmp_abc/sync-config",
|
|
1062
|
-
);
|
|
1063
|
-
expect(init.method).toBe("GET");
|
|
1064
|
-
expect(init.body).toBeUndefined();
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
it("returns the default row (isDefault: true) without updatedAt/updatedBy", async () => {
|
|
1068
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1069
|
-
jsonResponse(200, {
|
|
1070
|
-
membershipId: "psn_1#cmp_abc",
|
|
1071
|
-
syncMode: "all",
|
|
1072
|
-
isDefault: true,
|
|
1073
|
-
}),
|
|
1074
|
-
);
|
|
1075
|
-
|
|
1076
|
-
const result = await client.getMembershipSyncConfig("psn_1#cmp_abc");
|
|
1077
|
-
expect(result.isDefault).toBe(true);
|
|
1078
|
-
expect(result.updatedAt).toBeUndefined();
|
|
1079
|
-
expect(result.updatedBy).toBeUndefined();
|
|
1080
|
-
});
|
|
1081
|
-
|
|
1082
|
-
it("maps 401 to VaultAuthError", async () => {
|
|
1083
|
-
fetchSpy.mockImplementation(() =>
|
|
1084
|
-
Promise.resolve(jsonResponse(401, { error: "Token expired" })),
|
|
1085
|
-
);
|
|
1086
|
-
|
|
1087
|
-
await expect(
|
|
1088
|
-
client.getMembershipSyncConfig("psn_1#cmp_abc"),
|
|
1089
|
-
).rejects.toThrow(VaultAuthError);
|
|
1090
|
-
});
|
|
1091
|
-
|
|
1092
|
-
it("maps 404 to VaultNotFoundError (membership tombstoned or missing)", async () => {
|
|
1093
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1094
|
-
jsonResponse(404, {
|
|
1095
|
-
error: "Membership not found or not active: psn_1#cmp_abc",
|
|
1096
|
-
}),
|
|
1097
|
-
);
|
|
1098
|
-
|
|
1099
|
-
await expect(
|
|
1100
|
-
client.getMembershipSyncConfig("psn_1#cmp_abc"),
|
|
1101
|
-
).rejects.toThrow(VaultNotFoundError);
|
|
1102
|
-
});
|
|
1103
|
-
|
|
1104
|
-
it("surfaces network errors after retry exhaustion", async () => {
|
|
1105
|
-
fetchSpy.mockRejectedValue(new Error("socket hang up"));
|
|
1106
|
-
|
|
1107
|
-
await expect(
|
|
1108
|
-
client.getMembershipSyncConfig("psn_1#cmp_abc"),
|
|
1109
|
-
).rejects.toThrow(/socket hang up/);
|
|
1110
|
-
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
1111
|
-
});
|
|
1112
|
-
});
|
|
1113
|
-
|
|
1114
|
-
describe("setMembershipSyncConfig (US-004)", () => {
|
|
1115
|
-
it("PUTs the partial body to /v1/memberships/{id}/sync-config", async () => {
|
|
1116
|
-
const persisted: MembershipSyncConfig = {
|
|
1117
|
-
membershipId: "psn_1#cmp_abc",
|
|
1118
|
-
syncMode: "custom",
|
|
1119
|
-
customPaths: ["shared/docs"],
|
|
1120
|
-
isDefault: false,
|
|
1121
|
-
updatedAt: "2026-05-20T12:00:00.000Z",
|
|
1122
|
-
updatedBy: "psn_1",
|
|
1123
|
-
};
|
|
1124
|
-
fetchSpy.mockResolvedValueOnce(jsonResponse(200, persisted));
|
|
1125
|
-
|
|
1126
|
-
const result = await client.setMembershipSyncConfig("psn_1#cmp_abc", {
|
|
1127
|
-
syncMode: "custom",
|
|
1128
|
-
customPaths: ["shared/docs"],
|
|
1129
|
-
});
|
|
1130
|
-
|
|
1131
|
-
expect(result).toEqual(persisted);
|
|
1132
|
-
|
|
1133
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1134
|
-
expect(url).toBe(
|
|
1135
|
-
"https://vault.test.example.com/v1/memberships/psn_1%23cmp_abc/sync-config",
|
|
1136
|
-
);
|
|
1137
|
-
expect(init.method).toBe("PUT");
|
|
1138
|
-
const headers = init.headers as Record<string, string>;
|
|
1139
|
-
expect(headers["Content-Type"]).toBe("application/json");
|
|
1140
|
-
expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
|
|
1141
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
1142
|
-
syncMode: "custom",
|
|
1143
|
-
customPaths: ["shared/docs"],
|
|
1144
|
-
});
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
it("supports the shared mode without customPaths", async () => {
|
|
1148
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1149
|
-
jsonResponse(200, {
|
|
1150
|
-
membershipId: "psn_1#cmp_abc",
|
|
1151
|
-
syncMode: "shared",
|
|
1152
|
-
isDefault: false,
|
|
1153
|
-
updatedAt: "2026-05-20T12:00:00.000Z",
|
|
1154
|
-
updatedBy: "psn_1",
|
|
1155
|
-
}),
|
|
1156
|
-
);
|
|
1157
|
-
|
|
1158
|
-
const result = await client.setMembershipSyncConfig("psn_1#cmp_abc", {
|
|
1159
|
-
syncMode: "shared",
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
expect(result.syncMode).toBe("shared");
|
|
1163
|
-
expect(result.customPaths).toBeUndefined();
|
|
1164
|
-
|
|
1165
|
-
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1166
|
-
expect(JSON.parse(init.body as string)).toEqual({ syncMode: "shared" });
|
|
1167
|
-
});
|
|
1168
|
-
|
|
1169
|
-
it("maps 401 to VaultAuthError", async () => {
|
|
1170
|
-
fetchSpy.mockImplementation(() =>
|
|
1171
|
-
Promise.resolve(jsonResponse(401, { error: "Token expired" })),
|
|
1172
|
-
);
|
|
1173
|
-
|
|
1174
|
-
await expect(
|
|
1175
|
-
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
1176
|
-
).rejects.toThrow(VaultAuthError);
|
|
1177
|
-
});
|
|
1178
|
-
|
|
1179
|
-
it("maps 404 to VaultNotFoundError when the membership is gone", async () => {
|
|
1180
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1181
|
-
jsonResponse(404, { error: "Membership not found" }),
|
|
1182
|
-
);
|
|
1183
|
-
|
|
1184
|
-
await expect(
|
|
1185
|
-
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
1186
|
-
).rejects.toThrow(VaultNotFoundError);
|
|
1187
|
-
});
|
|
1188
|
-
|
|
1189
|
-
it("maps 409 to VaultConflictError", async () => {
|
|
1190
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1191
|
-
jsonResponse(409, { error: "Concurrent write conflict" }),
|
|
1192
|
-
);
|
|
1193
|
-
|
|
1194
|
-
await expect(
|
|
1195
|
-
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
1196
|
-
).rejects.toThrow(VaultConflictError);
|
|
1197
|
-
});
|
|
1198
|
-
|
|
1199
|
-
it("surfaces network errors after retry exhaustion", async () => {
|
|
1200
|
-
fetchSpy.mockRejectedValue(new Error("ETIMEDOUT"));
|
|
1201
|
-
|
|
1202
|
-
await expect(
|
|
1203
|
-
client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
|
|
1204
|
-
).rejects.toThrow(/ETIMEDOUT/);
|
|
1205
|
-
const syncConfigCalls = fetchSpy.mock.calls.filter(([url]) =>
|
|
1206
|
-
String(url).includes("/v1/memberships/psn_1%23cmp_abc/sync-config"),
|
|
1207
|
-
);
|
|
1208
|
-
expect(syncConfigCalls).toHaveLength(4);
|
|
1209
|
-
});
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
// ---------------------------------------------------------------------------
|
|
1213
|
-
// Refreshable authToken getter
|
|
1214
|
-
//
|
|
1215
|
-
// Regression for the personal-sync 401: a captured `authToken` string can
|
|
1216
|
-
// outlive Cognito's 60-min access token TTL during long multi-company
|
|
1217
|
-
// fanouts, causing mid-flight `refreshEntityContext` calls to 401. The
|
|
1218
|
-
// getter form lets every request resolve the latest token from disk.
|
|
1219
|
-
// ---------------------------------------------------------------------------
|
|
1220
|
-
|
|
1221
|
-
describe("authToken getter (refreshable token)", () => {
|
|
1222
|
-
// Response bodies can only be read once, so each mock call needs a fresh
|
|
1223
|
-
// Response. Using mockImplementation (not mockResolvedValue) ensures a new
|
|
1224
|
-
// Response instance per request — same pattern as the retry tests above.
|
|
1225
|
-
function alwaysOk(body: unknown): void {
|
|
1226
|
-
fetchSpy.mockImplementation(() =>
|
|
1227
|
-
Promise.resolve(jsonResponse(200, body)),
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
it("calls the getter on every request", async () => {
|
|
1232
|
-
const getter = vi.fn(async () => "first-token");
|
|
1233
|
-
const c = new VaultClient({
|
|
1234
|
-
apiUrl: "https://vault.test.example.com",
|
|
1235
|
-
authToken: getter,
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
alwaysOk({ members: [] });
|
|
1239
|
-
|
|
1240
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1241
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1242
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1243
|
-
|
|
1244
|
-
expect(getter).toHaveBeenCalledTimes(3);
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
it("picks up a rotated token without recreating the client", async () => {
|
|
1248
|
-
let current = "stale-token";
|
|
1249
|
-
const c = new VaultClient({
|
|
1250
|
-
apiUrl: "https://vault.test.example.com",
|
|
1251
|
-
authToken: async () => current,
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
alwaysOk({ members: [] });
|
|
1255
|
-
|
|
1256
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1257
|
-
const [, firstInit] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1258
|
-
expect((firstInit.headers as Record<string, string>).Authorization).toBe(
|
|
1259
|
-
"Bearer stale-token",
|
|
1260
|
-
);
|
|
1261
|
-
|
|
1262
|
-
// Simulate the menubar refreshing ~/.hq/cognito-tokens.json mid-flight.
|
|
1263
|
-
current = "fresh-token";
|
|
1264
|
-
|
|
1265
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1266
|
-
const [, secondInit] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
1267
|
-
expect((secondInit.headers as Record<string, string>).Authorization).toBe(
|
|
1268
|
-
"Bearer fresh-token",
|
|
1269
|
-
);
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
it("supports a sync getter (returns plain string, not a promise)", async () => {
|
|
1273
|
-
const c = new VaultClient({
|
|
1274
|
-
apiUrl: "https://vault.test.example.com",
|
|
1275
|
-
authToken: () => "sync-token",
|
|
1276
|
-
});
|
|
1277
|
-
|
|
1278
|
-
alwaysOk({ members: [] });
|
|
1279
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1280
|
-
|
|
1281
|
-
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1282
|
-
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
1283
|
-
"Bearer sync-token",
|
|
1284
|
-
);
|
|
1285
|
-
});
|
|
1286
|
-
|
|
1287
|
-
it("static-string authToken still works (back-compat)", async () => {
|
|
1288
|
-
const c = new VaultClient({
|
|
1289
|
-
apiUrl: "https://vault.test.example.com",
|
|
1290
|
-
authToken: "static-token",
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
alwaysOk({ members: [] });
|
|
1294
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1295
|
-
await c.listMembersOfCompany("cmp_abc");
|
|
1296
|
-
|
|
1297
|
-
const calls = fetchSpy.mock.calls as [string, RequestInit][];
|
|
1298
|
-
for (const [, init] of calls) {
|
|
1299
|
-
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
1300
|
-
"Bearer static-token",
|
|
1301
|
-
);
|
|
1302
|
-
}
|
|
1303
|
-
});
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
// ---------------------------------------------------------------------------
|
|
1307
|
-
// Raw vend (POST /vend, purpose-aware after US-009)
|
|
1308
|
-
// ---------------------------------------------------------------------------
|
|
1309
|
-
|
|
1310
|
-
describe("vend (POST /vend, purpose-aware)", () => {
|
|
1311
|
-
it("POSTs to /vend with paths/operations/purpose and returns credentials + policySize", async () => {
|
|
1312
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1313
|
-
jsonResponse(200, {
|
|
1314
|
-
credentials: {
|
|
1315
|
-
accessKeyId: "AKIA-BROWSE",
|
|
1316
|
-
secretAccessKey: "secret",
|
|
1317
|
-
sessionToken: "token",
|
|
1318
|
-
expiration: "2026-05-20T13:00:00.000Z",
|
|
1319
|
-
},
|
|
1320
|
-
paths: ["shared/docs/"],
|
|
1321
|
-
operations: "read-only",
|
|
1322
|
-
purpose: "browse",
|
|
1323
|
-
policySize: 412,
|
|
1324
|
-
requestId: "req_xyz",
|
|
1325
|
-
}),
|
|
1326
|
-
);
|
|
1327
|
-
|
|
1328
|
-
const out = await client.vend({
|
|
1329
|
-
paths: ["shared/docs/"],
|
|
1330
|
-
operations: "read-only",
|
|
1331
|
-
purpose: "browse",
|
|
1332
|
-
});
|
|
1333
|
-
|
|
1334
|
-
expect(out.purpose).toBe("browse");
|
|
1335
|
-
expect(out.policySize).toBe(412);
|
|
1336
|
-
expect(out.credentials.accessKeyId).toBe("AKIA-BROWSE");
|
|
1337
|
-
expect(out.credentials.sessionToken).toBe("token");
|
|
1338
|
-
|
|
1339
|
-
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1340
|
-
expect(url).toBe("https://vault.test.example.com/vend");
|
|
1341
|
-
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
1342
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
1343
|
-
paths: ["shared/docs/"],
|
|
1344
|
-
operations: "read-only",
|
|
1345
|
-
purpose: "browse",
|
|
1346
|
-
});
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
it("forwards purpose='sync' verbatim (no client-side defaulting)", async () => {
|
|
1350
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1351
|
-
jsonResponse(200, {
|
|
1352
|
-
credentials: {
|
|
1353
|
-
accessKeyId: "k",
|
|
1354
|
-
secretAccessKey: "s",
|
|
1355
|
-
sessionToken: "t",
|
|
1356
|
-
expiration: "2026-05-20T13:00:00.000Z",
|
|
1357
|
-
},
|
|
1358
|
-
paths: ["shared/"],
|
|
1359
|
-
operations: "read-only",
|
|
1360
|
-
purpose: "sync",
|
|
1361
|
-
policySize: 200,
|
|
1362
|
-
}),
|
|
1363
|
-
);
|
|
1364
|
-
|
|
1365
|
-
await client.vend({
|
|
1366
|
-
paths: ["shared/"],
|
|
1367
|
-
operations: "read-only",
|
|
1368
|
-
purpose: "sync",
|
|
1369
|
-
duration: 1800,
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
1373
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
1374
|
-
paths: ["shared/"],
|
|
1375
|
-
operations: "read-only",
|
|
1376
|
-
purpose: "sync",
|
|
1377
|
-
duration: 1800,
|
|
1378
|
-
});
|
|
1379
|
-
});
|
|
1380
|
-
});
|