@indigoai-us/hq-cli 5.77.13 → 5.78.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/CHANGELOG.md +34 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +51 -2
- package/dist/commands/pkg-install.js +43 -0
- package/dist/commands/run.js +8 -0
- package/dist/commands/secrets.js +125 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2247
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -71
- package/src/commands/pkg-install.ts +0 -215
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -184
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2037
- package/src/commands/secrets.ts +0 -2240
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- package/vitest.e2e.config.ts +0 -7
|
@@ -1,1117 +0,0 @@
|
|
|
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
|
-
ensureManifestEntryForProvision,
|
|
28
|
-
manifestPath,
|
|
29
|
-
patchManifest,
|
|
30
|
-
provisionCompany,
|
|
31
|
-
validateManifestAndDir,
|
|
32
|
-
validateSlug,
|
|
33
|
-
writeCompanyConfig,
|
|
34
|
-
} from "./cloud-provision.js";
|
|
35
|
-
|
|
36
|
-
// ── Test fixtures ────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
let tmpRoot: string;
|
|
39
|
-
|
|
40
|
-
function seedManifest(
|
|
41
|
-
root: string,
|
|
42
|
-
companies: Record<string, Record<string, unknown> | null> = {
|
|
43
|
-
indigo: { status: "active" },
|
|
44
|
-
},
|
|
45
|
-
): void {
|
|
46
|
-
const mPath = manifestPath(root);
|
|
47
|
-
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
48
|
-
fs.writeFileSync(mPath, yaml.dump({ companies }));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function seedCompanyDir(root: string, slug: string): void {
|
|
52
|
-
fs.mkdirSync(companyDirPath(root, slug), { recursive: true });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
beforeEach(() => {
|
|
56
|
-
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cloud-provision-test-"));
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
61
|
-
vi.restoreAllMocks();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// ── validateSlug ─────────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
describe("validateSlug", () => {
|
|
67
|
-
it("accepts a valid slug", () => {
|
|
68
|
-
expect(() => validateSlug("indigo")).not.toThrow();
|
|
69
|
-
expect(() => validateSlug("acme-co")).not.toThrow();
|
|
70
|
-
expect(() => validateSlug("acme_co")).not.toThrow();
|
|
71
|
-
expect(() => validateSlug("acme.co")).not.toThrow();
|
|
72
|
-
expect(() => validateSlug("ACME123")).not.toThrow();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("rejects an empty slug with code 2", () => {
|
|
76
|
-
expect(() => validateSlug("")).toThrowError(ProvisionError);
|
|
77
|
-
try {
|
|
78
|
-
validateSlug("");
|
|
79
|
-
} catch (e) {
|
|
80
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
81
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("rejects whitespace-only slug", () => {
|
|
86
|
-
expect(() => validateSlug(" ")).toThrowError(ProvisionError);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("rejects slugs with invalid characters", () => {
|
|
90
|
-
expect(() => validateSlug("acme co")).toThrowError(/Invalid slug/);
|
|
91
|
-
expect(() => validateSlug("acme/co")).toThrowError(/Invalid slug/);
|
|
92
|
-
expect(() => validateSlug("acme!")).toThrowError(/Invalid slug/);
|
|
93
|
-
expect(() => validateSlug("acme$co")).toThrowError(/Invalid slug/);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('rejects the path-traversal slug "."', () => {
|
|
97
|
-
try {
|
|
98
|
-
validateSlug(".");
|
|
99
|
-
expect.fail("should have thrown");
|
|
100
|
-
} catch (e) {
|
|
101
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
102
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
103
|
-
expect((e as ProvisionError).message).toMatch(/path-traversal/);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('rejects the path-traversal slug ".."', () => {
|
|
108
|
-
try {
|
|
109
|
-
validateSlug("..");
|
|
110
|
-
expect.fail("should have thrown");
|
|
111
|
-
} catch (e) {
|
|
112
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
113
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
114
|
-
expect((e as ProvisionError).message).toMatch(/path-traversal/);
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('rejects the reserved "personal" slug', () => {
|
|
119
|
-
expect(() => validateSlug("personal")).toThrowError(/reserved/);
|
|
120
|
-
try {
|
|
121
|
-
validateSlug("personal");
|
|
122
|
-
} catch (e) {
|
|
123
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// ── validateManifestAndDir ───────────────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
describe("validateManifestAndDir", () => {
|
|
131
|
-
it("returns parsed manifest on the happy path", () => {
|
|
132
|
-
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
133
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
134
|
-
const { manifest } = validateManifestAndDir(tmpRoot, "indigo");
|
|
135
|
-
expect(manifest.companies?.indigo).toEqual({ status: "active" });
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("throws code 2 if manifest.yaml is missing", () => {
|
|
139
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
140
|
-
try {
|
|
141
|
-
validateManifestAndDir(tmpRoot, "indigo");
|
|
142
|
-
expect.fail("should have thrown");
|
|
143
|
-
} catch (e) {
|
|
144
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
145
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
146
|
-
expect((e as ProvisionError).message).toMatch(/manifest\.yaml not found/);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("throws code 2 if manifest is malformed (no .companies)", () => {
|
|
151
|
-
const mPath = manifestPath(tmpRoot);
|
|
152
|
-
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
153
|
-
fs.writeFileSync(mPath, "not_companies: 'oops'\n");
|
|
154
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
155
|
-
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
|
|
156
|
-
/malformed/,
|
|
157
|
-
);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("throws code 2 if slug is missing from manifest", () => {
|
|
161
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
162
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
163
|
-
try {
|
|
164
|
-
validateManifestAndDir(tmpRoot, "indigo");
|
|
165
|
-
expect.fail("should have thrown");
|
|
166
|
-
} catch (e) {
|
|
167
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
168
|
-
expect((e as ProvisionError).message).toMatch(/not found under \.companies/);
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("throws code 2 if company is status: archived", () => {
|
|
173
|
-
seedManifest(tmpRoot, { indigo: { status: "archived" } });
|
|
174
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
175
|
-
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
|
|
176
|
-
/archived/,
|
|
177
|
-
);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("throws code 2 if company directory is missing", () => {
|
|
181
|
-
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
182
|
-
// Note: NOT calling seedCompanyDir
|
|
183
|
-
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
|
|
184
|
-
/does not exist/,
|
|
185
|
-
);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("accepts a manifest entry that is null (no fields)", () => {
|
|
189
|
-
seedManifest(tmpRoot, { indigo: null });
|
|
190
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
191
|
-
expect(() => validateManifestAndDir(tmpRoot, "indigo")).not.toThrow();
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ── ensureManifestEntryForProvision ──────────────────────────────────────────
|
|
196
|
-
|
|
197
|
-
describe("ensureManifestEntryForProvision", () => {
|
|
198
|
-
it("inserts an empty entry when slug is missing but company dir exists", () => {
|
|
199
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
200
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
201
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
202
|
-
const onDisk = yaml.load(
|
|
203
|
-
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
204
|
-
) as { companies: Record<string, unknown> };
|
|
205
|
-
expect(onDisk.companies.indigo).toEqual({});
|
|
206
|
-
expect(onDisk.companies.other).toEqual({ status: "active" });
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("validateManifestAndDir then succeeds for the auto-inserted slug", () => {
|
|
210
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
211
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
212
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
213
|
-
const { manifest } = validateManifestAndDir(tmpRoot, "indigo");
|
|
214
|
-
expect(manifest.companies?.indigo).toEqual({});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("is idempotent — re-run on an already-present entry is a no-op", () => {
|
|
218
|
-
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
219
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
220
|
-
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
221
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
222
|
-
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
223
|
-
expect(after).toBe(before);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("does NOT insert when company dir is missing (no typo masking)", () => {
|
|
227
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
228
|
-
// No seedCompanyDir — slug looks like a typo.
|
|
229
|
-
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
230
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
231
|
-
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
232
|
-
expect(after).toBe(before);
|
|
233
|
-
// Downstream validateManifestAndDir still throws — dir-missing wins over
|
|
234
|
-
// slug-missing in the error message because the dir check runs later, but
|
|
235
|
-
// either way the user gets exit 2 with no silent mutation.
|
|
236
|
-
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrow();
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("does NOT insert when companies/<slug> exists as a regular file (not a dir)", () => {
|
|
240
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
241
|
-
// Create a regular file at companies/indigo (not a directory). Without
|
|
242
|
-
// the isDirectory() guard, existsSync would return true and the helper
|
|
243
|
-
// would auto-insert — then provisionCompany would create the vault
|
|
244
|
-
// entity + patch manifest BEFORE writeCompanyConfig's mkdir failed with
|
|
245
|
-
// ENOTDIR. Verify no mutation.
|
|
246
|
-
const companiesDir = path.join(tmpRoot, "companies");
|
|
247
|
-
fs.mkdirSync(companiesDir, { recursive: true });
|
|
248
|
-
fs.writeFileSync(path.join(companiesDir, "indigo"), "not a directory");
|
|
249
|
-
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
250
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
251
|
-
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
252
|
-
expect(after).toBe(before);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("does NOT insert when manifest file is missing entirely", () => {
|
|
256
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
257
|
-
expect(() =>
|
|
258
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo"),
|
|
259
|
-
).not.toThrow();
|
|
260
|
-
expect(fs.existsSync(manifestPath(tmpRoot))).toBe(false);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("does NOT insert when manifest is malformed (no .companies map)", () => {
|
|
264
|
-
const mPath = manifestPath(tmpRoot);
|
|
265
|
-
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
266
|
-
const malformed = "not_companies: 'oops'\n";
|
|
267
|
-
fs.writeFileSync(mPath, malformed);
|
|
268
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
269
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
270
|
-
// File on disk unchanged — malformed manifests are left for validation
|
|
271
|
-
// to surface, not silently rewritten.
|
|
272
|
-
expect(fs.readFileSync(mPath, "utf-8")).toBe(malformed);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("preserves a null entry (does not promote null → {})", () => {
|
|
276
|
-
seedManifest(tmpRoot, { indigo: null });
|
|
277
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
278
|
-
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
279
|
-
const onDisk = yaml.load(
|
|
280
|
-
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
281
|
-
) as { companies: Record<string, unknown> };
|
|
282
|
-
expect(onDisk.companies.indigo).toBeNull();
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it('refuses to auto-insert for path-traversal slug "." (defense-in-depth)', () => {
|
|
286
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
287
|
-
// `companies/.` resolves to `companies/` itself, which DOES exist on
|
|
288
|
-
// disk — without the path-resolve guard, the helper would insert
|
|
289
|
-
// `".": {}` into the manifest. Verify it doesn't.
|
|
290
|
-
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
291
|
-
ensureManifestEntryForProvision(tmpRoot, ".");
|
|
292
|
-
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
293
|
-
expect(after).toBe(before);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it('refuses to auto-insert for path-traversal slug ".." (defense-in-depth)', () => {
|
|
297
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
298
|
-
// `companies/..` resolves to `tmpRoot` itself, which exists — without
|
|
299
|
-
// the path-resolve guard, the helper would insert `"..": {}` into the
|
|
300
|
-
// manifest. Verify it doesn't.
|
|
301
|
-
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
302
|
-
ensureManifestEntryForProvision(tmpRoot, "..");
|
|
303
|
-
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
304
|
-
expect(after).toBe(before);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("refuses to auto-insert when slug contains a path separator", () => {
|
|
308
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
309
|
-
// Even if validateSlug were bypassed, a slug like `foo/bar` would land
|
|
310
|
-
// in a nested path; the path-resolve guard rejects it.
|
|
311
|
-
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
312
|
-
ensureManifestEntryForProvision(tmpRoot, "foo/bar");
|
|
313
|
-
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
314
|
-
expect(after).toBe(before);
|
|
315
|
-
});
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
// ── patchManifest ────────────────────────────────────────────────────────────
|
|
319
|
-
|
|
320
|
-
describe("patchManifest", () => {
|
|
321
|
-
beforeEach(() => {
|
|
322
|
-
seedManifest(tmpRoot, {
|
|
323
|
-
indigo: { status: "active", existing_field: "kept" },
|
|
324
|
-
other: { status: "active", cloud_uid: "cmp_other" },
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("writes cloud_uid + bucket_name under the target slug", () => {
|
|
329
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
330
|
-
const after = yaml.load(
|
|
331
|
-
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
332
|
-
) as { companies: Record<string, Record<string, unknown>> };
|
|
333
|
-
expect(after.companies.indigo.cloud_uid).toBe("cmp_01H");
|
|
334
|
-
expect(after.companies.indigo.bucket_name).toBe("hq-vault-cmp-01H");
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it("preserves sibling fields on the target entry", () => {
|
|
338
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
339
|
-
const after = yaml.load(
|
|
340
|
-
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
341
|
-
) as { companies: Record<string, Record<string, unknown>> };
|
|
342
|
-
expect(after.companies.indigo.status).toBe("active");
|
|
343
|
-
expect(after.companies.indigo.existing_field).toBe("kept");
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it("preserves other companies untouched", () => {
|
|
347
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
348
|
-
const after = yaml.load(
|
|
349
|
-
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
350
|
-
) as { companies: Record<string, Record<string, unknown>> };
|
|
351
|
-
expect(after.companies.other).toEqual({
|
|
352
|
-
status: "active",
|
|
353
|
-
cloud_uid: "cmp_other",
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
it("is idempotent — same inputs produce identical bytes", () => {
|
|
358
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
359
|
-
const first = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
360
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
361
|
-
const second = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
362
|
-
expect(second).toBe(first);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("does not leave a .tmp file behind on success", () => {
|
|
366
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
367
|
-
const dir = fs.readdirSync(path.dirname(manifestPath(tmpRoot)));
|
|
368
|
-
expect(dir.filter((f) => f.includes(".tmp."))).toEqual([]);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("creates the .companies entry if the slug had a null value", () => {
|
|
372
|
-
seedManifest(tmpRoot, { newco: null });
|
|
373
|
-
patchManifest(tmpRoot, "newco", "cmp_NEW", "hq-vault-cmp-NEW");
|
|
374
|
-
const after = yaml.load(
|
|
375
|
-
fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
|
|
376
|
-
) as { companies: Record<string, Record<string, unknown>> };
|
|
377
|
-
expect(after.companies.newco).toEqual({
|
|
378
|
-
cloud_uid: "cmp_NEW",
|
|
379
|
-
bucket_name: "hq-vault-cmp-NEW",
|
|
380
|
-
});
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it("returns true when it actually changes a value", () => {
|
|
384
|
-
expect(patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H")).toBe(
|
|
385
|
-
true,
|
|
386
|
-
);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// ── Conflict-loop regression ───────────────────────────────────────────────
|
|
390
|
-
// Repro for the recurring HQ Sync conflict loop on companies/manifest.yaml:
|
|
391
|
-
// a re-provision / menubar Connect on an already-provisioned company used to
|
|
392
|
-
// unconditionally reserialize the manifest via yaml.dump — stripping comments
|
|
393
|
-
// and reflowing — even though nothing changed. The initial-sync step then
|
|
394
|
-
// pushed that comment-stripped form, so it perpetually diverged from any
|
|
395
|
-
// peer/cloud copy still holding the commented form, re-firing a conflict on
|
|
396
|
-
// every sync. The fix: skip the write entirely when values already match.
|
|
397
|
-
describe("conflict-loop regression — no-op re-provision must not churn the file", () => {
|
|
398
|
-
// A manifest in the on-disk shape /newcompany writes: header comment, blank
|
|
399
|
-
// lines, per-entry comment — none of which yaml.dump round-trips.
|
|
400
|
-
const COMMENTED_MANIFEST = `# HQ companies manifest — source of truth for routing.
|
|
401
|
-
# Synced across machines via the personal vault; edit with care.
|
|
402
|
-
|
|
403
|
-
companies:
|
|
404
|
-
indigo:
|
|
405
|
-
name: Indigo
|
|
406
|
-
status: active
|
|
407
|
-
cloud_uid: cmp_01H
|
|
408
|
-
bucket_name: hq-vault-cmp-01H
|
|
409
|
-
acme:
|
|
410
|
-
name: Acme Corp
|
|
411
|
-
status: active
|
|
412
|
-
`;
|
|
413
|
-
|
|
414
|
-
beforeEach(() => {
|
|
415
|
-
const mPath = manifestPath(tmpRoot);
|
|
416
|
-
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
417
|
-
fs.writeFileSync(mPath, COMMENTED_MANIFEST);
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it("returns false and leaves the file byte-for-byte intact (comments preserved) when values already match", () => {
|
|
421
|
-
const changed = patchManifest(
|
|
422
|
-
tmpRoot,
|
|
423
|
-
"indigo",
|
|
424
|
-
"cmp_01H",
|
|
425
|
-
"hq-vault-cmp-01H",
|
|
426
|
-
);
|
|
427
|
-
expect(changed).toBe(false);
|
|
428
|
-
// The whole point: no rewrite at all — comments + layout survive, so the
|
|
429
|
-
// synced bytes never diverge and no conflict is seeded.
|
|
430
|
-
expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(
|
|
431
|
-
COMMENTED_MANIFEST,
|
|
432
|
-
);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
it("re-running many times never churns the on-disk bytes (loop cannot start)", () => {
|
|
436
|
-
for (let i = 0; i < 5; i++) {
|
|
437
|
-
patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
|
|
438
|
-
}
|
|
439
|
-
expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(
|
|
440
|
-
COMMENTED_MANIFEST,
|
|
441
|
-
);
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
it("a genuine change writes once, then converges — subsequent re-runs are no-ops", () => {
|
|
445
|
-
// First real change (acme had no cloud_uid) writes once and returns true.
|
|
446
|
-
expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
|
|
447
|
-
true,
|
|
448
|
-
);
|
|
449
|
-
const afterFirst = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
450
|
-
// Every subsequent identical provision is a no-op — stable bytes, false.
|
|
451
|
-
expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
|
|
452
|
-
false,
|
|
453
|
-
);
|
|
454
|
-
expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
|
|
455
|
-
false,
|
|
456
|
-
);
|
|
457
|
-
expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(afterFirst);
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
// ── writeCompanyConfig ───────────────────────────────────────────────────────
|
|
463
|
-
|
|
464
|
-
describe("writeCompanyConfig", () => {
|
|
465
|
-
beforeEach(() => {
|
|
466
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it("writes valid JSON with all four fields", () => {
|
|
470
|
-
writeCompanyConfig(tmpRoot, "indigo", {
|
|
471
|
-
companyUid: "cmp_01H",
|
|
472
|
-
companySlug: "indigo",
|
|
473
|
-
bucketName: "hq-vault-cmp-01H",
|
|
474
|
-
vaultApiUrl: "https://vault.example.com",
|
|
475
|
-
});
|
|
476
|
-
const cPath = companyConfigPath(tmpRoot, "indigo");
|
|
477
|
-
const parsed = JSON.parse(fs.readFileSync(cPath, "utf-8"));
|
|
478
|
-
expect(parsed).toEqual({
|
|
479
|
-
companyUid: "cmp_01H",
|
|
480
|
-
companySlug: "indigo",
|
|
481
|
-
bucketName: "hq-vault-cmp-01H",
|
|
482
|
-
vaultApiUrl: "https://vault.example.com",
|
|
483
|
-
});
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
it("creates the parent .hq/ directory if missing", () => {
|
|
487
|
-
const hqDir = path.join(companyDirPath(tmpRoot, "indigo"), ".hq");
|
|
488
|
-
expect(fs.existsSync(hqDir)).toBe(false);
|
|
489
|
-
writeCompanyConfig(tmpRoot, "indigo", {
|
|
490
|
-
companyUid: "cmp_01H",
|
|
491
|
-
companySlug: "indigo",
|
|
492
|
-
bucketName: "b",
|
|
493
|
-
vaultApiUrl: "u",
|
|
494
|
-
});
|
|
495
|
-
expect(fs.existsSync(hqDir)).toBe(true);
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it("is idempotent — same inputs produce identical bytes", () => {
|
|
499
|
-
const config = {
|
|
500
|
-
companyUid: "cmp_01H",
|
|
501
|
-
companySlug: "indigo",
|
|
502
|
-
bucketName: "b",
|
|
503
|
-
vaultApiUrl: "u",
|
|
504
|
-
};
|
|
505
|
-
writeCompanyConfig(tmpRoot, "indigo", config);
|
|
506
|
-
const first = fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8");
|
|
507
|
-
writeCompanyConfig(tmpRoot, "indigo", config);
|
|
508
|
-
const second = fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8");
|
|
509
|
-
expect(second).toBe(first);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it("does not leave a .tmp file behind on success", () => {
|
|
513
|
-
writeCompanyConfig(tmpRoot, "indigo", {
|
|
514
|
-
companyUid: "cmp_01H",
|
|
515
|
-
companySlug: "indigo",
|
|
516
|
-
bucketName: "b",
|
|
517
|
-
vaultApiUrl: "u",
|
|
518
|
-
});
|
|
519
|
-
const hqDir = path.join(companyDirPath(tmpRoot, "indigo"), ".hq");
|
|
520
|
-
const entries = fs.readdirSync(hqDir);
|
|
521
|
-
expect(entries.filter((f) => f.includes(".tmp."))).toEqual([]);
|
|
522
|
-
});
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
// ── createDefaultVaultClient ─────────────────────────────────────────────────
|
|
526
|
-
|
|
527
|
-
describe("createDefaultVaultClient", () => {
|
|
528
|
-
const apiUrl = "https://vault.example.com";
|
|
529
|
-
const token = "test-token";
|
|
530
|
-
|
|
531
|
-
it("findCompanyBySlug returns the entity on 200", async () => {
|
|
532
|
-
const entity: VaultEntity = {
|
|
533
|
-
uid: "cmp_01H",
|
|
534
|
-
type: "company",
|
|
535
|
-
slug: "indigo",
|
|
536
|
-
name: "Indigo",
|
|
537
|
-
bucketName: "hq-vault-cmp-01H",
|
|
538
|
-
};
|
|
539
|
-
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
540
|
-
new Response(JSON.stringify({ entity }), { status: 200 }),
|
|
541
|
-
);
|
|
542
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
543
|
-
const out = await client.findCompanyBySlug("indigo");
|
|
544
|
-
expect(out).toEqual(entity);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it("findCompanyBySlug returns null on 404", async () => {
|
|
548
|
-
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
549
|
-
new Response("not found", { status: 404 }),
|
|
550
|
-
);
|
|
551
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
552
|
-
expect(await client.findCompanyBySlug("missing")).toBeNull();
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
it("findCompanyBySlug throws ProvisionError code 1 on 500", async () => {
|
|
556
|
-
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
557
|
-
new Response("kaboom", { status: 500, statusText: "Server Error" }),
|
|
558
|
-
);
|
|
559
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
560
|
-
try {
|
|
561
|
-
await client.findCompanyBySlug("indigo");
|
|
562
|
-
expect.fail("should have thrown");
|
|
563
|
-
} catch (e) {
|
|
564
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
565
|
-
expect((e as ProvisionError).code).toBe(1);
|
|
566
|
-
expect((e as ProvisionError).message).toMatch(/500/);
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it("findCompanyBySlug throws code 1 if 200 has no entity body", async () => {
|
|
571
|
-
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
572
|
-
new Response(JSON.stringify({}), { status: 200 }),
|
|
573
|
-
);
|
|
574
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
575
|
-
await expect(client.findCompanyBySlug("indigo")).rejects.toThrowError(
|
|
576
|
-
/no entity body/,
|
|
577
|
-
);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it("createCompanyEntity returns the entity on 201", async () => {
|
|
581
|
-
const entity: VaultEntity = {
|
|
582
|
-
uid: "cmp_01H",
|
|
583
|
-
type: "company",
|
|
584
|
-
slug: "indigo",
|
|
585
|
-
name: "Indigo",
|
|
586
|
-
bucketName: "hq-vault-cmp-01H",
|
|
587
|
-
};
|
|
588
|
-
const fetchSpy = vi
|
|
589
|
-
.spyOn(globalThis, "fetch")
|
|
590
|
-
.mockResolvedValueOnce(
|
|
591
|
-
new Response(JSON.stringify({ entity }), { status: 201 }),
|
|
592
|
-
);
|
|
593
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
594
|
-
const out = await client.createCompanyEntity({
|
|
595
|
-
slug: "indigo",
|
|
596
|
-
name: "Indigo",
|
|
597
|
-
});
|
|
598
|
-
expect(out).toEqual(entity);
|
|
599
|
-
// Verify request shape
|
|
600
|
-
const call = fetchSpy.mock.calls[0];
|
|
601
|
-
expect(call[0]).toBe(`${apiUrl}/entity`);
|
|
602
|
-
expect((call[1] as RequestInit)?.method).toBe("POST");
|
|
603
|
-
const body = JSON.parse(((call[1] as RequestInit)?.body as string) ?? "{}");
|
|
604
|
-
expect(body).toEqual({ type: "company", slug: "indigo", name: "Indigo" });
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it("createCompanyEntity throws code 1 on 409 conflict", async () => {
|
|
608
|
-
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
609
|
-
new Response("conflict", { status: 409, statusText: "Conflict" }),
|
|
610
|
-
);
|
|
611
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
612
|
-
try {
|
|
613
|
-
await client.createCompanyEntity({ slug: "indigo", name: "Indigo" });
|
|
614
|
-
expect.fail("should have thrown");
|
|
615
|
-
} catch (e) {
|
|
616
|
-
expect((e as ProvisionError).code).toBe(1);
|
|
617
|
-
expect((e as ProvisionError).message).toMatch(/409/);
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
it("createCompanyEntity sends ownerUid when provided", async () => {
|
|
622
|
-
const entity: VaultEntity = {
|
|
623
|
-
uid: "cmp_01H",
|
|
624
|
-
type: "company",
|
|
625
|
-
slug: "indigo",
|
|
626
|
-
name: "Indigo",
|
|
627
|
-
bucketName: "b",
|
|
628
|
-
};
|
|
629
|
-
const fetchSpy = vi
|
|
630
|
-
.spyOn(globalThis, "fetch")
|
|
631
|
-
.mockResolvedValueOnce(
|
|
632
|
-
new Response(JSON.stringify({ entity }), { status: 201 }),
|
|
633
|
-
);
|
|
634
|
-
const client = createDefaultVaultClient(apiUrl, token);
|
|
635
|
-
await client.createCompanyEntity({
|
|
636
|
-
slug: "indigo",
|
|
637
|
-
name: "Indigo",
|
|
638
|
-
ownerUid: "person_01H",
|
|
639
|
-
});
|
|
640
|
-
const body = JSON.parse(
|
|
641
|
-
((fetchSpy.mock.calls[0]?.[1] as RequestInit)?.body as string) ?? "{}",
|
|
642
|
-
);
|
|
643
|
-
expect(body.ownerUid).toBe("person_01H");
|
|
644
|
-
});
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
// ── provisionCompany (orchestrator) ──────────────────────────────────────────
|
|
648
|
-
|
|
649
|
-
describe("provisionCompany", () => {
|
|
650
|
-
const vaultApiUrl = "https://vault.example.com";
|
|
651
|
-
const accessToken = "test-token";
|
|
652
|
-
|
|
653
|
-
function setupValid(): void {
|
|
654
|
-
seedManifest(tmpRoot, { indigo: { status: "active" } });
|
|
655
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function makeVaultClient(overrides: Partial<VaultClient> = {}): VaultClient {
|
|
659
|
-
return {
|
|
660
|
-
listMyPersonEntities: vi.fn().mockResolvedValue([
|
|
661
|
-
{ uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
|
|
662
|
-
]),
|
|
663
|
-
// Default to "slug available in caller's namespace" so happy-path
|
|
664
|
-
// tests fall through to createCompanyEntity. Reuse-path tests
|
|
665
|
-
// override with {available: false, conflictingCompanyUid: ...}
|
|
666
|
-
// and supply a getCompanyByUid that returns the existing entity.
|
|
667
|
-
checkSlugInMyNamespace: vi
|
|
668
|
-
.fn()
|
|
669
|
-
.mockResolvedValue({ available: true }),
|
|
670
|
-
getCompanyByUid: vi
|
|
671
|
-
.fn()
|
|
672
|
-
.mockRejectedValue(
|
|
673
|
-
new Error(
|
|
674
|
-
"getCompanyByUid called without an explicit per-test mock — happy path should never hit it",
|
|
675
|
-
),
|
|
676
|
-
),
|
|
677
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
678
|
-
createCompanyEntity: vi.fn(),
|
|
679
|
-
...overrides,
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
it("happy path — entity not found → POST → manifest + config + sync", async () => {
|
|
684
|
-
setupValid();
|
|
685
|
-
const entity: VaultEntity = {
|
|
686
|
-
uid: "cmp_01H",
|
|
687
|
-
type: "company",
|
|
688
|
-
slug: "indigo",
|
|
689
|
-
name: "Indigo",
|
|
690
|
-
bucketName: "hq-vault-cmp-01H",
|
|
691
|
-
kmsKeyId: "key-123",
|
|
692
|
-
};
|
|
693
|
-
const vaultClient = makeVaultClient({
|
|
694
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
695
|
-
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
696
|
-
});
|
|
697
|
-
const runInitialSync = vi
|
|
698
|
-
.fn()
|
|
699
|
-
.mockResolvedValue({ filesUploaded: 7, bytesUploaded: 1024 });
|
|
700
|
-
|
|
701
|
-
const result = await provisionCompany({
|
|
702
|
-
slug: "indigo",
|
|
703
|
-
name: "Indigo",
|
|
704
|
-
hqRoot: tmpRoot,
|
|
705
|
-
vaultApiUrl,
|
|
706
|
-
vaultClient,
|
|
707
|
-
resolveAccessToken: async () => accessToken,
|
|
708
|
-
runInitialSync,
|
|
709
|
-
log: () => {},
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
expect(result).toEqual<ProvisionResult>({
|
|
713
|
-
ok: true,
|
|
714
|
-
company_slug: "indigo",
|
|
715
|
-
cloud_uid: "cmp_01H",
|
|
716
|
-
bucket_name: "hq-vault-cmp-01H",
|
|
717
|
-
vault_api_url: vaultApiUrl,
|
|
718
|
-
kms_key_id: "key-123",
|
|
719
|
-
created_entity: true,
|
|
720
|
-
manifest_patched: true,
|
|
721
|
-
config_written: true,
|
|
722
|
-
initial_sync: { ok: true, files_uploaded: 7, bytes_uploaded: 1024 },
|
|
723
|
-
});
|
|
724
|
-
// Manifest was actually patched on disk
|
|
725
|
-
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
726
|
-
companies: Record<string, Record<string, unknown>>;
|
|
727
|
-
};
|
|
728
|
-
expect(m.companies.indigo.cloud_uid).toBe("cmp_01H");
|
|
729
|
-
expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-01H");
|
|
730
|
-
// Config was written
|
|
731
|
-
const c = JSON.parse(
|
|
732
|
-
fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8"),
|
|
733
|
-
);
|
|
734
|
-
expect(c.companyUid).toBe("cmp_01H");
|
|
735
|
-
// POST happened
|
|
736
|
-
expect(vaultClient.createCompanyEntity).toHaveBeenCalledOnce();
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
it("folder-only path — slug missing from manifest but dir exists → auto-insert → succeeds", async () => {
|
|
740
|
-
// The hq-sync Connect dead-end this PR fixes: companies/indigo/ exists on
|
|
741
|
-
// disk, the user clicks Connect, but indigo isn't under .companies yet.
|
|
742
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
743
|
-
seedCompanyDir(tmpRoot, "indigo");
|
|
744
|
-
const entity: VaultEntity = {
|
|
745
|
-
uid: "cmp_02H",
|
|
746
|
-
type: "company",
|
|
747
|
-
slug: "indigo",
|
|
748
|
-
name: "Indigo",
|
|
749
|
-
bucketName: "hq-vault-cmp-02H",
|
|
750
|
-
kmsKeyId: null,
|
|
751
|
-
};
|
|
752
|
-
const vaultClient = makeVaultClient({
|
|
753
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
754
|
-
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
755
|
-
});
|
|
756
|
-
const result = await provisionCompany({
|
|
757
|
-
slug: "indigo",
|
|
758
|
-
name: "Indigo",
|
|
759
|
-
hqRoot: tmpRoot,
|
|
760
|
-
vaultApiUrl,
|
|
761
|
-
vaultClient,
|
|
762
|
-
resolveAccessToken: async () => accessToken,
|
|
763
|
-
runInitialSync: vi
|
|
764
|
-
.fn()
|
|
765
|
-
.mockResolvedValue({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
766
|
-
log: () => {},
|
|
767
|
-
});
|
|
768
|
-
expect(result.ok).toBe(true);
|
|
769
|
-
expect(result.created_entity).toBe(true);
|
|
770
|
-
expect(result.manifest_patched).toBe(true);
|
|
771
|
-
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
772
|
-
companies: Record<string, Record<string, unknown>>;
|
|
773
|
-
};
|
|
774
|
-
expect(m.companies.indigo.cloud_uid).toBe("cmp_02H");
|
|
775
|
-
expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-02H");
|
|
776
|
-
// Sibling untouched.
|
|
777
|
-
expect(m.companies.other).toEqual({ status: "active" });
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
it("typo guard — slug missing AND dir missing still throws code 2 (no auto-heal)", async () => {
|
|
781
|
-
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
782
|
-
// NOT calling seedCompanyDir — a slug with no folder is a typo.
|
|
783
|
-
await expect(
|
|
784
|
-
provisionCompany({
|
|
785
|
-
slug: "tyypo",
|
|
786
|
-
hqRoot: tmpRoot,
|
|
787
|
-
vaultApiUrl,
|
|
788
|
-
vaultClient: makeVaultClient(),
|
|
789
|
-
resolveAccessToken: async () => accessToken,
|
|
790
|
-
log: () => {},
|
|
791
|
-
}),
|
|
792
|
-
).rejects.toMatchObject({ code: 2 });
|
|
793
|
-
// Manifest unchanged — typo did not silently insert an entry.
|
|
794
|
-
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
795
|
-
companies: Record<string, unknown>;
|
|
796
|
-
};
|
|
797
|
-
expect(m.companies.tyypo).toBeUndefined();
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
it("idempotent path — entity found → no POST → still patches + syncs → created_entity=false", async () => {
|
|
801
|
-
setupValid();
|
|
802
|
-
const entity: VaultEntity = {
|
|
803
|
-
uid: "cmp_01H",
|
|
804
|
-
type: "company",
|
|
805
|
-
slug: "indigo",
|
|
806
|
-
name: "Indigo",
|
|
807
|
-
bucketName: "hq-vault-cmp-01H",
|
|
808
|
-
kmsKeyId: null,
|
|
809
|
-
};
|
|
810
|
-
const vaultClient = makeVaultClient({
|
|
811
|
-
// Reuse path under the per-user-namespace model: checkSlugInMyNamespace
|
|
812
|
-
// reports `available: false` with the existing entity's uid, and
|
|
813
|
-
// getCompanyByUid materializes the full entity for downstream use.
|
|
814
|
-
checkSlugInMyNamespace: vi
|
|
815
|
-
.fn()
|
|
816
|
-
.mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
|
|
817
|
-
getCompanyByUid: vi.fn().mockResolvedValue(entity),
|
|
818
|
-
createCompanyEntity: vi.fn(),
|
|
819
|
-
});
|
|
820
|
-
const result = await provisionCompany({
|
|
821
|
-
slug: "indigo",
|
|
822
|
-
hqRoot: tmpRoot,
|
|
823
|
-
vaultApiUrl,
|
|
824
|
-
vaultClient,
|
|
825
|
-
resolveAccessToken: async () => accessToken,
|
|
826
|
-
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
827
|
-
log: () => {},
|
|
828
|
-
});
|
|
829
|
-
expect(result.created_entity).toBe(false);
|
|
830
|
-
expect(result.kms_key_id).toBeNull();
|
|
831
|
-
expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
|
|
832
|
-
expect(vaultClient.getCompanyByUid).toHaveBeenCalledWith(entity.uid);
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
|
|
836
|
-
setupValid();
|
|
837
|
-
const entity: VaultEntity = {
|
|
838
|
-
uid: "cmp_01H",
|
|
839
|
-
type: "company",
|
|
840
|
-
slug: "indigo",
|
|
841
|
-
name: "Indigo",
|
|
842
|
-
// bucketName intentionally absent
|
|
843
|
-
};
|
|
844
|
-
const vaultClient = makeVaultClient({
|
|
845
|
-
// Same reuse-path mock shape as the idempotent-path test above.
|
|
846
|
-
checkSlugInMyNamespace: vi
|
|
847
|
-
.fn()
|
|
848
|
-
.mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
|
|
849
|
-
getCompanyByUid: vi.fn().mockResolvedValue(entity),
|
|
850
|
-
});
|
|
851
|
-
try {
|
|
852
|
-
await provisionCompany({
|
|
853
|
-
slug: "indigo",
|
|
854
|
-
hqRoot: tmpRoot,
|
|
855
|
-
vaultApiUrl,
|
|
856
|
-
vaultClient,
|
|
857
|
-
resolveAccessToken: async () => accessToken,
|
|
858
|
-
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
859
|
-
log: () => {},
|
|
860
|
-
});
|
|
861
|
-
expect.fail("should have thrown");
|
|
862
|
-
} catch (e) {
|
|
863
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
864
|
-
expect((e as ProvisionError).code).toBe(1);
|
|
865
|
-
expect((e as ProvisionError).partial?.cloud_uid).toBe("cmp_01H");
|
|
866
|
-
expect((e as ProvisionError).partial?.manifest_patched).toBe(false);
|
|
867
|
-
}
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
it("throws code 3 when initial sync fails — manifest + config STILL written", async () => {
|
|
871
|
-
setupValid();
|
|
872
|
-
const entity: VaultEntity = {
|
|
873
|
-
uid: "cmp_01H",
|
|
874
|
-
type: "company",
|
|
875
|
-
slug: "indigo",
|
|
876
|
-
name: "Indigo",
|
|
877
|
-
bucketName: "hq-vault-cmp-01H",
|
|
878
|
-
};
|
|
879
|
-
const vaultClient = makeVaultClient({
|
|
880
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
881
|
-
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
882
|
-
});
|
|
883
|
-
const runInitialSync = vi
|
|
884
|
-
.fn()
|
|
885
|
-
.mockRejectedValue(new Error("S3 timeout"));
|
|
886
|
-
|
|
887
|
-
try {
|
|
888
|
-
await provisionCompany({
|
|
889
|
-
slug: "indigo",
|
|
890
|
-
hqRoot: tmpRoot,
|
|
891
|
-
vaultApiUrl,
|
|
892
|
-
vaultClient,
|
|
893
|
-
resolveAccessToken: async () => accessToken,
|
|
894
|
-
runInitialSync,
|
|
895
|
-
log: () => {},
|
|
896
|
-
});
|
|
897
|
-
expect.fail("should have thrown");
|
|
898
|
-
} catch (e) {
|
|
899
|
-
expect(e).toBeInstanceOf(ProvisionError);
|
|
900
|
-
expect((e as ProvisionError).code).toBe(3);
|
|
901
|
-
expect((e as ProvisionError).partial?.manifest_patched).toBe(true);
|
|
902
|
-
expect((e as ProvisionError).partial?.config_written).toBe(true);
|
|
903
|
-
expect((e as ProvisionError).partial?.initial_sync?.ok).toBe(false);
|
|
904
|
-
expect((e as ProvisionError).partial?.initial_sync?.error).toMatch(
|
|
905
|
-
/S3 timeout/,
|
|
906
|
-
);
|
|
907
|
-
}
|
|
908
|
-
// Manifest WAS written despite the sync failure (matches partial=true)
|
|
909
|
-
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
910
|
-
companies: Record<string, Record<string, unknown>>;
|
|
911
|
-
};
|
|
912
|
-
expect(m.companies.indigo.cloud_uid).toBe("cmp_01H");
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it("throws code 2 on invalid slug before any vault call", async () => {
|
|
916
|
-
seedCompanyDir(tmpRoot, "indigo"); // dir exists but slug is invalid
|
|
917
|
-
const vaultClient = makeVaultClient();
|
|
918
|
-
try {
|
|
919
|
-
await provisionCompany({
|
|
920
|
-
slug: "personal",
|
|
921
|
-
hqRoot: tmpRoot,
|
|
922
|
-
vaultApiUrl,
|
|
923
|
-
vaultClient,
|
|
924
|
-
resolveAccessToken: async () => accessToken,
|
|
925
|
-
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
926
|
-
log: () => {},
|
|
927
|
-
});
|
|
928
|
-
expect.fail("should have thrown");
|
|
929
|
-
} catch (e) {
|
|
930
|
-
expect((e as ProvisionError).code).toBe(2);
|
|
931
|
-
expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
|
|
932
|
-
}
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
it("uses slug as the entity name when --name is omitted", async () => {
|
|
936
|
-
setupValid();
|
|
937
|
-
const entity: VaultEntity = {
|
|
938
|
-
uid: "cmp_01H",
|
|
939
|
-
type: "company",
|
|
940
|
-
slug: "indigo",
|
|
941
|
-
name: "indigo",
|
|
942
|
-
bucketName: "b",
|
|
943
|
-
};
|
|
944
|
-
const createSpy = vi.fn().mockResolvedValue(entity);
|
|
945
|
-
const vaultClient = makeVaultClient({
|
|
946
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
947
|
-
createCompanyEntity: createSpy,
|
|
948
|
-
});
|
|
949
|
-
await provisionCompany({
|
|
950
|
-
slug: "indigo",
|
|
951
|
-
// name omitted on purpose
|
|
952
|
-
hqRoot: tmpRoot,
|
|
953
|
-
vaultApiUrl,
|
|
954
|
-
vaultClient,
|
|
955
|
-
resolveAccessToken: async () => accessToken,
|
|
956
|
-
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
957
|
-
log: () => {},
|
|
958
|
-
});
|
|
959
|
-
expect(createSpy).toHaveBeenCalledWith({
|
|
960
|
-
slug: "indigo",
|
|
961
|
-
name: "indigo",
|
|
962
|
-
ownerUid: undefined,
|
|
963
|
-
});
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
it("skipInitialSync=true — runner is NOT called; result has initial_sync.skipped", async () => {
|
|
967
|
-
setupValid();
|
|
968
|
-
const entity: VaultEntity = {
|
|
969
|
-
uid: "cmp_01H",
|
|
970
|
-
type: "company",
|
|
971
|
-
slug: "indigo",
|
|
972
|
-
name: "Indigo",
|
|
973
|
-
bucketName: "hq-vault-cmp-01H",
|
|
974
|
-
};
|
|
975
|
-
const vaultClient = makeVaultClient({
|
|
976
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
977
|
-
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
978
|
-
});
|
|
979
|
-
const runInitialSync = vi.fn();
|
|
980
|
-
|
|
981
|
-
const result = await provisionCompany({
|
|
982
|
-
slug: "indigo",
|
|
983
|
-
hqRoot: tmpRoot,
|
|
984
|
-
vaultApiUrl,
|
|
985
|
-
vaultClient,
|
|
986
|
-
resolveAccessToken: async () => accessToken,
|
|
987
|
-
runInitialSync,
|
|
988
|
-
skipInitialSync: true,
|
|
989
|
-
log: () => {},
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
expect(runInitialSync).not.toHaveBeenCalled();
|
|
993
|
-
expect(result.initial_sync).toEqual({ skipped: true });
|
|
994
|
-
expect(result.ok).toBe(true);
|
|
995
|
-
expect(result.cloud_uid).toBe("cmp_01H");
|
|
996
|
-
expect(result.manifest_patched).toBe(true);
|
|
997
|
-
expect(result.config_written).toBe(true);
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
it("skipInitialSync=true — manifest + .hq/config.json are STILL written", async () => {
|
|
1001
|
-
setupValid();
|
|
1002
|
-
const entity: VaultEntity = {
|
|
1003
|
-
uid: "cmp_SKIP",
|
|
1004
|
-
type: "company",
|
|
1005
|
-
slug: "indigo",
|
|
1006
|
-
name: "Indigo",
|
|
1007
|
-
bucketName: "hq-vault-cmp-SKIP",
|
|
1008
|
-
};
|
|
1009
|
-
const vaultClient = makeVaultClient({
|
|
1010
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
1011
|
-
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
1012
|
-
});
|
|
1013
|
-
await provisionCompany({
|
|
1014
|
-
slug: "indigo",
|
|
1015
|
-
hqRoot: tmpRoot,
|
|
1016
|
-
vaultApiUrl,
|
|
1017
|
-
vaultClient,
|
|
1018
|
-
resolveAccessToken: async () => accessToken,
|
|
1019
|
-
runInitialSync: vi.fn(),
|
|
1020
|
-
skipInitialSync: true,
|
|
1021
|
-
log: () => {},
|
|
1022
|
-
});
|
|
1023
|
-
// Manifest patched on disk
|
|
1024
|
-
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
1025
|
-
companies: Record<string, Record<string, unknown>>;
|
|
1026
|
-
};
|
|
1027
|
-
expect(m.companies.indigo.cloud_uid).toBe("cmp_SKIP");
|
|
1028
|
-
expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-SKIP");
|
|
1029
|
-
// .hq/config.json written
|
|
1030
|
-
const c = JSON.parse(
|
|
1031
|
-
fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8"),
|
|
1032
|
-
);
|
|
1033
|
-
expect(c.companyUid).toBe("cmp_SKIP");
|
|
1034
|
-
expect(c.bucketName).toBe("hq-vault-cmp-SKIP");
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
// ── Pre-flight: caller MUST have a person entity ─────────────────────────
|
|
1038
|
-
//
|
|
1039
|
-
// Without one, the initial-sync step at the end of provision returns 403
|
|
1040
|
-
// "no person entity" from /sts/vend and leaves the operator with a half-
|
|
1041
|
-
// built cloud company. The pre-flight catches this BEFORE any cloud-side
|
|
1042
|
-
// resource creation. Documented in the 2026-05-14 setup-session deep dive.
|
|
1043
|
-
|
|
1044
|
-
it("pre-flight fails fast with code 2 when caller has no person entity — no resources touched", async () => {
|
|
1045
|
-
setupValid();
|
|
1046
|
-
const createCompanyEntity = vi.fn();
|
|
1047
|
-
const runInitialSync = vi.fn();
|
|
1048
|
-
const vaultClient = makeVaultClient({
|
|
1049
|
-
listMyPersonEntities: vi.fn().mockResolvedValue([]),
|
|
1050
|
-
findCompanyBySlug: vi.fn(),
|
|
1051
|
-
createCompanyEntity,
|
|
1052
|
-
});
|
|
1053
|
-
|
|
1054
|
-
let caught: unknown;
|
|
1055
|
-
try {
|
|
1056
|
-
await provisionCompany({
|
|
1057
|
-
slug: "indigo",
|
|
1058
|
-
hqRoot: tmpRoot,
|
|
1059
|
-
vaultApiUrl,
|
|
1060
|
-
vaultClient,
|
|
1061
|
-
resolveAccessToken: async () => accessToken,
|
|
1062
|
-
runInitialSync,
|
|
1063
|
-
log: () => {},
|
|
1064
|
-
});
|
|
1065
|
-
} catch (e) {
|
|
1066
|
-
caught = e;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
expect(caught).toBeInstanceOf(ProvisionError);
|
|
1070
|
-
expect((caught as ProvisionError).code).toBe(2);
|
|
1071
|
-
expect((caught as ProvisionError).message).toMatch(/No person entity/);
|
|
1072
|
-
expect((caught as ProvisionError).message).toMatch(/hq onboard/);
|
|
1073
|
-
|
|
1074
|
-
// No cloud-side mutations: no entity lookup, no entity creation, no sync.
|
|
1075
|
-
expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
|
|
1076
|
-
expect(createCompanyEntity).not.toHaveBeenCalled();
|
|
1077
|
-
expect(runInitialSync).not.toHaveBeenCalled();
|
|
1078
|
-
|
|
1079
|
-
// No disk-side mutations: manifest still untouched.
|
|
1080
|
-
const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
|
|
1081
|
-
companies: Record<string, Record<string, unknown>>;
|
|
1082
|
-
};
|
|
1083
|
-
expect(m.companies.indigo.cloud_uid).toBeUndefined();
|
|
1084
|
-
expect(m.companies.indigo.bucket_name).toBeUndefined();
|
|
1085
|
-
expect(fs.existsSync(companyConfigPath(tmpRoot, "indigo"))).toBe(false);
|
|
1086
|
-
});
|
|
1087
|
-
|
|
1088
|
-
it("pre-flight passes when caller has a person entity — flow continues to provisioning", async () => {
|
|
1089
|
-
setupValid();
|
|
1090
|
-
const entity: VaultEntity = {
|
|
1091
|
-
uid: "cmp_OK", type: "company", slug: "indigo", name: "Indigo",
|
|
1092
|
-
bucketName: "hq-vault-cmp-OK", kmsKeyId: "key-OK",
|
|
1093
|
-
};
|
|
1094
|
-
const listSpy = vi.fn().mockResolvedValue([
|
|
1095
|
-
{ uid: "prs_known", type: "person", slug: "joey-muller", name: "Joey Muller" },
|
|
1096
|
-
]);
|
|
1097
|
-
const vaultClient = makeVaultClient({
|
|
1098
|
-
listMyPersonEntities: listSpy,
|
|
1099
|
-
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
1100
|
-
createCompanyEntity: vi.fn().mockResolvedValue(entity),
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
const result = await provisionCompany({
|
|
1104
|
-
slug: "indigo",
|
|
1105
|
-
hqRoot: tmpRoot,
|
|
1106
|
-
vaultApiUrl,
|
|
1107
|
-
vaultClient,
|
|
1108
|
-
resolveAccessToken: async () => accessToken,
|
|
1109
|
-
runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
|
|
1110
|
-
log: () => {},
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
expect(listSpy).toHaveBeenCalledOnce();
|
|
1114
|
-
expect(result.ok).toBe(true);
|
|
1115
|
-
expect(result.cloud_uid).toBe("cmp_OK");
|
|
1116
|
-
});
|
|
1117
|
-
});
|