@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,1249 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for `hq files browse` + `hq files cat` (files-browse.ts).
|
|
3
|
-
*
|
|
4
|
-
* Stubs the multi-tenant STS vend routes (`sts.vend` / `sts.vendSelf`),
|
|
5
|
-
* listMyExplicitGrants, entity.findInMyNamespace/get, and the S3 client.
|
|
6
|
-
* Coverage focus:
|
|
7
|
-
*
|
|
8
|
-
* 1. Browse/cat vend via `/sts/vend` (company) and `/sts/vend-self`
|
|
9
|
-
* (personal) — NOT the legacy `POST /vend`, which is non-functional in
|
|
10
|
-
* multi-tenant prod (undefined BUCKET_ARN → MalformedPolicyDocument).
|
|
11
|
-
* 2. Namespace translation: company vault keys are company-relative, so the
|
|
12
|
-
* S3 list/get prefix is the bucket-relative form while the CLI surface
|
|
13
|
-
* stays anchored at `companies/<slug>/`.
|
|
14
|
-
* 3. `--out` refuses any destination under `<hqRoot>/companies/`.
|
|
15
|
-
* 4. ACL-source classification over the company-relative key space.
|
|
16
|
-
*
|
|
17
|
-
* Plus the pure helpers (parseCompanySlugFromPath, toBucketRelative,
|
|
18
|
-
* toCompanyAnchored, classifyAclSource, assertOutPathOutsideCompanies,
|
|
19
|
-
* formatBrowseTable).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
-
import { Command } from "commander";
|
|
24
|
-
import * as fs from "node:fs";
|
|
25
|
-
import * as os from "node:os";
|
|
26
|
-
import * as path from "node:path";
|
|
27
|
-
import { Readable, Writable } from "node:stream";
|
|
28
|
-
|
|
29
|
-
import {
|
|
30
|
-
assertOutPathOutsideCompanies,
|
|
31
|
-
classifyAclSource,
|
|
32
|
-
formatBrowseTable,
|
|
33
|
-
parseCompanySlugFromPath,
|
|
34
|
-
toBucketRelative,
|
|
35
|
-
toCompanyAnchored,
|
|
36
|
-
runBrowse,
|
|
37
|
-
runCat,
|
|
38
|
-
runSearch,
|
|
39
|
-
runGet,
|
|
40
|
-
readPins,
|
|
41
|
-
addPin,
|
|
42
|
-
pinFilePath,
|
|
43
|
-
runSharedWithMe,
|
|
44
|
-
formatSharedWithMeTable,
|
|
45
|
-
createCompanyPresignClient,
|
|
46
|
-
type BrowseVendResult,
|
|
47
|
-
type FilesBrowseS3Client,
|
|
48
|
-
type FilesBrowseVaultClient,
|
|
49
|
-
type FilesSharedWithMeVaultClient,
|
|
50
|
-
type S3ClientFactory,
|
|
51
|
-
type CompanyBrowseClientFactory,
|
|
52
|
-
} from "./files-browse.js";
|
|
53
|
-
import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
54
|
-
import {
|
|
55
|
-
ListObjectsV2Command,
|
|
56
|
-
GetObjectCommand,
|
|
57
|
-
type ListObjectsV2CommandOutput,
|
|
58
|
-
type GetObjectCommandOutput,
|
|
59
|
-
} from "@aws-sdk/client-s3";
|
|
60
|
-
|
|
61
|
-
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
let tmpRoot: string;
|
|
64
|
-
|
|
65
|
-
beforeEach(() => {
|
|
66
|
-
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-files-browse-"));
|
|
67
|
-
// Create the companies/ subtree so the guard has a real path to compare
|
|
68
|
-
// against (the guard's check is string-level so this isn't strictly
|
|
69
|
-
// required, but it mirrors the real layout).
|
|
70
|
-
fs.mkdirSync(path.join(tmpRoot, "companies", "indigo"), { recursive: true });
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
afterEach(() => {
|
|
74
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
75
|
-
vi.restoreAllMocks();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
function fakeGrant(p: string): ExplicitGrant {
|
|
79
|
-
return {
|
|
80
|
-
companyUid: "cmp_indigo",
|
|
81
|
-
path: p,
|
|
82
|
-
permission: "read",
|
|
83
|
-
source: "person",
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function fakeStsVend(): BrowseVendResult {
|
|
88
|
-
return {
|
|
89
|
-
credentials: {
|
|
90
|
-
accessKeyId: "ASIA-test",
|
|
91
|
-
secretAccessKey: "secret-test",
|
|
92
|
-
sessionToken: "session-test",
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface StubVaultOpts {
|
|
98
|
-
grants?: ExplicitGrant[];
|
|
99
|
-
entity?:
|
|
100
|
-
| { uid: string; slug: string; name?: string; bucketName?: string }
|
|
101
|
-
| null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
105
|
-
client: FilesBrowseVaultClient;
|
|
106
|
-
spies: {
|
|
107
|
-
stsVend: ReturnType<typeof vi.fn>;
|
|
108
|
-
vendSelf: ReturnType<typeof vi.fn>;
|
|
109
|
-
listMyExplicitGrants: ReturnType<typeof vi.fn>;
|
|
110
|
-
findInMyNamespace: ReturnType<typeof vi.fn>;
|
|
111
|
-
};
|
|
112
|
-
} {
|
|
113
|
-
const entity =
|
|
114
|
-
opts.entity === null
|
|
115
|
-
? null
|
|
116
|
-
: (opts.entity ?? {
|
|
117
|
-
uid: "cmp_indigo",
|
|
118
|
-
slug: "indigo",
|
|
119
|
-
name: "Indigo",
|
|
120
|
-
bucketName: "hq-vault-cmp-indigo",
|
|
121
|
-
});
|
|
122
|
-
const stsVend = vi.fn(async () => fakeStsVend());
|
|
123
|
-
const vendSelf = vi.fn(async () => fakeStsVend());
|
|
124
|
-
const listMyExplicitGrants = vi.fn(async () => opts.grants ?? []);
|
|
125
|
-
const findInMyNamespace = vi.fn(async () => entity);
|
|
126
|
-
const get = vi.fn(async (uid: string) => {
|
|
127
|
-
if (!entity) throw new Error(`entity ${uid} not found`);
|
|
128
|
-
return entity;
|
|
129
|
-
});
|
|
130
|
-
const client: FilesBrowseVaultClient = {
|
|
131
|
-
sts: { vend: stsVend, vendSelf },
|
|
132
|
-
listMyExplicitGrants,
|
|
133
|
-
entity: { get, findInMyNamespace },
|
|
134
|
-
};
|
|
135
|
-
return {
|
|
136
|
-
client,
|
|
137
|
-
spies: { stsVend, vendSelf, listMyExplicitGrants, findInMyNamespace },
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface StubS3Opts {
|
|
142
|
-
listResponses?: ListObjectsV2CommandOutput[];
|
|
143
|
-
getResponse?: GetObjectCommandOutput;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function makeStubS3Factory(opts: StubS3Opts): {
|
|
147
|
-
factory: S3ClientFactory;
|
|
148
|
-
/**
|
|
149
|
-
* The SAME stub typed as a company-mode client factory. Company browse/cat/
|
|
150
|
-
* get/search now inject `companyClient` (the presign/list seam) instead of
|
|
151
|
-
* `s3Factory`; the stub ignores its factory arg either way, so one stub
|
|
152
|
-
* serves both. Personal-mode tests use `factory`; company-mode tests use
|
|
153
|
-
* `companyFactory`.
|
|
154
|
-
*/
|
|
155
|
-
companyFactory: CompanyBrowseClientFactory;
|
|
156
|
-
sendSpy: ReturnType<typeof vi.fn>;
|
|
157
|
-
factorySpy: ReturnType<typeof vi.fn>;
|
|
158
|
-
} {
|
|
159
|
-
const responses = [...(opts.listResponses ?? [])];
|
|
160
|
-
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
161
|
-
if (cmd instanceof ListObjectsV2Command) {
|
|
162
|
-
const next = responses.shift() ?? { Contents: [] };
|
|
163
|
-
return next;
|
|
164
|
-
}
|
|
165
|
-
if (cmd instanceof GetObjectCommand) {
|
|
166
|
-
return opts.getResponse ?? { Body: undefined };
|
|
167
|
-
}
|
|
168
|
-
throw new Error(`unexpected command: ${cmd}`);
|
|
169
|
-
});
|
|
170
|
-
const factorySpy = vi.fn(
|
|
171
|
-
() =>
|
|
172
|
-
({
|
|
173
|
-
send: sendSpy,
|
|
174
|
-
}) as unknown as FilesBrowseS3Client,
|
|
175
|
-
);
|
|
176
|
-
return {
|
|
177
|
-
factory: factorySpy as unknown as S3ClientFactory,
|
|
178
|
-
companyFactory: factorySpy as unknown as CompanyBrowseClientFactory,
|
|
179
|
-
sendSpy,
|
|
180
|
-
factorySpy,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ── parseCompanySlugFromPath ────────────────────────────────────────────────
|
|
185
|
-
|
|
186
|
-
describe("parseCompanySlugFromPath", () => {
|
|
187
|
-
it("extracts slug from a canonical prefix", () => {
|
|
188
|
-
expect(parseCompanySlugFromPath("companies/indigo/scratch/")).toBe("indigo");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("tolerates leading slashes", () => {
|
|
192
|
-
expect(parseCompanySlugFromPath("/companies/acme/foo")).toBe("acme");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("rejects paths not anchored at companies/", () => {
|
|
196
|
-
expect(() => parseCompanySlugFromPath("personal/notes/")).toThrow(
|
|
197
|
-
/Expected a path starting with 'companies\//,
|
|
198
|
-
);
|
|
199
|
-
expect(() => parseCompanySlugFromPath("")).toThrow();
|
|
200
|
-
expect(() => parseCompanySlugFromPath("companies/")).toThrow();
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// ── toBucketRelative / toCompanyAnchored (namespace translation) ────────────
|
|
205
|
-
|
|
206
|
-
describe("toBucketRelative", () => {
|
|
207
|
-
it("strips the companies/<slug>/ anchor to a company-relative key", () => {
|
|
208
|
-
expect(toBucketRelative("companies/indigo/knowledge/foo.md", "indigo")).toBe(
|
|
209
|
-
"knowledge/foo.md",
|
|
210
|
-
);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("strips the anchor for a bare prefix (trailing slash preserved)", () => {
|
|
214
|
-
expect(toBucketRelative("companies/indigo/scratch/", "indigo")).toBe(
|
|
215
|
-
"scratch/",
|
|
216
|
-
);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("yields empty string for the bare company root", () => {
|
|
220
|
-
expect(toBucketRelative("companies/indigo/", "indigo")).toBe("");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("tolerates leading slashes", () => {
|
|
224
|
-
expect(toBucketRelative("/companies/indigo/x/y", "indigo")).toBe("x/y");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("passes through a path that lacks the anchor (e.g. already relative)", () => {
|
|
228
|
-
expect(toBucketRelative("knowledge/foo.md", "indigo")).toBe(
|
|
229
|
-
"knowledge/foo.md",
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("does not strip a different company's anchor", () => {
|
|
234
|
-
expect(toBucketRelative("companies/acme/x", "indigo")).toBe(
|
|
235
|
-
"companies/acme/x",
|
|
236
|
-
);
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe("toCompanyAnchored", () => {
|
|
241
|
-
it("re-attaches the companies/<slug>/ anchor", () => {
|
|
242
|
-
expect(toCompanyAnchored("knowledge/foo.md", "indigo")).toBe(
|
|
243
|
-
"companies/indigo/knowledge/foo.md",
|
|
244
|
-
);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("round-trips with toBucketRelative", () => {
|
|
248
|
-
const anchored = "companies/indigo/a/b/c.md";
|
|
249
|
-
expect(toCompanyAnchored(toBucketRelative(anchored, "indigo"), "indigo")).toBe(
|
|
250
|
-
anchored,
|
|
251
|
-
);
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// ── classifyAclSource ───────────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
describe("classifyAclSource", () => {
|
|
258
|
-
it("returns shared-with-you when any grant prefix covers the key", () => {
|
|
259
|
-
expect(classifyAclSource("scratch/foo.txt", ["scratch/"])).toBe(
|
|
260
|
-
"shared-with-you",
|
|
261
|
-
);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("returns role-bypass when no grant prefix covers the key", () => {
|
|
265
|
-
expect(classifyAclSource("secrets/db.txt", ["scratch/"])).toBe(
|
|
266
|
-
"role-bypass",
|
|
267
|
-
);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("returns role-bypass on an empty grant list", () => {
|
|
271
|
-
expect(classifyAclSource("anything/x", [])).toBe("role-bypass");
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("treats an empty-string prefix as a company-wide grant (shared)", () => {
|
|
275
|
-
expect(classifyAclSource("anything/x", [""])).toBe("shared-with-you");
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("matches against the first covering prefix — multiple are fine", () => {
|
|
279
|
-
expect(
|
|
280
|
-
classifyAclSource("scratch/sub/y.bin", ["other/", "scratch/"]),
|
|
281
|
-
).toBe("shared-with-you");
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
// ── assertOutPathOutsideCompanies (writes-guard) ───────────────────────────
|
|
286
|
-
|
|
287
|
-
describe("assertOutPathOutsideCompanies", () => {
|
|
288
|
-
it("refuses a destination directly under <hqRoot>/companies/", () => {
|
|
289
|
-
const dest = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
|
|
290
|
-
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).toThrow(
|
|
291
|
-
/Refusing to write/,
|
|
292
|
-
);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it("refuses a destination deep under <hqRoot>/companies/", () => {
|
|
296
|
-
const dest = path.join(
|
|
297
|
-
tmpRoot,
|
|
298
|
-
"companies",
|
|
299
|
-
"indigo",
|
|
300
|
-
"scratch",
|
|
301
|
-
"deep",
|
|
302
|
-
"leaked.txt",
|
|
303
|
-
);
|
|
304
|
-
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).toThrow();
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("refuses the bare companies/ root", () => {
|
|
308
|
-
const dest = path.join(tmpRoot, "companies");
|
|
309
|
-
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).toThrow();
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("permits a destination outside <hqRoot>/companies/ (e.g. /tmp)", () => {
|
|
313
|
-
const dest = path.join(tmpRoot, "scratch.txt");
|
|
314
|
-
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).not.toThrow();
|
|
315
|
-
expect(assertOutPathOutsideCompanies(dest, tmpRoot)).toBe(
|
|
316
|
-
path.resolve(dest),
|
|
317
|
-
);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("permits an absolute path under /tmp that is unrelated to hqRoot", () => {
|
|
321
|
-
// Use a sibling tmp dir as the "outside" location so the test is
|
|
322
|
-
// platform-portable (no hard-coded /tmp/foo paths).
|
|
323
|
-
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-out-"));
|
|
324
|
-
try {
|
|
325
|
-
const dest = path.join(otherRoot, "leaked.txt");
|
|
326
|
-
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).not.toThrow();
|
|
327
|
-
} finally {
|
|
328
|
-
fs.rmSync(otherRoot, { recursive: true, force: true });
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("refuses a relative path that resolves under companies/", () => {
|
|
333
|
-
// On macOS the OS tmp dir is a symlink (`/tmp` → `/private/tmp`,
|
|
334
|
-
// `/var/folders/...` is the canonical form). Resolving the realpath
|
|
335
|
-
// on both sides keeps `path.resolve`'s output and the guard's
|
|
336
|
-
// protected-root match on the same physical tree, so a cwd-relative
|
|
337
|
-
// "companies/indigo/…" lookup is correctly classified as protected.
|
|
338
|
-
const realRoot = fs.realpathSync(tmpRoot);
|
|
339
|
-
const cwd = process.cwd();
|
|
340
|
-
process.chdir(realRoot);
|
|
341
|
-
try {
|
|
342
|
-
expect(() =>
|
|
343
|
-
assertOutPathOutsideCompanies("companies/indigo/foo.txt", realRoot),
|
|
344
|
-
).toThrow();
|
|
345
|
-
} finally {
|
|
346
|
-
process.chdir(cwd);
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
// ── formatBrowseTable ───────────────────────────────────────────────────────
|
|
352
|
-
|
|
353
|
-
describe("formatBrowseTable", () => {
|
|
354
|
-
it("renders an empty marker when no rows", () => {
|
|
355
|
-
expect(formatBrowseTable([])).toMatch(/No objects/);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("includes key, size, modified, and ACL source in the row", () => {
|
|
359
|
-
const out = formatBrowseTable([
|
|
360
|
-
{
|
|
361
|
-
key: "companies/indigo/scratch/a.txt",
|
|
362
|
-
size: 42,
|
|
363
|
-
lastModified: new Date("2026-01-15T10:00:00Z"),
|
|
364
|
-
aclSource: "shared-with-you",
|
|
365
|
-
},
|
|
366
|
-
]);
|
|
367
|
-
expect(out).toContain("companies/indigo/scratch/a.txt");
|
|
368
|
-
expect(out).toContain("42");
|
|
369
|
-
expect(out).toContain("2026-01-15");
|
|
370
|
-
expect(out).toContain("shared-with-you");
|
|
371
|
-
});
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// ── runBrowse ───────────────────────────────────────────────────────────────
|
|
375
|
-
|
|
376
|
-
describe("runBrowse", () => {
|
|
377
|
-
it("company mode lists via the presign/list client (no STS vend) on the company-relative prefix", async () => {
|
|
378
|
-
const { client, spies } = makeStubVaultClient({});
|
|
379
|
-
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
380
|
-
listResponses: [{ Contents: [] }],
|
|
381
|
-
});
|
|
382
|
-
await runBrowse({
|
|
383
|
-
pathPrefix: "companies/indigo/scratch/",
|
|
384
|
-
vaultClient: client,
|
|
385
|
-
companyClient: companyFactory,
|
|
386
|
-
region: "us-east-1",
|
|
387
|
-
});
|
|
388
|
-
// HQ-59: company browse no longer vends STS creds — it goes through the
|
|
389
|
-
// server-side list API, so NEITHER vend route is touched.
|
|
390
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
391
|
-
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
392
|
-
// List prefix is company-relative (the bug: was anchored → 0 results).
|
|
393
|
-
const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
|
|
394
|
-
expect(listCmd.input.Prefix).toBe("scratch/");
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it("paginates ListObjectsV2 fully, classifies ACL, and re-anchors keys for display", async () => {
|
|
398
|
-
const { client } = makeStubVaultClient({
|
|
399
|
-
// Real grants are glob/anchored; normalization folds this to "scratch/".
|
|
400
|
-
grants: [fakeGrant("companies/indigo/scratch/*")],
|
|
401
|
-
});
|
|
402
|
-
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
403
|
-
// List objects are company-relative (no companies/<slug>/ prefix).
|
|
404
|
-
listResponses: [
|
|
405
|
-
{
|
|
406
|
-
Contents: [
|
|
407
|
-
{
|
|
408
|
-
Key: "scratch/a.txt",
|
|
409
|
-
Size: 10,
|
|
410
|
-
LastModified: new Date("2026-01-01T00:00:00Z"),
|
|
411
|
-
},
|
|
412
|
-
{
|
|
413
|
-
Key: "secrets/db.txt",
|
|
414
|
-
Size: 20,
|
|
415
|
-
LastModified: new Date("2026-01-02T00:00:00Z"),
|
|
416
|
-
},
|
|
417
|
-
],
|
|
418
|
-
NextContinuationToken: "page2",
|
|
419
|
-
},
|
|
420
|
-
{
|
|
421
|
-
Contents: [
|
|
422
|
-
{
|
|
423
|
-
Key: "scratch/sub/b.bin",
|
|
424
|
-
Size: 30,
|
|
425
|
-
LastModified: new Date("2026-01-03T00:00:00Z"),
|
|
426
|
-
},
|
|
427
|
-
// S3 directory marker — should be filtered out.
|
|
428
|
-
{
|
|
429
|
-
Key: "scratch/empty/",
|
|
430
|
-
Size: 0,
|
|
431
|
-
LastModified: new Date("2026-01-04T00:00:00Z"),
|
|
432
|
-
},
|
|
433
|
-
],
|
|
434
|
-
},
|
|
435
|
-
],
|
|
436
|
-
});
|
|
437
|
-
const result = await runBrowse({
|
|
438
|
-
pathPrefix: "companies/indigo/scratch/",
|
|
439
|
-
vaultClient: client,
|
|
440
|
-
companyClient: companyFactory,
|
|
441
|
-
region: "us-east-1",
|
|
442
|
-
});
|
|
443
|
-
expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
|
|
444
|
-
expect(result.rows).toHaveLength(3);
|
|
445
|
-
// Displayed keys are re-anchored to companies/<slug>/.
|
|
446
|
-
const byKey = Object.fromEntries(result.rows.map((r) => [r.key, r]));
|
|
447
|
-
expect(byKey["companies/indigo/scratch/a.txt"].aclSource).toBe(
|
|
448
|
-
"shared-with-you",
|
|
449
|
-
);
|
|
450
|
-
expect(byKey["companies/indigo/scratch/sub/b.bin"].aclSource).toBe(
|
|
451
|
-
"shared-with-you",
|
|
452
|
-
);
|
|
453
|
-
// Visible only via role-bypass — this is the leak-out-of-grant case.
|
|
454
|
-
expect(byKey["companies/indigo/secrets/db.txt"].aclSource).toBe(
|
|
455
|
-
"role-bypass",
|
|
456
|
-
);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
it("throws when the slug doesn't resolve in the caller's namespace", async () => {
|
|
460
|
-
const { client } = makeStubVaultClient({ entity: null });
|
|
461
|
-
const { factory } = makeStubS3Factory({ listResponses: [] });
|
|
462
|
-
await expect(
|
|
463
|
-
runBrowse({
|
|
464
|
-
pathPrefix: "companies/notmine/scratch/",
|
|
465
|
-
vaultClient: client,
|
|
466
|
-
s3Factory: factory,
|
|
467
|
-
region: "us-east-1",
|
|
468
|
-
}),
|
|
469
|
-
).rejects.toThrow(/No company found for slug 'notmine'/);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
473
|
-
//
|
|
474
|
-
// Personal mode resolves the entity via `entity.get(personalUid)` (skipping
|
|
475
|
-
// the company-namespace lookup), vends via `/sts/vend-self`, omits the
|
|
476
|
-
// explicit-grants fetch, and tags every row `aclSource: "personal-vault"`.
|
|
477
|
-
// The path arg is bucket-relative — companies/<slug>/ prefix is NOT required.
|
|
478
|
-
|
|
479
|
-
it("personalMode: resolves via entity.get, vends /sts/vend-self, skips namespace + grants", async () => {
|
|
480
|
-
const { client, spies } = makeStubVaultClient({
|
|
481
|
-
entity: {
|
|
482
|
-
uid: "prs_test",
|
|
483
|
-
slug: "personal",
|
|
484
|
-
bucketName: "hq-vault-prs-test",
|
|
485
|
-
},
|
|
486
|
-
});
|
|
487
|
-
const { factory } = makeStubS3Factory({
|
|
488
|
-
listResponses: [
|
|
489
|
-
{
|
|
490
|
-
Contents: [{ Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() }],
|
|
491
|
-
},
|
|
492
|
-
],
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
const result = await runBrowse({
|
|
496
|
-
pathPrefix: ".claude/",
|
|
497
|
-
personalMode: true,
|
|
498
|
-
personalUid: "prs_test",
|
|
499
|
-
vaultClient: client,
|
|
500
|
-
s3Factory: factory,
|
|
501
|
-
region: "us-east-1",
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
expect(result.rows).toHaveLength(1);
|
|
505
|
-
expect(result.rows[0].key).toBe(".claude/CLAUDE.md");
|
|
506
|
-
// findInMyNamespace is the company-mode lookup — must not be touched.
|
|
507
|
-
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
508
|
-
// Grants graph is a company concept — must not be fetched.
|
|
509
|
-
expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
|
|
510
|
-
// Personal vends self, never the company route.
|
|
511
|
-
expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
|
|
512
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
it("personalMode: empty pathPrefix lists the bucket root", async () => {
|
|
516
|
-
const { client } = makeStubVaultClient({
|
|
517
|
-
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
518
|
-
});
|
|
519
|
-
const { factory, sendSpy } = makeStubS3Factory({
|
|
520
|
-
listResponses: [
|
|
521
|
-
{
|
|
522
|
-
Contents: [
|
|
523
|
-
{ Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() },
|
|
524
|
-
{ Key: "core/policies/foo.md", Size: 50, LastModified: new Date() },
|
|
525
|
-
],
|
|
526
|
-
},
|
|
527
|
-
],
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
const result = await runBrowse({
|
|
531
|
-
pathPrefix: "",
|
|
532
|
-
personalMode: true,
|
|
533
|
-
personalUid: "prs_test",
|
|
534
|
-
vaultClient: client,
|
|
535
|
-
s3Factory: factory,
|
|
536
|
-
region: "us-east-1",
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
expect(result.rows).toHaveLength(2);
|
|
540
|
-
// ListObjectsV2 issued with an empty Prefix means "list everything".
|
|
541
|
-
const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
|
|
542
|
-
expect(listCmd.input.Prefix).toBe("");
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
it("personalMode: every row is tagged aclSource='personal-vault'", async () => {
|
|
546
|
-
const { client } = makeStubVaultClient({
|
|
547
|
-
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
548
|
-
// Note: even if grants WERE present, personalMode would skip the
|
|
549
|
-
// lookup entirely — no chance of accidentally tagging a personal
|
|
550
|
-
// key as `shared-with-you`.
|
|
551
|
-
grants: [fakeGrant("companies/indigo/")],
|
|
552
|
-
});
|
|
553
|
-
const { factory } = makeStubS3Factory({
|
|
554
|
-
listResponses: [
|
|
555
|
-
{
|
|
556
|
-
Contents: [
|
|
557
|
-
{ Key: ".claude/CLAUDE.md", Size: 1, LastModified: new Date() },
|
|
558
|
-
{ Key: "core/policies/_digest.md", Size: 2, LastModified: new Date() },
|
|
559
|
-
{ Key: "personal/notes.md", Size: 3, LastModified: new Date() },
|
|
560
|
-
],
|
|
561
|
-
},
|
|
562
|
-
],
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
const result = await runBrowse({
|
|
566
|
-
pathPrefix: "",
|
|
567
|
-
personalMode: true,
|
|
568
|
-
personalUid: "prs_test",
|
|
569
|
-
vaultClient: client,
|
|
570
|
-
s3Factory: factory,
|
|
571
|
-
region: "us-east-1",
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
for (const row of result.rows) {
|
|
575
|
-
expect(row.aclSource).toBe("personal-vault");
|
|
576
|
-
}
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
it("personalMode: throws when personalUid is missing", async () => {
|
|
580
|
-
const { client } = makeStubVaultClient({});
|
|
581
|
-
const { factory } = makeStubS3Factory({});
|
|
582
|
-
|
|
583
|
-
await expect(
|
|
584
|
-
runBrowse({
|
|
585
|
-
pathPrefix: "",
|
|
586
|
-
personalMode: true,
|
|
587
|
-
// personalUid intentionally omitted
|
|
588
|
-
vaultClient: client,
|
|
589
|
-
s3Factory: factory,
|
|
590
|
-
region: "us-east-1",
|
|
591
|
-
}),
|
|
592
|
-
).rejects.toThrow(/personalMode requires personalUid/);
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
it("personalMode: throws when entity has no provisioned bucket", async () => {
|
|
596
|
-
// Force entity.get to return a bucket-less entity.
|
|
597
|
-
const { client } = makeStubVaultClient({
|
|
598
|
-
entity: { uid: "prs_test", slug: "personal" },
|
|
599
|
-
});
|
|
600
|
-
const { factory } = makeStubS3Factory({});
|
|
601
|
-
|
|
602
|
-
await expect(
|
|
603
|
-
runBrowse({
|
|
604
|
-
pathPrefix: "",
|
|
605
|
-
personalMode: true,
|
|
606
|
-
personalUid: "prs_test",
|
|
607
|
-
vaultClient: client,
|
|
608
|
-
s3Factory: factory,
|
|
609
|
-
region: "us-east-1",
|
|
610
|
-
}),
|
|
611
|
-
).rejects.toThrow(/no provisioned bucket/);
|
|
612
|
-
});
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// ── runCat ──────────────────────────────────────────────────────────────────
|
|
616
|
-
|
|
617
|
-
describe("runCat", () => {
|
|
618
|
-
it("company mode fetches the company-relative key via presign (no STS vend)", async () => {
|
|
619
|
-
const { client, spies } = makeStubVaultClient({});
|
|
620
|
-
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
621
|
-
getResponse: {
|
|
622
|
-
Body: Readable.from(Buffer.from("hello world")),
|
|
623
|
-
} as GetObjectCommandOutput,
|
|
624
|
-
});
|
|
625
|
-
// Drain to a sink so process.stdout isn't touched.
|
|
626
|
-
const sink = new Writable({ write(_c, _e, cb) { cb(); } });
|
|
627
|
-
await runCat({
|
|
628
|
-
key: "companies/indigo/scratch/a.txt",
|
|
629
|
-
vaultClient: client,
|
|
630
|
-
companyClient: companyFactory,
|
|
631
|
-
region: "us-east-1",
|
|
632
|
-
hqRoot: tmpRoot,
|
|
633
|
-
stdout: sink,
|
|
634
|
-
});
|
|
635
|
-
// HQ-59: company cat goes through the presign API — no STS vend.
|
|
636
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
637
|
-
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
638
|
-
// GetObject key is company-relative (anchor stripped).
|
|
639
|
-
const getCmd = sendSpy.mock.calls.find(
|
|
640
|
-
(c) => c[0] instanceof GetObjectCommand,
|
|
641
|
-
)?.[0] as GetObjectCommand;
|
|
642
|
-
expect(getCmd.input.Key).toBe("scratch/a.txt");
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("writes to --out when outside the companies tree and reports byte count", async () => {
|
|
646
|
-
const { client } = makeStubVaultClient({});
|
|
647
|
-
const payload = Buffer.from("safe-payload");
|
|
648
|
-
const { companyFactory } = makeStubS3Factory({
|
|
649
|
-
getResponse: {
|
|
650
|
-
Body: Readable.from(payload),
|
|
651
|
-
} as GetObjectCommandOutput,
|
|
652
|
-
});
|
|
653
|
-
const outFile = path.join(tmpRoot, "safe.txt");
|
|
654
|
-
const result = await runCat({
|
|
655
|
-
key: "companies/indigo/scratch/a.txt",
|
|
656
|
-
out: outFile,
|
|
657
|
-
vaultClient: client,
|
|
658
|
-
companyClient: companyFactory,
|
|
659
|
-
region: "us-east-1",
|
|
660
|
-
hqRoot: tmpRoot,
|
|
661
|
-
});
|
|
662
|
-
expect(result.destination).toEqual({ kind: "file", absPath: outFile });
|
|
663
|
-
expect(fs.readFileSync(outFile, "utf-8")).toBe("safe-payload");
|
|
664
|
-
expect(result.bytesWritten).toBe(payload.length);
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it("refuses --out under <hqRoot>/companies/ BEFORE vending (no leak)", async () => {
|
|
668
|
-
const { client, spies } = makeStubVaultClient({});
|
|
669
|
-
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
670
|
-
getResponse: { Body: Readable.from(Buffer.from("nope")) } as GetObjectCommandOutput,
|
|
671
|
-
});
|
|
672
|
-
const badOut = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
|
|
673
|
-
await expect(
|
|
674
|
-
runCat({
|
|
675
|
-
key: "companies/indigo/scratch/a.txt",
|
|
676
|
-
out: badOut,
|
|
677
|
-
vaultClient: client,
|
|
678
|
-
companyClient: companyFactory,
|
|
679
|
-
region: "us-east-1",
|
|
680
|
-
hqRoot: tmpRoot,
|
|
681
|
-
}),
|
|
682
|
-
).rejects.toThrow(/Refusing to write/);
|
|
683
|
-
// Critically: the guard runs FIRST — no vend, and no list/presign/S3 call
|
|
684
|
-
// was made. Failing closed is the whole point of the guard.
|
|
685
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
686
|
-
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
687
|
-
expect(sendSpy).not.toHaveBeenCalled();
|
|
688
|
-
// And no file was written under the protected tree.
|
|
689
|
-
expect(fs.existsSync(badOut)).toBe(false);
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
it("rejects a vault key that isn't anchored at companies/", async () => {
|
|
693
|
-
const { client } = makeStubVaultClient({});
|
|
694
|
-
const { factory } = makeStubS3Factory({});
|
|
695
|
-
await expect(
|
|
696
|
-
runCat({
|
|
697
|
-
key: "personal/notes/x.txt",
|
|
698
|
-
vaultClient: client,
|
|
699
|
-
s3Factory: factory,
|
|
700
|
-
region: "us-east-1",
|
|
701
|
-
hqRoot: tmpRoot,
|
|
702
|
-
}),
|
|
703
|
-
).rejects.toThrow(/Expected a path starting with 'companies\//);
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
707
|
-
|
|
708
|
-
it("personalMode: streams from the person bucket via /sts/vend-self, no slug parse", async () => {
|
|
709
|
-
const { client, spies } = makeStubVaultClient({
|
|
710
|
-
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
711
|
-
});
|
|
712
|
-
const body = new Readable({
|
|
713
|
-
read() {
|
|
714
|
-
this.push("hello personal");
|
|
715
|
-
this.push(null);
|
|
716
|
-
},
|
|
717
|
-
});
|
|
718
|
-
const { factory, sendSpy } = makeStubS3Factory({
|
|
719
|
-
getResponse: { Body: body as unknown as GetObjectCommandOutput["Body"] },
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
// The key has no `companies/<slug>/` prefix — would normally fail
|
|
723
|
-
// parseCompanySlugFromPath. Under personalMode, that parse is skipped.
|
|
724
|
-
const sink = new Writable({
|
|
725
|
-
write(_chunk, _enc, cb) {
|
|
726
|
-
cb();
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
|
-
const result = await runCat({
|
|
730
|
-
key: ".claude/CLAUDE.md",
|
|
731
|
-
personalMode: true,
|
|
732
|
-
personalUid: "prs_test",
|
|
733
|
-
vaultClient: client,
|
|
734
|
-
s3Factory: factory,
|
|
735
|
-
region: "us-east-1",
|
|
736
|
-
hqRoot: tmpRoot,
|
|
737
|
-
stdout: sink,
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
expect(result.destination.kind).toBe("stdout");
|
|
741
|
-
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
742
|
-
// Personal vends self, against the bucket-relative key.
|
|
743
|
-
expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
|
|
744
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
745
|
-
// GetObject targeted the person bucket with the bucket-relative key.
|
|
746
|
-
const getCmd = sendSpy.mock.calls.find(
|
|
747
|
-
(c) => c[0] instanceof GetObjectCommand,
|
|
748
|
-
)?.[0] as GetObjectCommand;
|
|
749
|
-
expect(getCmd.input.Bucket).toBe("hq-vault-prs-test");
|
|
750
|
-
expect(getCmd.input.Key).toBe(".claude/CLAUDE.md");
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
it("personalMode: throws when personalUid is missing", async () => {
|
|
754
|
-
const { client } = makeStubVaultClient({});
|
|
755
|
-
const { factory } = makeStubS3Factory({});
|
|
756
|
-
await expect(
|
|
757
|
-
runCat({
|
|
758
|
-
key: ".claude/CLAUDE.md",
|
|
759
|
-
personalMode: true,
|
|
760
|
-
// personalUid omitted
|
|
761
|
-
vaultClient: client,
|
|
762
|
-
s3Factory: factory,
|
|
763
|
-
region: "us-east-1",
|
|
764
|
-
hqRoot: tmpRoot,
|
|
765
|
-
}),
|
|
766
|
-
).rejects.toThrow(/personalMode requires personalUid/);
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
// ── runSharedWithMe ──────────────────────────────────────────────────────────
|
|
771
|
-
|
|
772
|
-
function sharedWithMeClient(opts: {
|
|
773
|
-
memberships?: Array<{ companyUid: string }>;
|
|
774
|
-
grantsByCompany?: Record<string, ExplicitGrant[]>;
|
|
775
|
-
slugByUid?: Record<string, string>;
|
|
776
|
-
}): FilesSharedWithMeVaultClient {
|
|
777
|
-
return {
|
|
778
|
-
listMyMemberships: async () => opts.memberships ?? [],
|
|
779
|
-
listMyExplicitGrants: async (companyUid: string) =>
|
|
780
|
-
opts.grantsByCompany?.[companyUid] ?? [],
|
|
781
|
-
entity: {
|
|
782
|
-
get: async (uid: string) => ({
|
|
783
|
-
uid,
|
|
784
|
-
slug: opts.slugByUid?.[uid] ?? uid,
|
|
785
|
-
}),
|
|
786
|
-
},
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
describe("runSharedWithMe", () => {
|
|
791
|
-
it("lists explicit grants for a single company (companyUid supplied)", async () => {
|
|
792
|
-
const rows = await runSharedWithMe({
|
|
793
|
-
vaultClient: sharedWithMeClient({
|
|
794
|
-
grantsByCompany: {
|
|
795
|
-
cmp_indigo: [fakeGrant("knowledge/"), fakeGrant("reports/q3.pdf")],
|
|
796
|
-
},
|
|
797
|
-
}),
|
|
798
|
-
companyUid: "cmp_indigo",
|
|
799
|
-
companySlug: "indigo",
|
|
800
|
-
});
|
|
801
|
-
expect(rows).toEqual([
|
|
802
|
-
{ companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
|
|
803
|
-
{ companySlug: "indigo", path: "reports/q3.pdf", permission: "read", source: "person" },
|
|
804
|
-
]);
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
it("rolls up across every membership when no company is supplied", async () => {
|
|
808
|
-
const rows = await runSharedWithMe({
|
|
809
|
-
vaultClient: sharedWithMeClient({
|
|
810
|
-
memberships: [{ companyUid: "cmp_b" }, { companyUid: "cmp_a" }],
|
|
811
|
-
slugByUid: { cmp_a: "acme", cmp_b: "beta" },
|
|
812
|
-
grantsByCompany: {
|
|
813
|
-
cmp_a: [fakeGrant("docs/")],
|
|
814
|
-
cmp_b: [fakeGrant("shared/")],
|
|
815
|
-
},
|
|
816
|
-
}),
|
|
817
|
-
});
|
|
818
|
-
// Sorted by company slug, then path.
|
|
819
|
-
expect(rows.map((r) => [r.companySlug, r.path])).toEqual([
|
|
820
|
-
["acme", "docs/"],
|
|
821
|
-
["beta", "shared/"],
|
|
822
|
-
]);
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
it("skips a company whose grant fetch throws (best-effort roll-up)", async () => {
|
|
826
|
-
const client: FilesSharedWithMeVaultClient = {
|
|
827
|
-
listMyMemberships: async () => [{ companyUid: "cmp_ok" }, { companyUid: "cmp_bad" }],
|
|
828
|
-
listMyExplicitGrants: async (uid: string) => {
|
|
829
|
-
if (uid === "cmp_bad") throw new Error("boom");
|
|
830
|
-
return [fakeGrant("ok/")];
|
|
831
|
-
},
|
|
832
|
-
entity: { get: async (uid: string) => ({ uid, slug: uid }) },
|
|
833
|
-
};
|
|
834
|
-
const rows = await runSharedWithMe({ vaultClient: client });
|
|
835
|
-
expect(rows).toEqual([
|
|
836
|
-
{ companySlug: "cmp_ok", path: "ok/", permission: "read", source: "person" },
|
|
837
|
-
]);
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
it("formats an empty result with the role-bypass caveat", () => {
|
|
841
|
-
const out = formatSharedWithMeTable([]);
|
|
842
|
-
expect(out).toContain("Nothing is explicitly shared with you");
|
|
843
|
-
expect(out).toContain("role-bypass");
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
it("formats rows as a table with company + path + permission + source", () => {
|
|
847
|
-
const out = formatSharedWithMeTable([
|
|
848
|
-
{ companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
|
|
849
|
-
]);
|
|
850
|
-
expect(out).toContain("COMPANY");
|
|
851
|
-
expect(out).toContain("indigo");
|
|
852
|
-
expect(out).toContain("knowledge/");
|
|
853
|
-
});
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
// ── runSearch ─────────────────────────────────────────────────────────────
|
|
857
|
-
|
|
858
|
-
describe("runSearch", () => {
|
|
859
|
-
it("filters the company listing by case-insensitive substring on the key", async () => {
|
|
860
|
-
const { client, spies } = makeStubVaultClient({});
|
|
861
|
-
const { companyFactory } = makeStubS3Factory({
|
|
862
|
-
listResponses: [
|
|
863
|
-
{
|
|
864
|
-
Contents: [
|
|
865
|
-
{ Key: "knowledge/Roadmap.md", Size: 1, LastModified: new Date() },
|
|
866
|
-
{ Key: "reports/q3.pdf", Size: 2, LastModified: new Date() },
|
|
867
|
-
{ Key: "knowledge/notes.md", Size: 3, LastModified: new Date() },
|
|
868
|
-
],
|
|
869
|
-
},
|
|
870
|
-
],
|
|
871
|
-
});
|
|
872
|
-
const rows = await runSearch({
|
|
873
|
-
query: "roadmap", // lower-case query matches mixed-case key
|
|
874
|
-
companySlug: "indigo",
|
|
875
|
-
vaultClient: client,
|
|
876
|
-
companyClient: companyFactory,
|
|
877
|
-
region: "us-east-1",
|
|
878
|
-
});
|
|
879
|
-
// Keys are re-anchored for display; only the matching one survives.
|
|
880
|
-
expect(rows.map((r) => r.key)).toEqual([
|
|
881
|
-
"companies/indigo/knowledge/Roadmap.md",
|
|
882
|
-
]);
|
|
883
|
-
// HQ-59: search lists via the presign/list API (inherited from runBrowse) —
|
|
884
|
-
// no STS vend.
|
|
885
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
it("returns an empty array when nothing matches", async () => {
|
|
889
|
-
const { client } = makeStubVaultClient({});
|
|
890
|
-
const { companyFactory } = makeStubS3Factory({
|
|
891
|
-
listResponses: [
|
|
892
|
-
{ Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
|
|
893
|
-
],
|
|
894
|
-
});
|
|
895
|
-
const rows = await runSearch({
|
|
896
|
-
query: "zzz-no-match",
|
|
897
|
-
companySlug: "indigo",
|
|
898
|
-
vaultClient: client,
|
|
899
|
-
companyClient: companyFactory,
|
|
900
|
-
region: "us-east-1",
|
|
901
|
-
});
|
|
902
|
-
expect(rows).toEqual([]);
|
|
903
|
-
});
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
// ── pin set (readPins / addPin) ─────────────────────────────────────────────
|
|
907
|
-
|
|
908
|
-
describe("pin set", () => {
|
|
909
|
-
it("readPins returns an empty set when the file is missing", () => {
|
|
910
|
-
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
it("addPin creates the file, dedups, and sorts prefixes", () => {
|
|
914
|
-
addPin(tmpRoot, "indigo", "knowledge/");
|
|
915
|
-
addPin(tmpRoot, "indigo", "knowledge/"); // duplicate — ignored
|
|
916
|
-
addPin(tmpRoot, "indigo", "data/");
|
|
917
|
-
const pf = readPins(tmpRoot);
|
|
918
|
-
expect(pf.pins.indigo).toEqual(["data/", "knowledge/"]);
|
|
919
|
-
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(true);
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
it("addPin keeps per-company lists separate", () => {
|
|
923
|
-
addPin(tmpRoot, "indigo", "knowledge/");
|
|
924
|
-
addPin(tmpRoot, "acme", "docs/");
|
|
925
|
-
const pf = readPins(tmpRoot);
|
|
926
|
-
expect(pf.pins).toEqual({ indigo: ["knowledge/"], acme: ["docs/"] });
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
it("readPins tolerates a corrupt pins.json (→ fresh)", () => {
|
|
930
|
-
fs.mkdirSync(path.dirname(pinFilePath(tmpRoot)), { recursive: true });
|
|
931
|
-
fs.writeFileSync(pinFilePath(tmpRoot), "{ not valid json");
|
|
932
|
-
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
933
|
-
});
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
// ── runGet ────────────────────────────────────────────────────────────────
|
|
937
|
-
|
|
938
|
-
function makeGetStubS3(opts: {
|
|
939
|
-
listKeys: Array<{ Key: string; Size: number }>;
|
|
940
|
-
bodyFor: (key: string) => Buffer;
|
|
941
|
-
}): { companyFactory: CompanyBrowseClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
|
|
942
|
-
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
943
|
-
if (cmd instanceof ListObjectsV2Command) {
|
|
944
|
-
return {
|
|
945
|
-
Contents: opts.listKeys.map((k) => ({ ...k, LastModified: new Date() })),
|
|
946
|
-
} as ListObjectsV2CommandOutput;
|
|
947
|
-
}
|
|
948
|
-
if (cmd instanceof GetObjectCommand) {
|
|
949
|
-
const key = (cmd as GetObjectCommand).input.Key as string;
|
|
950
|
-
return {
|
|
951
|
-
Body: Readable.from(opts.bodyFor(key)),
|
|
952
|
-
} as unknown as GetObjectCommandOutput;
|
|
953
|
-
}
|
|
954
|
-
throw new Error(`unexpected command: ${cmd}`);
|
|
955
|
-
});
|
|
956
|
-
// `get` is company-only now, so the stub is the presign/list seam.
|
|
957
|
-
const companyFactory = vi.fn(
|
|
958
|
-
() => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
|
|
959
|
-
) as unknown as CompanyBrowseClientFactory;
|
|
960
|
-
return { companyFactory, sendSpy };
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
describe("runGet", () => {
|
|
964
|
-
it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
|
|
965
|
-
const { client, spies } = makeStubVaultClient({});
|
|
966
|
-
const { companyFactory, sendSpy } = makeGetStubS3({
|
|
967
|
-
listKeys: [
|
|
968
|
-
{ Key: "knowledge/a.md", Size: 3 },
|
|
969
|
-
{ Key: "knowledge/sub/b.md", Size: 3 },
|
|
970
|
-
],
|
|
971
|
-
bodyFor: (k) => Buffer.from(k === "knowledge/a.md" ? "AAA" : "BBB"),
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
const result = await runGet({
|
|
975
|
-
path: "companies/indigo/knowledge/",
|
|
976
|
-
hqRoot: tmpRoot,
|
|
977
|
-
companySlug: "indigo",
|
|
978
|
-
vaultClient: client,
|
|
979
|
-
companyClient: companyFactory,
|
|
980
|
-
region: "us-east-1",
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
// HQ-59: get materializes through the presign/list API — no STS vend.
|
|
984
|
-
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
985
|
-
expect(result.filesWritten).toBe(2);
|
|
986
|
-
|
|
987
|
-
// Files landed in place under companies/<slug>/.
|
|
988
|
-
expect(
|
|
989
|
-
fs.readFileSync(
|
|
990
|
-
path.join(tmpRoot, "companies", "indigo", "knowledge", "a.md"),
|
|
991
|
-
"utf-8",
|
|
992
|
-
),
|
|
993
|
-
).toBe("AAA");
|
|
994
|
-
expect(
|
|
995
|
-
fs.readFileSync(
|
|
996
|
-
path.join(tmpRoot, "companies", "indigo", "knowledge", "sub", "b.md"),
|
|
997
|
-
"utf-8",
|
|
998
|
-
),
|
|
999
|
-
).toBe("BBB");
|
|
1000
|
-
|
|
1001
|
-
// GetObject used company-relative keys (no companies/<slug>/ anchor).
|
|
1002
|
-
const getKeys = sendSpy.mock.calls
|
|
1003
|
-
.filter((c) => c[0] instanceof GetObjectCommand)
|
|
1004
|
-
.map((c) => (c[0] as GetObjectCommand).input.Key);
|
|
1005
|
-
expect(getKeys).toEqual(["knowledge/a.md", "knowledge/sub/b.md"]);
|
|
1006
|
-
|
|
1007
|
-
// Pin registered for the in-place prefix.
|
|
1008
|
-
expect(readPins(tmpRoot).pins.indigo).toEqual(["knowledge/"]);
|
|
1009
|
-
expect(result.pinned).toEqual({ companySlug: "indigo", prefix: "knowledge/" });
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
it("--into writes outside companies/ and registers NO pin", async () => {
|
|
1013
|
-
const into = path.join(tmpRoot, "extract");
|
|
1014
|
-
const { client } = makeStubVaultClient({});
|
|
1015
|
-
const { companyFactory } = makeGetStubS3({
|
|
1016
|
-
listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
|
|
1017
|
-
bodyFor: () => Buffer.from("AAA"),
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
const result = await runGet({
|
|
1021
|
-
path: "companies/indigo/knowledge/",
|
|
1022
|
-
into,
|
|
1023
|
-
hqRoot: tmpRoot,
|
|
1024
|
-
companySlug: "indigo",
|
|
1025
|
-
vaultClient: client,
|
|
1026
|
-
companyClient: companyFactory,
|
|
1027
|
-
region: "us-east-1",
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
// Written relative to the requested prefix, under --into.
|
|
1031
|
-
expect(fs.readFileSync(path.join(into, "a.md"), "utf-8")).toBe("AAA");
|
|
1032
|
-
// No pin — --into is outside the sync envelope.
|
|
1033
|
-
expect(result.pinned).toBeUndefined();
|
|
1034
|
-
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(false);
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
it("throws when no objects exist under the path", async () => {
|
|
1038
|
-
const { client } = makeStubVaultClient({});
|
|
1039
|
-
const { companyFactory } = makeGetStubS3({
|
|
1040
|
-
listKeys: [],
|
|
1041
|
-
bodyFor: () => Buffer.from(""),
|
|
1042
|
-
});
|
|
1043
|
-
await expect(
|
|
1044
|
-
runGet({
|
|
1045
|
-
path: "companies/indigo/nope/",
|
|
1046
|
-
hqRoot: tmpRoot,
|
|
1047
|
-
companySlug: "indigo",
|
|
1048
|
-
vaultClient: client,
|
|
1049
|
-
companyClient: companyFactory,
|
|
1050
|
-
region: "us-east-1",
|
|
1051
|
-
}),
|
|
1052
|
-
).rejects.toThrow(/No objects under/);
|
|
1053
|
-
});
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
// ── --company parent-binding (regression) ───────────────────────────────────
|
|
1057
|
-
//
|
|
1058
|
-
// The `files` parent group declares `--company`, so commander binds the flag
|
|
1059
|
-
// to the PARENT command, not the subcommand — `subcommand.opts().company` is
|
|
1060
|
-
// empty. The browse/cat/search/get/shared-with-me actions therefore resolve
|
|
1061
|
-
// it via `command.optsWithGlobals().company`. This guards that contract so a
|
|
1062
|
-
// future refactor doesn't silently regress `hq files <sub> ... --company X`
|
|
1063
|
-
// back to the swallowed-flag bug.
|
|
1064
|
-
|
|
1065
|
-
describe("--company parent binding (optsWithGlobals)", () => {
|
|
1066
|
-
it("a subcommand sees --company declared on the parent group", () => {
|
|
1067
|
-
const program = new Command();
|
|
1068
|
-
const files = program
|
|
1069
|
-
.command("files")
|
|
1070
|
-
.option("--company <slug>", "Company slug");
|
|
1071
|
-
let seen: string | undefined = "UNSET";
|
|
1072
|
-
files
|
|
1073
|
-
.command("search <query>")
|
|
1074
|
-
.option("--personal")
|
|
1075
|
-
.action((_query: string, _options: unknown, command: Command) => {
|
|
1076
|
-
seen = command.optsWithGlobals().company as string | undefined;
|
|
1077
|
-
});
|
|
1078
|
-
program.parse(
|
|
1079
|
-
["files", "search", "README", "--company", "indigo"],
|
|
1080
|
-
{ from: "user" } as never,
|
|
1081
|
-
);
|
|
1082
|
-
expect(seen).toBe("indigo");
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
it("documents the bug: subcommand-local opts.company is empty for a parent-bound flag", () => {
|
|
1086
|
-
const program = new Command();
|
|
1087
|
-
const files = program
|
|
1088
|
-
.command("files")
|
|
1089
|
-
.option("--company <slug>", "Company slug");
|
|
1090
|
-
let local: string | undefined = "UNSET";
|
|
1091
|
-
files
|
|
1092
|
-
.command("search <query>")
|
|
1093
|
-
.action((_query: string, options: { company?: string }) => {
|
|
1094
|
-
local = options.company;
|
|
1095
|
-
});
|
|
1096
|
-
program.parse(
|
|
1097
|
-
["files", "search", "README", "--company", "indigo"],
|
|
1098
|
-
{ from: "user" } as never,
|
|
1099
|
-
);
|
|
1100
|
-
expect(local).toBeUndefined();
|
|
1101
|
-
});
|
|
1102
|
-
});
|
|
1103
|
-
|
|
1104
|
-
// ── createCompanyPresignClient (HQ-59 list/presign translation) ─────────────
|
|
1105
|
-
//
|
|
1106
|
-
// The company-mode browse client. Verifies it translates the S3-shaped
|
|
1107
|
-
// commands the orchestrators issue into the server-side list/presign API:
|
|
1108
|
-
// - ListObjectsV2Command → GET /v1/files/list (company-relative objects)
|
|
1109
|
-
// - GetObjectCommand → POST /v1/files/presign → fetch(url) → Body
|
|
1110
|
-
// fetch is the single network seam (vaultApiFetch uses it; the presigned
|
|
1111
|
-
// download uses it), so we spy on globalThis.fetch and route by URL.
|
|
1112
|
-
|
|
1113
|
-
describe("createCompanyPresignClient", () => {
|
|
1114
|
-
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
1115
|
-
|
|
1116
|
-
beforeEach(() => {
|
|
1117
|
-
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
1118
|
-
});
|
|
1119
|
-
afterEach(() => {
|
|
1120
|
-
fetchSpy.mockRestore();
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
function json(status: number, body: unknown): Response {
|
|
1124
|
-
return new Response(JSON.stringify(body), {
|
|
1125
|
-
status,
|
|
1126
|
-
headers: { "content-type": "application/json" },
|
|
1127
|
-
});
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
it("ListObjectsV2 → GET /v1/files/list, mapping objects + cursor onto the S3 shape", async () => {
|
|
1131
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1132
|
-
json(200, {
|
|
1133
|
-
prefix: "scratch/",
|
|
1134
|
-
objects: [
|
|
1135
|
-
{
|
|
1136
|
-
key: "scratch/a.txt",
|
|
1137
|
-
size: 12,
|
|
1138
|
-
lastModified: "2026-01-01T00:00:00.000Z",
|
|
1139
|
-
etag: "abc123",
|
|
1140
|
-
permission: "read",
|
|
1141
|
-
},
|
|
1142
|
-
],
|
|
1143
|
-
cursor: "next-page",
|
|
1144
|
-
truncated: true,
|
|
1145
|
-
}),
|
|
1146
|
-
);
|
|
1147
|
-
|
|
1148
|
-
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1149
|
-
const out = await client.send(
|
|
1150
|
-
new ListObjectsV2Command({
|
|
1151
|
-
Bucket: "ignored",
|
|
1152
|
-
Prefix: "scratch/",
|
|
1153
|
-
ContinuationToken: undefined,
|
|
1154
|
-
}),
|
|
1155
|
-
);
|
|
1156
|
-
|
|
1157
|
-
// Request: GET to /v1/files/list with company + prefix query, bearer token.
|
|
1158
|
-
const [url, init] = fetchSpy.mock.calls[0];
|
|
1159
|
-
expect(String(url)).toContain("/v1/files/list");
|
|
1160
|
-
expect(String(url)).toContain("company=cmp_indigo");
|
|
1161
|
-
expect(String(url)).toContain("prefix=scratch%2F");
|
|
1162
|
-
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer tok");
|
|
1163
|
-
|
|
1164
|
-
// Response mapped onto the ListObjectsV2 shape the orchestrators read.
|
|
1165
|
-
expect(out.Contents).toHaveLength(1);
|
|
1166
|
-
expect(out.Contents?.[0].Key).toBe("scratch/a.txt");
|
|
1167
|
-
expect(out.Contents?.[0].Size).toBe(12);
|
|
1168
|
-
expect(out.Contents?.[0].ETag).toBe('"abc123"');
|
|
1169
|
-
expect(out.Contents?.[0].LastModified?.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
|
1170
|
-
expect(out.NextContinuationToken).toBe("next-page");
|
|
1171
|
-
expect(out.IsTruncated).toBe(true);
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
it("ListObjectsV2 forwards the ContinuationToken as the cursor query param", async () => {
|
|
1175
|
-
fetchSpy.mockResolvedValueOnce(json(200, { objects: [], cursor: null, truncated: false }));
|
|
1176
|
-
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1177
|
-
await client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/", ContinuationToken: "page2" }));
|
|
1178
|
-
expect(String(fetchSpy.mock.calls[0][0])).toContain("cursor=page2");
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
it("ListObjectsV2 throws on a non-2xx list response", async () => {
|
|
1182
|
-
fetchSpy.mockResolvedValueOnce(json(403, { error: "Forbidden", code: "X" }));
|
|
1183
|
-
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1184
|
-
await expect(
|
|
1185
|
-
client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/" })),
|
|
1186
|
-
).rejects.toThrow(/Forbidden/);
|
|
1187
|
-
});
|
|
1188
|
-
|
|
1189
|
-
it("GetObject → POST /v1/files/presign then fetches the URL and returns a Body", async () => {
|
|
1190
|
-
// 1) presign POST → returns a URL; 2) the URL fetch → returns the bytes.
|
|
1191
|
-
fetchSpy
|
|
1192
|
-
.mockResolvedValueOnce(
|
|
1193
|
-
json(200, { results: [{ key: "scratch/a.txt", op: "get", url: "https://s3.example/presigned" }] }),
|
|
1194
|
-
)
|
|
1195
|
-
.mockResolvedValueOnce(new Response("file-bytes-here", { status: 200 }));
|
|
1196
|
-
|
|
1197
|
-
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1198
|
-
const out = await client.send(
|
|
1199
|
-
new GetObjectCommand({ Bucket: "ignored", Key: "scratch/a.txt" }),
|
|
1200
|
-
);
|
|
1201
|
-
|
|
1202
|
-
// First call: POST presign with the right body.
|
|
1203
|
-
const [presignUrl, presignInit] = fetchSpy.mock.calls[0];
|
|
1204
|
-
expect(String(presignUrl)).toContain("/v1/files/presign");
|
|
1205
|
-
expect(presignInit?.method).toBe("POST");
|
|
1206
|
-
expect(JSON.parse(presignInit?.body as string)).toEqual({
|
|
1207
|
-
company: "cmp_indigo",
|
|
1208
|
-
key: "scratch/a.txt",
|
|
1209
|
-
op: "get",
|
|
1210
|
-
});
|
|
1211
|
-
// Second call: fetch the presigned URL.
|
|
1212
|
-
expect(String(fetchSpy.mock.calls[1][0])).toBe("https://s3.example/presigned");
|
|
1213
|
-
|
|
1214
|
-
// Body is a readable stream of the downloaded bytes.
|
|
1215
|
-
const chunks: Buffer[] = [];
|
|
1216
|
-
for await (const c of out.Body as unknown as AsyncIterable<Buffer>) {
|
|
1217
|
-
chunks.push(Buffer.from(c));
|
|
1218
|
-
}
|
|
1219
|
-
expect(Buffer.concat(chunks).toString("utf-8")).toBe("file-bytes-here");
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
it("GetObject throws the per-key error when presign denies the key (no URL)", async () => {
|
|
1223
|
-
fetchSpy.mockResolvedValueOnce(
|
|
1224
|
-
json(200, {
|
|
1225
|
-
results: [
|
|
1226
|
-
{ key: "secret/x", op: "get", error: "Caller lacks read permission on 'secret/x'", code: "FILES_PRESIGN_FORBIDDEN" },
|
|
1227
|
-
],
|
|
1228
|
-
}),
|
|
1229
|
-
);
|
|
1230
|
-
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1231
|
-
await expect(
|
|
1232
|
-
client.send(new GetObjectCommand({ Bucket: "b", Key: "secret/x" })),
|
|
1233
|
-
).rejects.toThrow(/lacks read permission/);
|
|
1234
|
-
// The denial short-circuits BEFORE any download fetch.
|
|
1235
|
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
it("GetObject throws when the presigned download itself fails", async () => {
|
|
1239
|
-
fetchSpy
|
|
1240
|
-
.mockResolvedValueOnce(
|
|
1241
|
-
json(200, { results: [{ key: "k", op: "get", url: "https://s3.example/x" }] }),
|
|
1242
|
-
)
|
|
1243
|
-
.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
|
1244
|
-
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1245
|
-
await expect(
|
|
1246
|
-
client.send(new GetObjectCommand({ Bucket: "b", Key: "k" })),
|
|
1247
|
-
).rejects.toThrow(/Failed to download 'k'/);
|
|
1248
|
-
});
|
|
1249
|
-
});
|