@indigoai-us/hq-cli 5.5.5 → 5.6.0
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/commands/cloud-provision.d.ts +196 -0
- package/dist/commands/cloud-provision.js +417 -0
- package/dist/index.js +9 -2
- package/package.json +1 -1
- package/src/commands/cloud-provision.test.ts +652 -0
- package/src/commands/cloud-provision.ts +638 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq cloud provision company <slug>` (cloud-provision.ts).
|
|
3
|
+
*
|
|
4
|
+
* Coverage targets:
|
|
5
|
+
* - validateSlug — pure validation
|
|
6
|
+
* - validateManifestAndDir — fs reads against tmp manifest + company dir
|
|
7
|
+
* - patchManifest — atomic YAML mutation, preserves siblings, idempotent
|
|
8
|
+
* - writeCompanyConfig — atomic JSON write, creates parent .hq/, idempotent
|
|
9
|
+
* - createDefaultVaultClient — HTTP surface with mocked global fetch
|
|
10
|
+
* - provisionCompany — full orchestrator with all dependencies injected
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as yaml from "js-yaml";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
ProvisionError,
|
|
21
|
+
type ProvisionResult,
|
|
22
|
+
type VaultClient,
|
|
23
|
+
type VaultEntity,
|
|
24
|
+
companyConfigPath,
|
|
25
|
+
companyDirPath,
|
|
26
|
+
createDefaultVaultClient,
|
|
27
|
+
manifestPath,
|
|
28
|
+
patchManifest,
|
|
29
|
+
provisionCompany,
|
|
30
|
+
validateManifestAndDir,
|
|
31
|
+
validateSlug,
|
|
32
|
+
writeCompanyConfig,
|
|
33
|
+
} from "./cloud-provision.js";
|
|
34
|
+
|
|
35
|
+
// ── Test fixtures ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
let tmpRoot: string;
|
|
38
|
+
|
|
39
|
+
function seedManifest(
|
|
40
|
+
root: string,
|
|
41
|
+
companies: Record<string, Record<string, unknown> | null> = {
|
|
42
|
+
indigo: { status: "active" },
|
|
43
|
+
},
|
|
44
|
+
): void {
|
|
45
|
+
const mPath = manifestPath(root);
|
|
46
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
47
|
+
fs.writeFileSync(mPath, yaml.dump({ companies }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function seedCompanyDir(root: string, slug: string): void {
|
|
51
|
+
fs.mkdirSync(companyDirPath(root, slug), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cloud-provision-test-"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── validateSlug ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("validateSlug", () => {
|
|
66
|
+
it("accepts a valid slug", () => {
|
|
67
|
+
expect(() => validateSlug("indigo")).not.toThrow();
|
|
68
|
+
expect(() => validateSlug("acme-co")).not.toThrow();
|
|
69
|
+
expect(() => validateSlug("acme_co")).not.toThrow();
|
|
70
|
+
expect(() => validateSlug("acme.co")).not.toThrow();
|
|
71
|
+
expect(() => validateSlug("ACME123")).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects an empty slug with code 2", () => {
|
|
75
|
+
expect(() => validateSlug("")).toThrowError(ProvisionError);
|
|
76
|
+
try {
|
|
77
|
+
validateSlug("");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
expect(e).toBeInstanceOf(ProvisionError);
|
|
80
|
+
expect((e as ProvisionError).code).toBe(2);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects whitespace-only slug", () => {
|
|
85
|
+
expect(() => validateSlug(" ")).toThrowError(ProvisionError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects slugs with invalid characters", () => {
|
|
89
|
+
expect(() => validateSlug("acme co")).toThrowError(/Invalid slug/);
|
|
90
|
+
expect(() => validateSlug("acme/co")).toThrowError(/Invalid slug/);
|
|
91
|
+
expect(() => validateSlug("acme!")).toThrowError(/Invalid slug/);
|
|
92
|
+
expect(() => validateSlug("acme$co")).toThrowError(/Invalid slug/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects the reserved "personal" slug', () => {
|
|
96
|
+
expect(() => validateSlug("personal")).toThrowError(/reserved/);
|
|
97
|
+
try {
|
|
98
|
+
validateSlug("personal");
|
|
99
|
+
} catch (e) {
|
|
100
|
+
expect((e as ProvisionError).code).toBe(2);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── validateManifestAndDir ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe("validateManifestAndDir", () => {
|
|
108
|
+
it("returns parsed manifest on the happy path", () => {
|
|
109
|
+
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
110
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
111
|
+
const { manifest } = validateManifestAndDir(tmpRoot, "indigo");
|
|
112
|
+
expect(manifest.companies?.indigo).toEqual({ status: "active" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws code 2 if manifest.yaml is missing", () => {
|
|
116
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
117
|
+
try {
|
|
118
|
+
validateManifestAndDir(tmpRoot, "indigo");
|
|
119
|
+
expect.fail("should have thrown");
|
|
120
|
+
} catch (e) {
|
|
121
|
+
expect(e).toBeInstanceOf(ProvisionError);
|
|
122
|
+
expect((e as ProvisionError).code).toBe(2);
|
|
123
|
+
expect((e as ProvisionError).message).toMatch(/manifest\.yaml not found/);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("throws code 2 if manifest is malformed (no .companies)", () => {
|
|
128
|
+
const mPath = manifestPath(tmpRoot);
|
|
129
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
130
|
+
fs.writeFileSync(mPath, "not_companies: 'oops'\n");
|
|
131
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
132
|
+
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
|
|
133
|
+
/malformed/,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("throws code 2 if slug is missing from manifest", () => {
|
|
138
|
+
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
139
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
140
|
+
try {
|
|
141
|
+
validateManifestAndDir(tmpRoot, "indigo");
|
|
142
|
+
expect.fail("should have thrown");
|
|
143
|
+
} catch (e) {
|
|
144
|
+
expect((e as ProvisionError).code).toBe(2);
|
|
145
|
+
expect((e as ProvisionError).message).toMatch(/not found under \.companies/);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("throws code 2 if company is status: archived", () => {
|
|
150
|
+
seedManifest(tmpRoot, { indigo: { status: "archived" } });
|
|
151
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
152
|
+
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
|
|
153
|
+
/archived/,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throws code 2 if company directory is missing", () => {
|
|
158
|
+
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
159
|
+
// Note: NOT calling seedCompanyDir
|
|
160
|
+
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
|
|
161
|
+
/does not exist/,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("accepts a manifest entry that is null (no fields)", () => {
|
|
166
|
+
seedManifest(tmpRoot, { indigo: null });
|
|
167
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
168
|
+
expect(() => validateManifestAndDir(tmpRoot, "indigo")).not.toThrow();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── patchManifest ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe("patchManifest", () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
seedManifest(tmpRoot, {
|
|
177
|
+
indigo: { status: "active", existing_field: "kept" },
|
|
178
|
+
other: { status: "active", cloud_uid: "cmp_other" },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("writes cloud_uid + bucket_name under the target slug", () => {
|
|
183
|
+
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
184
|
+
const after = yaml.load(
|
|
185
|
+
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
186
|
+
) as { companies: Record<string, Record<string, unknown>> };
|
|
187
|
+
expect(after.companies.indigo.cloud_uid).toBe("cmp_01H");
|
|
188
|
+
expect(after.companies.indigo.bucket_name).toBe("hq-vault-cmp-01H");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("preserves sibling fields on the target entry", () => {
|
|
192
|
+
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
193
|
+
const after = yaml.load(
|
|
194
|
+
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
195
|
+
) as { companies: Record<string, Record<string, unknown>> };
|
|
196
|
+
expect(after.companies.indigo.status).toBe("active");
|
|
197
|
+
expect(after.companies.indigo.existing_field).toBe("kept");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("preserves other companies untouched", () => {
|
|
201
|
+
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
202
|
+
const after = yaml.load(
|
|
203
|
+
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
204
|
+
) as { companies: Record<string, Record<string, unknown>> };
|
|
205
|
+
expect(after.companies.other).toEqual({
|
|
206
|
+
status: "active",
|
|
207
|
+
cloud_uid: "cmp_other",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("is idempotent — same inputs produce identical bytes", () => {
|
|
212
|
+
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
213
|
+
const first = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
214
|
+
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
215
|
+
const second = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
216
|
+
expect(second).toBe(first);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not leave a .tmp file behind on success", () => {
|
|
220
|
+
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
221
|
+
const dir = fs.readdirSync(path.dirname(manifestPath(tmpRoot)));
|
|
222
|
+
expect(dir.filter((f) => f.includes(".tmp."))).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("creates the .companies entry if the slug had a null value", () => {
|
|
226
|
+
seedManifest(tmpRoot, { newco: null });
|
|
227
|
+
patchManifest(tmpRoot, "newco", "cmp_NEW", "hq-vault-cmp-NEW");
|
|
228
|
+
const after = yaml.load(
|
|
229
|
+
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
230
|
+
) as { companies: Record<string, Record<string, unknown>> };
|
|
231
|
+
expect(after.companies.newco).toEqual({
|
|
232
|
+
cloud_uid: "cmp_NEW",
|
|
233
|
+
bucket_name: "hq-vault-cmp-NEW",
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── writeCompanyConfig ───────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
describe("writeCompanyConfig", () => {
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("writes valid JSON with all four fields", () => {
|
|
246
|
+
writeCompanyConfig(tmpRoot, "indigo", {
|
|
247
|
+
companyUid: "cmp_01H",
|
|
248
|
+
companySlug: "indigo",
|
|
249
|
+
bucketName: "hq-vault-cmp-01H",
|
|
250
|
+
vaultApiUrl: "https://vault.example.com",
|
|
251
|
+
});
|
|
252
|
+
const cPath = companyConfigPath(tmpRoot, "indigo");
|
|
253
|
+
const parsed = JSON.parse(fs.readFileSync(cPath, "utf-8"));
|
|
254
|
+
expect(parsed).toEqual({
|
|
255
|
+
companyUid: "cmp_01H",
|
|
256
|
+
companySlug: "indigo",
|
|
257
|
+
bucketName: "hq-vault-cmp-01H",
|
|
258
|
+
vaultApiUrl: "https://vault.example.com",
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("creates the parent .hq/ directory if missing", () => {
|
|
263
|
+
const hqDir = path.join(companyDirPath(tmpRoot, "indigo"), ".hq");
|
|
264
|
+
expect(fs.existsSync(hqDir)).toBe(false);
|
|
265
|
+
writeCompanyConfig(tmpRoot, "indigo", {
|
|
266
|
+
companyUid: "cmp_01H",
|
|
267
|
+
companySlug: "indigo",
|
|
268
|
+
bucketName: "b",
|
|
269
|
+
vaultApiUrl: "u",
|
|
270
|
+
});
|
|
271
|
+
expect(fs.existsSync(hqDir)).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("is idempotent — same inputs produce identical bytes", () => {
|
|
275
|
+
const config = {
|
|
276
|
+
companyUid: "cmp_01H",
|
|
277
|
+
companySlug: "indigo",
|
|
278
|
+
bucketName: "b",
|
|
279
|
+
vaultApiUrl: "u",
|
|
280
|
+
};
|
|
281
|
+
writeCompanyConfig(tmpRoot, "indigo", config);
|
|
282
|
+
const first = fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8");
|
|
283
|
+
writeCompanyConfig(tmpRoot, "indigo", config);
|
|
284
|
+
const second = fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8");
|
|
285
|
+
expect(second).toBe(first);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not leave a .tmp file behind on success", () => {
|
|
289
|
+
writeCompanyConfig(tmpRoot, "indigo", {
|
|
290
|
+
companyUid: "cmp_01H",
|
|
291
|
+
companySlug: "indigo",
|
|
292
|
+
bucketName: "b",
|
|
293
|
+
vaultApiUrl: "u",
|
|
294
|
+
});
|
|
295
|
+
const hqDir = path.join(companyDirPath(tmpRoot, "indigo"), ".hq");
|
|
296
|
+
const entries = fs.readdirSync(hqDir);
|
|
297
|
+
expect(entries.filter((f) => f.includes(".tmp."))).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ── createDefaultVaultClient ─────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
describe("createDefaultVaultClient", () => {
|
|
304
|
+
const apiUrl = "https://vault.example.com";
|
|
305
|
+
const token = "test-token";
|
|
306
|
+
|
|
307
|
+
it("findCompanyBySlug returns the entity on 200", async () => {
|
|
308
|
+
const entity: VaultEntity = {
|
|
309
|
+
uid: "cmp_01H",
|
|
310
|
+
type: "company",
|
|
311
|
+
slug: "indigo",
|
|
312
|
+
name: "Indigo",
|
|
313
|
+
bucketName: "hq-vault-cmp-01H",
|
|
314
|
+
};
|
|
315
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
316
|
+
new Response(JSON.stringify({ entity }), { status: 200 }),
|
|
317
|
+
);
|
|
318
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
319
|
+
const out = await client.findCompanyBySlug("indigo");
|
|
320
|
+
expect(out).toEqual(entity);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("findCompanyBySlug returns null on 404", async () => {
|
|
324
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
325
|
+
new Response("not found", { status: 404 }),
|
|
326
|
+
);
|
|
327
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
328
|
+
expect(await client.findCompanyBySlug("missing")).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("findCompanyBySlug throws ProvisionError code 1 on 500", async () => {
|
|
332
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
333
|
+
new Response("kaboom", { status: 500, statusText: "Server Error" }),
|
|
334
|
+
);
|
|
335
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
336
|
+
try {
|
|
337
|
+
await client.findCompanyBySlug("indigo");
|
|
338
|
+
expect.fail("should have thrown");
|
|
339
|
+
} catch (e) {
|
|
340
|
+
expect(e).toBeInstanceOf(ProvisionError);
|
|
341
|
+
expect((e as ProvisionError).code).toBe(1);
|
|
342
|
+
expect((e as ProvisionError).message).toMatch(/500/);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("findCompanyBySlug throws code 1 if 200 has no entity body", async () => {
|
|
347
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
348
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
349
|
+
);
|
|
350
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
351
|
+
await expect(client.findCompanyBySlug("indigo")).rejects.toThrowError(
|
|
352
|
+
/no entity body/,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("createCompanyEntity returns the entity on 201", async () => {
|
|
357
|
+
const entity: VaultEntity = {
|
|
358
|
+
uid: "cmp_01H",
|
|
359
|
+
type: "company",
|
|
360
|
+
slug: "indigo",
|
|
361
|
+
name: "Indigo",
|
|
362
|
+
bucketName: "hq-vault-cmp-01H",
|
|
363
|
+
};
|
|
364
|
+
const fetchSpy = vi
|
|
365
|
+
.spyOn(globalThis, "fetch")
|
|
366
|
+
.mockResolvedValueOnce(
|
|
367
|
+
new Response(JSON.stringify({ entity }), { status: 201 }),
|
|
368
|
+
);
|
|
369
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
370
|
+
const out = await client.createCompanyEntity({
|
|
371
|
+
slug: "indigo",
|
|
372
|
+
name: "Indigo",
|
|
373
|
+
});
|
|
374
|
+
expect(out).toEqual(entity);
|
|
375
|
+
// Verify request shape
|
|
376
|
+
const call = fetchSpy.mock.calls[0];
|
|
377
|
+
expect(call[0]).toBe(`${apiUrl}/v1/entities`);
|
|
378
|
+
expect((call[1] as RequestInit)?.method).toBe("POST");
|
|
379
|
+
const body = JSON.parse(((call[1] as RequestInit)?.body as string) ?? "{}");
|
|
380
|
+
expect(body).toEqual({ type: "company", slug: "indigo", name: "Indigo" });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("createCompanyEntity throws code 1 on 409 conflict", async () => {
|
|
384
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
385
|
+
new Response("conflict", { status: 409, statusText: "Conflict" }),
|
|
386
|
+
);
|
|
387
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
388
|
+
try {
|
|
389
|
+
await client.createCompanyEntity({ slug: "indigo", name: "Indigo" });
|
|
390
|
+
expect.fail("should have thrown");
|
|
391
|
+
} catch (e) {
|
|
392
|
+
expect((e as ProvisionError).code).toBe(1);
|
|
393
|
+
expect((e as ProvisionError).message).toMatch(/409/);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("createCompanyEntity sends ownerUid when provided", async () => {
|
|
398
|
+
const entity: VaultEntity = {
|
|
399
|
+
uid: "cmp_01H",
|
|
400
|
+
type: "company",
|
|
401
|
+
slug: "indigo",
|
|
402
|
+
name: "Indigo",
|
|
403
|
+
bucketName: "b",
|
|
404
|
+
};
|
|
405
|
+
const fetchSpy = vi
|
|
406
|
+
.spyOn(globalThis, "fetch")
|
|
407
|
+
.mockResolvedValueOnce(
|
|
408
|
+
new Response(JSON.stringify({ entity }), { status: 201 }),
|
|
409
|
+
);
|
|
410
|
+
const client = createDefaultVaultClient(apiUrl, token);
|
|
411
|
+
await client.createCompanyEntity({
|
|
412
|
+
slug: "indigo",
|
|
413
|
+
name: "Indigo",
|
|
414
|
+
ownerUid: "person_01H",
|
|
415
|
+
});
|
|
416
|
+
const body = JSON.parse(
|
|
417
|
+
((fetchSpy.mock.calls[0]?.[1] as RequestInit)?.body as string) ?? "{}",
|
|
418
|
+
);
|
|
419
|
+
expect(body.ownerUid).toBe("person_01H");
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ── provisionCompany (orchestrator) ──────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
describe("provisionCompany", () => {
|
|
426
|
+
const vaultApiUrl = "https://vault.example.com";
|
|
427
|
+
const accessToken = "test-token";
|
|
428
|
+
|
|
429
|
+
function setupValid(): void {
|
|
430
|
+
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
431
|
+
seedCompanyDir(tmpRoot, "indigo");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function makeVaultClient(overrides: Partial<VaultClient> = {}): VaultClient {
|
|
435
|
+
return {
|
|
436
|
+
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
437
|
+
createCompanyEntity: vi.fn(),
|
|
438
|
+
...overrides,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
it("happy path — entity not found → POST → manifest + config + sync", async () => {
|
|
443
|
+
setupValid();
|
|
444
|
+
const entity: VaultEntity = {
|
|
445
|
+
uid: "cmp_01H",
|
|
446
|
+
type: "company",
|
|
447
|
+
slug: "indigo",
|
|
448
|
+
name: "Indigo",
|
|
449
|
+
bucketName: "hq-vault-cmp-01H",
|
|
450
|
+
kmsKeyId: "key-123",
|
|
451
|
+
};
|
|
452
|
+
const vaultClient = makeVaultClient({
|
|
453
|
+
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
454
|
+
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
455
|
+
});
|
|
456
|
+
const runInitialSync = vi
|
|
457
|
+
.fn()
|
|
458
|
+
.mockResolvedValue({ filesUploaded: 7, bytesUploaded: 1024 });
|
|
459
|
+
|
|
460
|
+
const result = await provisionCompany({
|
|
461
|
+
slug: "indigo",
|
|
462
|
+
name: "Indigo",
|
|
463
|
+
hqRoot: tmpRoot,
|
|
464
|
+
vaultApiUrl,
|
|
465
|
+
vaultClient,
|
|
466
|
+
resolveAccessToken: async () => accessToken,
|
|
467
|
+
runInitialSync,
|
|
468
|
+
log: () => {},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(result).toEqual<ProvisionResult>({
|
|
472
|
+
ok: true,
|
|
473
|
+
company_slug: "indigo",
|
|
474
|
+
cloud_uid: "cmp_01H",
|
|
475
|
+
bucket_name: "hq-vault-cmp-01H",
|
|
476
|
+
vault_api_url: vaultApiUrl,
|
|
477
|
+
kms_key_id: "key-123",
|
|
478
|
+
created_entity: true,
|
|
479
|
+
manifest_patched: true,
|
|
480
|
+
config_written: true,
|
|
481
|
+
initial_sync: { ok: true, files_uploaded: 7, bytes_uploaded: 1024 },
|
|
482
|
+
});
|
|
483
|
+
// Manifest was actually patched on disk
|
|
484
|
+
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
485
|
+
companies: Record<string, Record<string, unknown>>;
|
|
486
|
+
};
|
|
487
|
+
expect(m.companies.indigo.cloud_uid).toBe("cmp_01H");
|
|
488
|
+
expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-01H");
|
|
489
|
+
// Config was written
|
|
490
|
+
const c = JSON.parse(
|
|
491
|
+
fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8"),
|
|
492
|
+
);
|
|
493
|
+
expect(c.companyUid).toBe("cmp_01H");
|
|
494
|
+
// POST happened
|
|
495
|
+
expect(vaultClient.createCompanyEntity).toHaveBeenCalledOnce();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("idempotent path — entity found → no POST → still patches + syncs → created_entity=false", async () => {
|
|
499
|
+
setupValid();
|
|
500
|
+
const entity: VaultEntity = {
|
|
501
|
+
uid: "cmp_01H",
|
|
502
|
+
type: "company",
|
|
503
|
+
slug: "indigo",
|
|
504
|
+
name: "Indigo",
|
|
505
|
+
bucketName: "hq-vault-cmp-01H",
|
|
506
|
+
kmsKeyId: null,
|
|
507
|
+
};
|
|
508
|
+
const vaultClient = makeVaultClient({
|
|
509
|
+
findCompanyBySlug: vi.fn().mockResolvedValue(entity),
|
|
510
|
+
createCompanyEntity: vi.fn(),
|
|
511
|
+
});
|
|
512
|
+
const result = await provisionCompany({
|
|
513
|
+
slug: "indigo",
|
|
514
|
+
hqRoot: tmpRoot,
|
|
515
|
+
vaultApiUrl,
|
|
516
|
+
vaultClient,
|
|
517
|
+
resolveAccessToken: async () => accessToken,
|
|
518
|
+
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
519
|
+
log: () => {},
|
|
520
|
+
});
|
|
521
|
+
expect(result.created_entity).toBe(false);
|
|
522
|
+
expect(result.kms_key_id).toBeNull();
|
|
523
|
+
expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
|
|
527
|
+
setupValid();
|
|
528
|
+
const entity: VaultEntity = {
|
|
529
|
+
uid: "cmp_01H",
|
|
530
|
+
type: "company",
|
|
531
|
+
slug: "indigo",
|
|
532
|
+
name: "Indigo",
|
|
533
|
+
// bucketName intentionally absent
|
|
534
|
+
};
|
|
535
|
+
const vaultClient = makeVaultClient({
|
|
536
|
+
findCompanyBySlug: vi.fn().mockResolvedValue(entity),
|
|
537
|
+
});
|
|
538
|
+
try {
|
|
539
|
+
await provisionCompany({
|
|
540
|
+
slug: "indigo",
|
|
541
|
+
hqRoot: tmpRoot,
|
|
542
|
+
vaultApiUrl,
|
|
543
|
+
vaultClient,
|
|
544
|
+
resolveAccessToken: async () => accessToken,
|
|
545
|
+
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
546
|
+
log: () => {},
|
|
547
|
+
});
|
|
548
|
+
expect.fail("should have thrown");
|
|
549
|
+
} catch (e) {
|
|
550
|
+
expect(e).toBeInstanceOf(ProvisionError);
|
|
551
|
+
expect((e as ProvisionError).code).toBe(1);
|
|
552
|
+
expect((e as ProvisionError).partial?.cloud_uid).toBe("cmp_01H");
|
|
553
|
+
expect((e as ProvisionError).partial?.manifest_patched).toBe(false);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("throws code 3 when initial sync fails — manifest + config STILL written", async () => {
|
|
558
|
+
setupValid();
|
|
559
|
+
const entity: VaultEntity = {
|
|
560
|
+
uid: "cmp_01H",
|
|
561
|
+
type: "company",
|
|
562
|
+
slug: "indigo",
|
|
563
|
+
name: "Indigo",
|
|
564
|
+
bucketName: "hq-vault-cmp-01H",
|
|
565
|
+
};
|
|
566
|
+
const vaultClient = makeVaultClient({
|
|
567
|
+
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
568
|
+
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
569
|
+
});
|
|
570
|
+
const runInitialSync = vi
|
|
571
|
+
.fn()
|
|
572
|
+
.mockRejectedValue(new Error("S3 timeout"));
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
await provisionCompany({
|
|
576
|
+
slug: "indigo",
|
|
577
|
+
hqRoot: tmpRoot,
|
|
578
|
+
vaultApiUrl,
|
|
579
|
+
vaultClient,
|
|
580
|
+
resolveAccessToken: async () => accessToken,
|
|
581
|
+
runInitialSync,
|
|
582
|
+
log: () => {},
|
|
583
|
+
});
|
|
584
|
+
expect.fail("should have thrown");
|
|
585
|
+
} catch (e) {
|
|
586
|
+
expect(e).toBeInstanceOf(ProvisionError);
|
|
587
|
+
expect((e as ProvisionError).code).toBe(3);
|
|
588
|
+
expect((e as ProvisionError).partial?.manifest_patched).toBe(true);
|
|
589
|
+
expect((e as ProvisionError).partial?.config_written).toBe(true);
|
|
590
|
+
expect((e as ProvisionError).partial?.initial_sync?.ok).toBe(false);
|
|
591
|
+
expect((e as ProvisionError).partial?.initial_sync?.error).toMatch(
|
|
592
|
+
/S3 timeout/,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
// Manifest WAS written despite the sync failure (matches partial=true)
|
|
596
|
+
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
597
|
+
companies: Record<string, Record<string, unknown>>;
|
|
598
|
+
};
|
|
599
|
+
expect(m.companies.indigo.cloud_uid).toBe("cmp_01H");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("throws code 2 on invalid slug before any vault call", async () => {
|
|
603
|
+
seedCompanyDir(tmpRoot, "indigo"); // dir exists but slug is invalid
|
|
604
|
+
const vaultClient = makeVaultClient();
|
|
605
|
+
try {
|
|
606
|
+
await provisionCompany({
|
|
607
|
+
slug: "personal",
|
|
608
|
+
hqRoot: tmpRoot,
|
|
609
|
+
vaultApiUrl,
|
|
610
|
+
vaultClient,
|
|
611
|
+
resolveAccessToken: async () => accessToken,
|
|
612
|
+
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
613
|
+
log: () => {},
|
|
614
|
+
});
|
|
615
|
+
expect.fail("should have thrown");
|
|
616
|
+
} catch (e) {
|
|
617
|
+
expect((e as ProvisionError).code).toBe(2);
|
|
618
|
+
expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("uses slug as the entity name when --name is omitted", async () => {
|
|
623
|
+
setupValid();
|
|
624
|
+
const entity: VaultEntity = {
|
|
625
|
+
uid: "cmp_01H",
|
|
626
|
+
type: "company",
|
|
627
|
+
slug: "indigo",
|
|
628
|
+
name: "indigo",
|
|
629
|
+
bucketName: "b",
|
|
630
|
+
};
|
|
631
|
+
const createSpy = vi.fn().mockResolvedValue(entity);
|
|
632
|
+
const vaultClient = makeVaultClient({
|
|
633
|
+
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
634
|
+
createCompanyEntity: createSpy,
|
|
635
|
+
});
|
|
636
|
+
await provisionCompany({
|
|
637
|
+
slug: "indigo",
|
|
638
|
+
// name omitted on purpose
|
|
639
|
+
hqRoot: tmpRoot,
|
|
640
|
+
vaultApiUrl,
|
|
641
|
+
vaultClient,
|
|
642
|
+
resolveAccessToken: async () => accessToken,
|
|
643
|
+
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
644
|
+
log: () => {},
|
|
645
|
+
});
|
|
646
|
+
expect(createSpy).toHaveBeenCalledWith({
|
|
647
|
+
slug: "indigo",
|
|
648
|
+
name: "indigo",
|
|
649
|
+
ownerUid: undefined,
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
});
|