@indigoai-us/hq-cloud 5.1.0 → 5.1.9
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-runner.d.ts +134 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +360 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +648 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +59 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +72 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +160 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +804 -0
- package/src/bin/sync-runner.ts +499 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +94 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +563 -0
- package/src/vault-client.ts +478 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- package/test/share-sync.integration.test.ts +210 -0
|
@@ -0,0 +1,563 @@
|
|
|
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
|
+
} from "./vault-client.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
22
|
+
return new Response(JSON.stringify(body), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function textResponse(status: number, body: string): Response {
|
|
29
|
+
return new Response(body, { status });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TEST_CONFIG = {
|
|
33
|
+
apiUrl: "https://vault.test.example.com",
|
|
34
|
+
authToken: "test-jwt-token-123",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let client: VaultClient;
|
|
38
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
client = new VaultClient(TEST_CONFIG);
|
|
42
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Auth header injection
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe("auth header injection", () => {
|
|
54
|
+
it("sends Bearer token on every request", async () => {
|
|
55
|
+
fetchSpy.mockResolvedValueOnce(
|
|
56
|
+
jsonResponse(200, { members: [] }),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await client.listMembersOfCompany("cmp_abc");
|
|
60
|
+
|
|
61
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
62
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
63
|
+
const headers = init.headers as Record<string, string>;
|
|
64
|
+
expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sets Content-Type on POST requests", async () => {
|
|
68
|
+
fetchSpy.mockResolvedValueOnce(
|
|
69
|
+
jsonResponse(200, { membership: {}, inviteToken: "tok" }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await client.createInvite({
|
|
73
|
+
companyUid: "cmp_abc",
|
|
74
|
+
role: "member",
|
|
75
|
+
invitedBy: "psn_xyz",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
79
|
+
const headers = init.headers as Record<string, string>;
|
|
80
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Error mapping
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe("error mapping", () => {
|
|
89
|
+
it("maps 401 to VaultAuthError", async () => {
|
|
90
|
+
fetchSpy.mockResolvedValueOnce(
|
|
91
|
+
jsonResponse(401, { message: "Token expired" }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("maps 403 to VaultPermissionDeniedError", async () => {
|
|
98
|
+
fetchSpy.mockResolvedValueOnce(
|
|
99
|
+
jsonResponse(403, { message: "Admin required" }),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultPermissionDeniedError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("maps 404 to VaultNotFoundError", async () => {
|
|
106
|
+
fetchSpy.mockResolvedValueOnce(
|
|
107
|
+
jsonResponse(404, { message: "Not found" }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await expect(client.entity.get("cmp_missing")).rejects.toThrow(VaultNotFoundError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("maps 409 to VaultConflictError", async () => {
|
|
114
|
+
fetchSpy.mockResolvedValueOnce(
|
|
115
|
+
jsonResponse(409, { message: "Already accepted" }),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
await expect(client.acceptInvite("tok", "psn_abc")).rejects.toThrow(VaultConflictError);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("preserves error message from response body", async () => {
|
|
122
|
+
fetchSpy.mockResolvedValueOnce(
|
|
123
|
+
jsonResponse(403, { message: "Only admins can invite" }),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await client.createInvite({
|
|
128
|
+
companyUid: "cmp_abc",
|
|
129
|
+
role: "member",
|
|
130
|
+
invitedBy: "psn_xyz",
|
|
131
|
+
});
|
|
132
|
+
expect.fail("Should have thrown");
|
|
133
|
+
} catch (err) {
|
|
134
|
+
expect(err).toBeInstanceOf(VaultPermissionDeniedError);
|
|
135
|
+
expect((err as VaultPermissionDeniedError).message).toBe("Only admins can invite");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles non-JSON error bodies gracefully", async () => {
|
|
140
|
+
fetchSpy.mockResolvedValueOnce(
|
|
141
|
+
textResponse(404, "Not Found"),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await client.entity.findBySlug("company", "test");
|
|
146
|
+
expect.fail("Should have thrown");
|
|
147
|
+
} catch (err) {
|
|
148
|
+
expect(err).toBeInstanceOf(VaultNotFoundError);
|
|
149
|
+
expect((err as VaultNotFoundError).message).toBe("Not Found");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Retry behavior
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("retry behavior", () => {
|
|
159
|
+
it("retries on 429 and succeeds on second attempt", async () => {
|
|
160
|
+
fetchSpy
|
|
161
|
+
.mockResolvedValueOnce(jsonResponse(429, { message: "Rate limited" }))
|
|
162
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
163
|
+
|
|
164
|
+
const result = await client.listMembersOfCompany("cmp_abc");
|
|
165
|
+
expect(result).toHaveLength(1);
|
|
166
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("retries on 500 and succeeds on third attempt", async () => {
|
|
170
|
+
fetchSpy
|
|
171
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "Internal error" }))
|
|
172
|
+
.mockResolvedValueOnce(jsonResponse(502, { message: "Bad gateway" }))
|
|
173
|
+
.mockResolvedValueOnce(jsonResponse(200, { membership: { role: "admin" } }));
|
|
174
|
+
|
|
175
|
+
const result = await client.updateRole({
|
|
176
|
+
membershipKey: "psn_1#cmp_abc",
|
|
177
|
+
newRole: "admin",
|
|
178
|
+
updaterUid: "psn_owner",
|
|
179
|
+
companyUid: "cmp_abc",
|
|
180
|
+
});
|
|
181
|
+
expect(result.role).toBe("admin");
|
|
182
|
+
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws after exhausting all retries on persistent 500", async () => {
|
|
186
|
+
fetchSpy.mockImplementation(() =>
|
|
187
|
+
Promise.resolve(jsonResponse(500, { message: "Down" })),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultClientError);
|
|
191
|
+
// 1 initial + 3 retries = 4
|
|
192
|
+
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not retry on 401 (non-transient)", async () => {
|
|
196
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(401, { message: "Expired" }));
|
|
197
|
+
|
|
198
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
199
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not retry on 403 (non-transient)", async () => {
|
|
203
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
|
|
204
|
+
|
|
205
|
+
await expect(client.createInvite({
|
|
206
|
+
companyUid: "cmp_abc",
|
|
207
|
+
role: "member",
|
|
208
|
+
invitedBy: "psn_xyz",
|
|
209
|
+
})).rejects.toThrow(VaultPermissionDeniedError);
|
|
210
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("retries on network errors (fetch throws)", async () => {
|
|
214
|
+
fetchSpy
|
|
215
|
+
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
|
216
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
217
|
+
|
|
218
|
+
const result = await client.listMembersOfCompany("cmp_abc");
|
|
219
|
+
expect(result).toEqual([]);
|
|
220
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// API surface
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe("API surface", () => {
|
|
229
|
+
it("createInvite sends correct body and URL", async () => {
|
|
230
|
+
fetchSpy.mockResolvedValueOnce(
|
|
231
|
+
jsonResponse(200, {
|
|
232
|
+
membership: { membershipKey: "psn_1#cmp_abc", role: "member", status: "pending" },
|
|
233
|
+
inviteToken: "tok_secure_random",
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const result = await client.createInvite({
|
|
238
|
+
companyUid: "cmp_abc",
|
|
239
|
+
role: "member",
|
|
240
|
+
invitedBy: "psn_owner",
|
|
241
|
+
inviteeEmail: "alice@example.com",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.inviteToken).toBe("tok_secure_random");
|
|
245
|
+
|
|
246
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
247
|
+
expect(url).toBe("https://vault.test.example.com/membership/invite");
|
|
248
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
249
|
+
companyUid: "cmp_abc",
|
|
250
|
+
role: "member",
|
|
251
|
+
invitedBy: "psn_owner",
|
|
252
|
+
inviteeEmail: "alice@example.com",
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("acceptInvite sends token and personUid", async () => {
|
|
257
|
+
fetchSpy.mockResolvedValueOnce(
|
|
258
|
+
jsonResponse(200, {
|
|
259
|
+
membership: { status: "active", role: "member" },
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const result = await client.acceptInvite("tok_abc", "psn_invitee");
|
|
264
|
+
expect(result.membership.status).toBe("active");
|
|
265
|
+
|
|
266
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
267
|
+
expect(url).toBe("https://vault.test.example.com/membership/accept");
|
|
268
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
269
|
+
token: "tok_abc",
|
|
270
|
+
personUid: "psn_invitee",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("updateRole sends correct payload", async () => {
|
|
275
|
+
fetchSpy.mockResolvedValueOnce(
|
|
276
|
+
jsonResponse(200, {
|
|
277
|
+
membership: { role: "guest", allowedPrefixes: ["docs/"] },
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const result = await client.updateRole({
|
|
282
|
+
membershipKey: "psn_1#cmp_abc",
|
|
283
|
+
newRole: "guest",
|
|
284
|
+
allowedPrefixes: ["docs/"],
|
|
285
|
+
updaterUid: "psn_admin",
|
|
286
|
+
companyUid: "cmp_abc",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.role).toBe("guest");
|
|
290
|
+
expect(result.allowedPrefixes).toEqual(["docs/"]);
|
|
291
|
+
|
|
292
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
293
|
+
expect(url).toBe("https://vault.test.example.com/membership/role");
|
|
294
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
295
|
+
membershipKey: "psn_1#cmp_abc",
|
|
296
|
+
newRole: "guest",
|
|
297
|
+
allowedPrefixes: ["docs/"],
|
|
298
|
+
updaterUid: "psn_admin",
|
|
299
|
+
companyUid: "cmp_abc",
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("entity.get calls correct URL", async () => {
|
|
304
|
+
fetchSpy.mockResolvedValueOnce(
|
|
305
|
+
jsonResponse(200, {
|
|
306
|
+
entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const entity = await client.entity.get("cmp_abc");
|
|
311
|
+
expect(entity.slug).toBe("acme");
|
|
312
|
+
|
|
313
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
314
|
+
expect(url).toBe("https://vault.test.example.com/entity/cmp_abc");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("entity.findBySlug calls correct URL", async () => {
|
|
318
|
+
fetchSpy.mockResolvedValueOnce(
|
|
319
|
+
jsonResponse(200, {
|
|
320
|
+
entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const entity = await client.entity.findBySlug("company", "acme");
|
|
325
|
+
expect(entity.uid).toBe("cmp_abc");
|
|
326
|
+
|
|
327
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
328
|
+
expect(url).toBe("https://vault.test.example.com/entity/by-slug/company/acme");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("revokeMembership calls POST /membership/revoke with companyUid", async () => {
|
|
332
|
+
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
333
|
+
|
|
334
|
+
await client.revokeMembership("psn_1#cmp_abc", "cmp_abc");
|
|
335
|
+
|
|
336
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
337
|
+
expect(url).toBe("https://vault.test.example.com/membership/revoke");
|
|
338
|
+
expect(init.method).toBe("POST");
|
|
339
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
340
|
+
membershipKey: "psn_1#cmp_abc",
|
|
341
|
+
companyUid: "cmp_abc",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("listPendingInvites calls correct URL", async () => {
|
|
346
|
+
fetchSpy.mockResolvedValueOnce(
|
|
347
|
+
jsonResponse(200, { invites: [{ status: "pending" }] }),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const invites = await client.listPendingInvites("cmp_abc");
|
|
351
|
+
expect(invites).toHaveLength(1);
|
|
352
|
+
|
|
353
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
354
|
+
expect(url).toBe("https://vault.test.example.com/membership/company/cmp_abc/pending");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("listMyMemberships hits GET /membership/me and unwraps memberships[]", async () => {
|
|
358
|
+
fetchSpy.mockResolvedValueOnce(
|
|
359
|
+
jsonResponse(200, {
|
|
360
|
+
memberships: [
|
|
361
|
+
{ membershipKey: "psn_1#cmp_a", role: "owner", status: "active" },
|
|
362
|
+
{ membershipKey: "psn_1#cmp_b", role: "member", status: "active" },
|
|
363
|
+
],
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const memberships = await client.listMyMemberships();
|
|
368
|
+
expect(memberships).toHaveLength(2);
|
|
369
|
+
expect(memberships[0].membershipKey).toBe("psn_1#cmp_a");
|
|
370
|
+
|
|
371
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
372
|
+
expect(url).toBe("https://vault.test.example.com/membership/me");
|
|
373
|
+
expect(init.method).toBe("GET");
|
|
374
|
+
// No body on GET.
|
|
375
|
+
expect(init.body).toBeUndefined();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("listMyMemberships returns [] for callers with no person entity (bootstrap case)", async () => {
|
|
379
|
+
// Server returns 200 + { memberships: [] } rather than 404 when the
|
|
380
|
+
// caller is signed in but hasn't been provisioned yet. The SDK must
|
|
381
|
+
// surface an empty array, NOT throw — hq-sync-runner relies on this
|
|
382
|
+
// to emit `setup-needed` without catching HTTP errors.
|
|
383
|
+
fetchSpy.mockResolvedValueOnce(
|
|
384
|
+
jsonResponse(200, { memberships: [] }),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const memberships = await client.listMyMemberships();
|
|
388
|
+
expect(memberships).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("listMyPendingInvitesByEmail hits GET /membership/pending-by-email", async () => {
|
|
392
|
+
fetchSpy.mockResolvedValueOnce(
|
|
393
|
+
jsonResponse(200, {
|
|
394
|
+
invites: [
|
|
395
|
+
{
|
|
396
|
+
membershipKey: "email:stefan@getindigo.ai#cmp_abc",
|
|
397
|
+
companyUid: "cmp_abc",
|
|
398
|
+
role: "owner",
|
|
399
|
+
invitedBy: "sub-admin",
|
|
400
|
+
invitedAt: "2026-04-20T00:00:00Z",
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const invites = await client.listMyPendingInvitesByEmail();
|
|
407
|
+
expect(invites).toHaveLength(1);
|
|
408
|
+
expect(invites[0].companyUid).toBe("cmp_abc");
|
|
409
|
+
|
|
410
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
411
|
+
expect(url).toBe(
|
|
412
|
+
"https://vault.test.example.com/membership/pending-by-email",
|
|
413
|
+
);
|
|
414
|
+
expect(init.method).toBe("GET");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("listMyPendingInvitesByEmail returns [] when server omits the key", async () => {
|
|
418
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
419
|
+
const invites = await client.listMyPendingInvitesByEmail();
|
|
420
|
+
expect(invites).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("claimPendingInvitesByEmail POSTs personUid to /membership/claim-by-email", async () => {
|
|
424
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
425
|
+
|
|
426
|
+
await client.claimPendingInvitesByEmail("ent_person_stefan");
|
|
427
|
+
|
|
428
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
429
|
+
expect(url).toBe(
|
|
430
|
+
"https://vault.test.example.com/membership/claim-by-email",
|
|
431
|
+
);
|
|
432
|
+
expect(init.method).toBe("POST");
|
|
433
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
434
|
+
personUid: "ent_person_stefan",
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe("VaultClient identity bootstrap", () => {
|
|
440
|
+
let client: VaultClient;
|
|
441
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
442
|
+
|
|
443
|
+
beforeEach(() => {
|
|
444
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
445
|
+
fetchSpy.mockResolvedValue(jsonResponse(200, {}));
|
|
446
|
+
client = new VaultClient({
|
|
447
|
+
apiUrl: "https://vault.test.example.com",
|
|
448
|
+
authToken: "test-token",
|
|
449
|
+
region: "us-east-1",
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
afterEach(() => {
|
|
454
|
+
fetchSpy.mockRestore();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("entity.listByType GETs /entity/by-type/{type}", async () => {
|
|
458
|
+
fetchSpy.mockResolvedValueOnce(
|
|
459
|
+
jsonResponse(200, {
|
|
460
|
+
entities: [
|
|
461
|
+
{
|
|
462
|
+
uid: "ent_person_stefan",
|
|
463
|
+
slug: "stefan-johnson",
|
|
464
|
+
type: "person",
|
|
465
|
+
status: "active",
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const entities = await client.entity.listByType("person");
|
|
472
|
+
expect(entities).toHaveLength(1);
|
|
473
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
474
|
+
expect(url).toBe(
|
|
475
|
+
"https://vault.test.example.com/entity/by-type/person",
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("entity.listByType returns [] when server omits the key", async () => {
|
|
480
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
481
|
+
const entities = await client.entity.listByType("person");
|
|
482
|
+
expect(entities).toEqual([]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("ensureMyPersonEntity short-circuits when a person entity already exists", async () => {
|
|
486
|
+
fetchSpy.mockResolvedValueOnce(
|
|
487
|
+
jsonResponse(200, {
|
|
488
|
+
entities: [
|
|
489
|
+
{
|
|
490
|
+
uid: "ent_person_existing",
|
|
491
|
+
slug: "already-there",
|
|
492
|
+
type: "person",
|
|
493
|
+
status: "active",
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const person = await client.ensureMyPersonEntity({
|
|
500
|
+
ownerSub: "sub-abc",
|
|
501
|
+
displayName: "Stefan Johnson",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(person.uid).toBe("ent_person_existing");
|
|
505
|
+
// Only one HTTP call — list. No POST /entity.
|
|
506
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("ensureMyPersonEntity POSTs /entity with a slug derived from displayName when none exist", async () => {
|
|
510
|
+
fetchSpy
|
|
511
|
+
.mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
|
|
512
|
+
.mockResolvedValueOnce(
|
|
513
|
+
jsonResponse(200, {
|
|
514
|
+
entity: {
|
|
515
|
+
uid: "ent_person_new",
|
|
516
|
+
slug: "stefan-johnson",
|
|
517
|
+
type: "person",
|
|
518
|
+
status: "active",
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const person = await client.ensureMyPersonEntity({
|
|
524
|
+
ownerSub: "sub-abc",
|
|
525
|
+
displayName: "Stefan Johnson",
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(person.uid).toBe("ent_person_new");
|
|
529
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
530
|
+
const [url, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
531
|
+
expect(url).toBe("https://vault.test.example.com/entity");
|
|
532
|
+
expect(init.method).toBe("POST");
|
|
533
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
534
|
+
type: "person",
|
|
535
|
+
name: "Stefan Johnson",
|
|
536
|
+
slug: "stefan-johnson",
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("ensureMyPersonEntity falls back to user-<sub-suffix> when displayName slugifies to empty", async () => {
|
|
541
|
+
fetchSpy
|
|
542
|
+
.mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
|
|
543
|
+
.mockResolvedValueOnce(
|
|
544
|
+
jsonResponse(200, {
|
|
545
|
+
entity: {
|
|
546
|
+
uid: "ent_person_new",
|
|
547
|
+
slug: "user-12345678",
|
|
548
|
+
type: "person",
|
|
549
|
+
status: "active",
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
await client.ensureMyPersonEntity({
|
|
555
|
+
ownerSub: "sub-abcdef12345678",
|
|
556
|
+
displayName: "!!!",
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const [, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
560
|
+
const body = JSON.parse(init.body as string);
|
|
561
|
+
expect(body.slug).toBe("user-12345678");
|
|
562
|
+
});
|
|
563
|
+
});
|