@indigoai-us/hq-cloud 5.1.0 → 5.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/bin/sync-runner.d.ts +134 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +360 -0
  4. package/dist/bin/sync-runner.js.map +1 -0
  5. package/dist/bin/sync-runner.test.d.ts +10 -0
  6. package/dist/bin/sync-runner.test.d.ts.map +1 -0
  7. package/dist/bin/sync-runner.test.js +648 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -0
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/share.js +2 -2
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +9 -1
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +28 -0
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +33 -10
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +15 -4
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts.map +1 -1
  22. package/dist/cognito-auth.js +19 -1
  23. package/dist/cognito-auth.js.map +1 -1
  24. package/dist/cognito-auth.test.d.ts +9 -0
  25. package/dist/cognito-auth.test.d.ts.map +1 -0
  26. package/dist/cognito-auth.test.js +113 -0
  27. package/dist/cognito-auth.test.js.map +1 -0
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +1 -0
  30. package/dist/context.js.map +1 -1
  31. package/dist/daemon-worker.d.ts +6 -1
  32. package/dist/daemon-worker.d.ts.map +1 -1
  33. package/dist/daemon-worker.js +12 -16
  34. package/dist/daemon-worker.js.map +1 -1
  35. package/dist/daemon.d.ts +2 -0
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +2 -0
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/ignore.d.ts +13 -2
  40. package/dist/ignore.d.ts.map +1 -1
  41. package/dist/ignore.js +69 -12
  42. package/dist/ignore.js.map +1 -1
  43. package/dist/index.d.ts +24 -28
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +19 -134
  46. package/dist/index.js.map +1 -1
  47. package/dist/journal.d.ts +20 -4
  48. package/dist/journal.d.ts.map +1 -1
  49. package/dist/journal.js +45 -8
  50. package/dist/journal.js.map +1 -1
  51. package/dist/journal.test.d.ts +9 -0
  52. package/dist/journal.test.d.ts.map +1 -0
  53. package/dist/journal.test.js +114 -0
  54. package/dist/journal.test.js.map +1 -0
  55. package/dist/s3.d.ts +18 -6
  56. package/dist/s3.d.ts.map +1 -1
  57. package/dist/s3.js +57 -56
  58. package/dist/s3.js.map +1 -1
  59. package/dist/types.d.ts +34 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/vault-client.d.ts +59 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +72 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +160 -0
  66. package/dist/vault-client.test.js.map +1 -1
  67. package/dist/watcher.d.ts +7 -1
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +11 -5
  70. package/dist/watcher.js.map +1 -1
  71. package/package.json +15 -3
  72. package/src/bin/sync-runner.test.ts +804 -0
  73. package/src/bin/sync-runner.ts +499 -0
  74. package/src/cli/accept.ts +97 -0
  75. package/src/cli/conflict.ts +119 -0
  76. package/src/cli/index.ts +25 -0
  77. package/src/cli/invite.test.ts +247 -0
  78. package/src/cli/invite.ts +180 -0
  79. package/src/cli/promote.ts +123 -0
  80. package/src/cli/share.test.ts +155 -0
  81. package/src/cli/share.ts +212 -0
  82. package/src/cli/sync.test.ts +225 -0
  83. package/src/cli/sync.ts +225 -0
  84. package/src/cognito-auth.test.ts +156 -0
  85. package/src/cognito-auth.ts +18 -1
  86. package/src/context.test.ts +202 -0
  87. package/src/context.ts +178 -0
  88. package/src/daemon-worker.ts +13 -19
  89. package/src/daemon.ts +2 -0
  90. package/src/ignore.ts +76 -12
  91. package/src/index.ts +94 -165
  92. package/src/journal.test.ts +146 -0
  93. package/src/journal.ts +53 -11
  94. package/src/s3.ts +76 -66
  95. package/src/types.ts +37 -0
  96. package/src/vault-client.test.ts +563 -0
  97. package/src/vault-client.ts +478 -0
  98. package/src/watcher.ts +12 -5
  99. package/test/invite-flow.integration.test.ts +244 -0
  100. package/test/share-sync.integration.test.ts +210 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `hq sync` command — pull everything allowed from entity vault (VLT-5 US-002).
3
+ *
4
+ * Pulls all files the caller's STS session policy permits.
5
+ * Never auto-overwrites local changes — prompts on conflict.
6
+ */
7
+
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import type { VaultServiceConfig } from "../types.js";
11
+ import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
+ import { downloadFile, listRemoteFiles } from "../s3.js";
13
+ import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
14
+ import { createIgnoreFilter } from "../ignore.js";
15
+ import { resolveConflict } from "./conflict.js";
16
+ import type { ConflictStrategy } from "./conflict.js";
17
+
18
+ /**
19
+ * Per-file events emitted by `sync()` as it progresses.
20
+ *
21
+ * When `SyncOptions.onEvent` is set, these events are delivered to the caller
22
+ * in place of the default human-readable `console.log` / `console.error`
23
+ * output. This is the seam that lets `hq-sync-runner` stream ndjson to the
24
+ * AppBar menubar without the engine knowing anything about ndjson (ADR-0001).
25
+ *
26
+ * The human CLI (`hq sync`) leaves `onEvent` undefined and falls through to
27
+ * `defaultConsoleLogger` below, which preserves the existing tty output.
28
+ */
29
+ export type SyncProgressEvent =
30
+ | { type: "progress"; path: string; bytes: number; message?: string }
31
+ | { type: "error"; path: string; message: string };
32
+
33
+ export interface SyncOptions {
34
+ /** Company slug or UID (defaults to active company from config) */
35
+ company?: string;
36
+ /** Non-interactive conflict strategy */
37
+ onConflict?: ConflictStrategy;
38
+ /** Vault service config */
39
+ vaultConfig: VaultServiceConfig;
40
+ /** HQ root directory */
41
+ hqRoot: string;
42
+ /**
43
+ * Per-file event callback. When present, suppresses the default
44
+ * `console.log`/`console.error` human output — the caller is expected to
45
+ * render events themselves (e.g. emit ndjson to stdout). When absent, the
46
+ * default human logger is used. See `SyncProgressEvent`.
47
+ */
48
+ onEvent?: (event: SyncProgressEvent) => void;
49
+ }
50
+
51
+ export interface SyncResult {
52
+ filesDownloaded: number;
53
+ bytesDownloaded: number;
54
+ filesSkipped: number;
55
+ conflicts: number;
56
+ aborted: boolean;
57
+ }
58
+
59
+ /**
60
+ * Sync (pull) all allowed files from the entity vault.
61
+ */
62
+ export async function sync(options: SyncOptions): Promise<SyncResult> {
63
+ const { company, onConflict, vaultConfig, hqRoot } = options;
64
+ const emit = options.onEvent ?? defaultConsoleLogger;
65
+
66
+ // Resolve company
67
+ const companyRef = company ?? resolveActiveCompany(hqRoot);
68
+ if (!companyRef) {
69
+ throw new Error(
70
+ "No company specified and no active company found. " +
71
+ "Use --company <slug> or set up .hq/config.json.",
72
+ );
73
+ }
74
+
75
+ // Resolve entity context
76
+ let ctx = await resolveEntityContext(companyRef, vaultConfig);
77
+ const shouldSync = createIgnoreFilter(hqRoot);
78
+ const journal = readJournal(ctx.slug);
79
+
80
+ let filesDownloaded = 0;
81
+ let bytesDownloaded = 0;
82
+ let filesSkipped = 0;
83
+ let conflicts = 0;
84
+
85
+ // List all remote files (IAM session policy filters at the AWS layer)
86
+ const remoteFiles = await listRemoteFiles(ctx);
87
+
88
+ for (const remoteFile of remoteFiles) {
89
+ const localPath = path.join(hqRoot, remoteFile.key);
90
+
91
+ // Apply ignore rules
92
+ if (!shouldSync(localPath)) {
93
+ filesSkipped++;
94
+ continue;
95
+ }
96
+
97
+ // Auto-refresh context if credentials expiring
98
+ if (isExpiringSoon(ctx.expiresAt)) {
99
+ ctx = await refreshEntityContext(companyRef, vaultConfig);
100
+ }
101
+
102
+ // Check for local conflict
103
+ const journalEntry = getEntry(journal, remoteFile.key);
104
+
105
+ if (fs.existsSync(localPath)) {
106
+ const localHash = hashFile(localPath);
107
+
108
+ // If local file has changed since last sync, it's a conflict
109
+ if (journalEntry && journalEntry.hash !== localHash) {
110
+ conflicts++;
111
+
112
+ const resolution = await resolveConflict(
113
+ {
114
+ path: remoteFile.key,
115
+ localHash,
116
+ remoteModified: remoteFile.lastModified,
117
+ localModified: fs.statSync(localPath).mtime,
118
+ direction: "pull",
119
+ },
120
+ onConflict,
121
+ );
122
+
123
+ if (resolution === "abort") {
124
+ writeJournal(ctx.slug, journal);
125
+ return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: true };
126
+ }
127
+ if (resolution === "keep" || resolution === "skip") {
128
+ filesSkipped++;
129
+ continue;
130
+ }
131
+ // "overwrite" falls through to download
132
+ } else if (journalEntry && journalEntry.hash === localHash) {
133
+ // Local unchanged since last sync — check if remote changed
134
+ // by comparing etag/timestamp
135
+ const lastSyncTime = new Date(journalEntry.syncedAt).getTime();
136
+ const remoteModTime = remoteFile.lastModified.getTime();
137
+ if (remoteModTime <= lastSyncTime) {
138
+ // Remote hasn't changed either — skip
139
+ filesSkipped++;
140
+ continue;
141
+ }
142
+ }
143
+ }
144
+
145
+ // Download
146
+ try {
147
+ await downloadFile(ctx, remoteFile.key, localPath);
148
+
149
+ const hash = hashFile(localPath);
150
+ const stat = fs.statSync(localPath);
151
+ updateEntry(journal, remoteFile.key, hash, stat.size, "down");
152
+
153
+ // Attach message from journal entry if present
154
+ const remoteJournalMessage = (journalEntry as { message?: string } | undefined)?.message;
155
+ emit({
156
+ type: "progress",
157
+ path: remoteFile.key,
158
+ bytes: stat.size,
159
+ ...(remoteJournalMessage ? { message: remoteJournalMessage } : {}),
160
+ });
161
+
162
+ filesDownloaded++;
163
+ bytesDownloaded += stat.size;
164
+ } catch (err) {
165
+ // STS session policy may deny access to some paths — this is expected
166
+ // for guest members with allowedPrefixes
167
+ if (isAccessDenied(err)) {
168
+ filesSkipped++;
169
+ } else {
170
+ emit({
171
+ type: "error",
172
+ path: remoteFile.key,
173
+ message: err instanceof Error ? err.message : String(err),
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ writeJournal(ctx.slug, journal);
180
+
181
+ return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: false };
182
+ }
183
+
184
+ /**
185
+ * Resolve active company from .hq/config.json.
186
+ */
187
+ function resolveActiveCompany(hqRoot: string): string | undefined {
188
+ const configPath = path.join(hqRoot, ".hq", "config.json");
189
+ if (fs.existsSync(configPath)) {
190
+ try {
191
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
192
+ return config.activeCompany ?? config.companySlug;
193
+ } catch {
194
+ // Ignore parse errors
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ /**
201
+ * Check if an error is an S3 access denied (expected for filtered guests).
202
+ */
203
+ function isAccessDenied(err: unknown): boolean {
204
+ if (err && typeof err === "object" && "name" in err) {
205
+ return err.name === "AccessDenied" || err.name === "Forbidden";
206
+ }
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Default human-readable event rendering. Preserves the exact output format
212
+ * that `hq sync` emitted before SyncProgressEvent was introduced, so callers
213
+ * without an `onEvent` see no behavioral change.
214
+ */
215
+ function defaultConsoleLogger(event: SyncProgressEvent): void {
216
+ if (event.type === "progress") {
217
+ if (event.message) {
218
+ console.log(` ✓ ${event.path} — "${event.message}"`);
219
+ } else {
220
+ console.log(` ✓ ${event.path}`);
221
+ }
222
+ } else if (event.type === "error") {
223
+ console.error(` ✗ ${event.path} — ${event.message}`);
224
+ }
225
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Unit tests for cognito-auth.ts — focus on the `expiresAt` shape contract.
3
+ *
4
+ * Canonical on-disk shape is ISO 8601 (what both writers emit). The reader
5
+ * also tolerates a raw number (ms since epoch) for forward/backward compat
6
+ * during rollouts, and fails safe on anything unparseable.
7
+ */
8
+
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ // Sandbox HOME *before* importing the module — it reads os.homedir() at load
15
+ // time to compute the cache file path.
16
+ let originalHome: string | undefined;
17
+ let tmpHome: string;
18
+
19
+ beforeEach(() => {
20
+ originalHome = process.env.HOME;
21
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cognito-auth-test-"));
22
+ process.env.HOME = tmpHome;
23
+ vi.resetModules();
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (originalHome === undefined) delete process.env.HOME;
28
+ else process.env.HOME = originalHome;
29
+ fs.rmSync(tmpHome, { recursive: true, force: true });
30
+ vi.unstubAllGlobals();
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ async function importModule() {
35
+ return await import("./cognito-auth.js");
36
+ }
37
+
38
+ const baseTokens = {
39
+ accessToken: "access",
40
+ idToken: "id",
41
+ refreshToken: "refresh",
42
+ tokenType: "Bearer" as const,
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Reader: isExpiring accepts both shapes and fails safe
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("isExpiring — expiresAt shape tolerance", () => {
50
+ it("returns false for ISO string far in the future", async () => {
51
+ const { isExpiring } = await importModule();
52
+ const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
53
+ expect(isExpiring({ ...baseTokens, expiresAt: future })).toBe(false);
54
+ });
55
+
56
+ it("returns true for ISO string within the buffer window", async () => {
57
+ const { isExpiring } = await importModule();
58
+ const soon = new Date(Date.now() + 10 * 1000).toISOString();
59
+ expect(isExpiring({ ...baseTokens, expiresAt: soon }, 60)).toBe(true);
60
+ });
61
+
62
+ it("returns false for raw number (ms) far in the future", async () => {
63
+ const { isExpiring } = await importModule();
64
+ const future = Date.now() + 60 * 60 * 1000;
65
+ // Cast because the type says string; the point is runtime tolerance.
66
+ expect(
67
+ isExpiring({ ...baseTokens, expiresAt: future as unknown as string }),
68
+ ).toBe(false);
69
+ });
70
+
71
+ it("returns true for raw number (ms) within the buffer window", async () => {
72
+ const { isExpiring } = await importModule();
73
+ const soon = Date.now() + 10 * 1000;
74
+ expect(
75
+ isExpiring(
76
+ { ...baseTokens, expiresAt: soon as unknown as string },
77
+ 60,
78
+ ),
79
+ ).toBe(true);
80
+ });
81
+
82
+ it("fails safe (returns true) for malformed expiresAt", async () => {
83
+ const { isExpiring } = await importModule();
84
+ expect(
85
+ isExpiring({ ...baseTokens, expiresAt: "not a date" }),
86
+ ).toBe(true);
87
+ expect(
88
+ isExpiring({
89
+ ...baseTokens,
90
+ expiresAt: undefined as unknown as string,
91
+ }),
92
+ ).toBe(true);
93
+ expect(
94
+ isExpiring({
95
+ ...baseTokens,
96
+ expiresAt: Number.NaN as unknown as string,
97
+ }),
98
+ ).toBe(true);
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Round-trip: writers emit ISO, readers read ISO
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("expiresAt shape round-trip", () => {
107
+ it("saveCachedTokens + loadCachedTokens preserves ISO string shape", async () => {
108
+ const { saveCachedTokens, loadCachedTokens } = await importModule();
109
+ const iso = new Date(Date.now() + 3600 * 1000).toISOString();
110
+ saveCachedTokens({ ...baseTokens, expiresAt: iso });
111
+ const loaded = loadCachedTokens();
112
+ expect(loaded).not.toBeNull();
113
+ expect(typeof loaded?.expiresAt).toBe("string");
114
+ expect(loaded?.expiresAt).toBe(iso);
115
+ expect(loaded?.expiresAt).toMatch(
116
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
117
+ );
118
+ });
119
+
120
+ it("refreshTokens writes an ISO string to cache", async () => {
121
+ vi.stubGlobal(
122
+ "fetch",
123
+ vi.fn(async () =>
124
+ new Response(
125
+ JSON.stringify({
126
+ access_token: "new-access",
127
+ id_token: "new-id",
128
+ refresh_token: "new-refresh",
129
+ expires_in: 3600,
130
+ token_type: "Bearer",
131
+ }),
132
+ { status: 200, headers: { "Content-Type": "application/json" } },
133
+ ),
134
+ ),
135
+ );
136
+
137
+ const { refreshTokens, loadCachedTokens } = await importModule();
138
+ const result = await refreshTokens(
139
+ {
140
+ region: "us-east-1",
141
+ userPoolDomain: "hq-vault-dev",
142
+ clientId: "test-client",
143
+ },
144
+ "prior-refresh-token",
145
+ );
146
+
147
+ expect(typeof result.expiresAt).toBe("string");
148
+ expect(result.expiresAt).toMatch(
149
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
150
+ );
151
+
152
+ const onDisk = loadCachedTokens();
153
+ expect(onDisk?.expiresAt).toBe(result.expiresAt);
154
+ expect(typeof onDisk?.expiresAt).toBe("string");
155
+ });
156
+ });
@@ -85,9 +85,26 @@ export function clearCachedTokens(): void {
85
85
  if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
86
86
  }
87
87
 
88
+ /**
89
+ * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is ISO 8601 (what
90
+ * both writers in this file emit), but older/external writers may have left a
91
+ * raw number. Accept both so a shape mismatch during rollout doesn't wedge
92
+ * sign-in. Returns null for anything unparseable — callers should treat that
93
+ * as "expired" and force a refresh.
94
+ */
95
+ function parseExpiresAtMs(raw: unknown): number | null {
96
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
97
+ if (typeof raw === "string") {
98
+ const ms = new Date(raw).getTime();
99
+ return Number.isFinite(ms) ? ms : null;
100
+ }
101
+ return null;
102
+ }
103
+
88
104
  /** True when the token expires within the given buffer (default 60s). */
89
105
  export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
90
- const expiresAt = new Date(tokens.expiresAt).getTime();
106
+ const expiresAt = parseExpiresAtMs(tokens.expiresAt);
107
+ if (expiresAt === null) return true;
91
108
  return expiresAt - Date.now() < bufferSeconds * 1000;
92
109
  }
93
110
 
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Unit tests for context.ts — entity context resolution (VLT-5 US-001).
3
+ *
4
+ * Uses a mock fetch to simulate vault-service API responses.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from "vitest";
8
+ import {
9
+ resolveEntityContext,
10
+ refreshEntityContext,
11
+ clearContextCache,
12
+ isExpiringSoon,
13
+ } from "./context.js";
14
+ import type { VaultServiceConfig } from "./types.js";
15
+
16
+ const mockConfig: VaultServiceConfig = {
17
+ apiUrl: "https://vault-api.test",
18
+ authToken: "test-jwt-token",
19
+ region: "us-east-1",
20
+ };
21
+
22
+ const mockEntity = {
23
+ uid: "cmp_01ABCDEF",
24
+ slug: "acme",
25
+ bucketName: "hq-vault-acme-123",
26
+ status: "active",
27
+ };
28
+
29
+ const mockVendResponse = {
30
+ credentials: {
31
+ accessKeyId: "ASIA_TEST_KEY",
32
+ secretAccessKey: "test-secret",
33
+ sessionToken: "test-session-token",
34
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
35
+ },
36
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
37
+ };
38
+
39
+ function setupFetchMock(overrides?: {
40
+ entityStatus?: number;
41
+ entityBody?: unknown;
42
+ vendStatus?: number;
43
+ vendBody?: unknown;
44
+ }) {
45
+ const fetchMock = vi.fn();
46
+
47
+ fetchMock.mockImplementation(async (url: string) => {
48
+ const urlStr = String(url);
49
+
50
+ if (urlStr.includes("/entity/by-slug/")) {
51
+ return {
52
+ ok: (overrides?.entityStatus ?? 200) < 400,
53
+ status: overrides?.entityStatus ?? 200,
54
+ json: async () => overrides?.entityBody ?? { entity: mockEntity },
55
+ text: async () => JSON.stringify(overrides?.entityBody ?? { entity: mockEntity }),
56
+ };
57
+ }
58
+
59
+ if (urlStr.includes("/entity/cmp_")) {
60
+ return {
61
+ ok: (overrides?.entityStatus ?? 200) < 400,
62
+ status: overrides?.entityStatus ?? 200,
63
+ json: async () => overrides?.entityBody ?? { entity: mockEntity },
64
+ text: async () => JSON.stringify(overrides?.entityBody ?? { entity: mockEntity }),
65
+ };
66
+ }
67
+
68
+ if (urlStr.includes("/sts/vend")) {
69
+ return {
70
+ ok: (overrides?.vendStatus ?? 200) < 400,
71
+ status: overrides?.vendStatus ?? 200,
72
+ json: async () => overrides?.vendBody ?? mockVendResponse,
73
+ text: async () => JSON.stringify(overrides?.vendBody ?? mockVendResponse),
74
+ };
75
+ }
76
+
77
+ return { ok: false, status: 404, text: async () => "Not found" };
78
+ });
79
+
80
+ vi.stubGlobal("fetch", fetchMock);
81
+ return fetchMock;
82
+ }
83
+
84
+ describe("resolveEntityContext", () => {
85
+ beforeEach(() => {
86
+ clearContextCache();
87
+ vi.restoreAllMocks();
88
+ });
89
+
90
+ it("resolves context by slug", async () => {
91
+ const fetchMock = setupFetchMock();
92
+
93
+ const ctx = await resolveEntityContext("acme", mockConfig);
94
+
95
+ expect(ctx.uid).toBe("cmp_01ABCDEF");
96
+ expect(ctx.bucketName).toBe("hq-vault-acme-123");
97
+ expect(ctx.credentials.accessKeyId).toBe("ASIA_TEST_KEY");
98
+ expect(ctx.region).toBe("us-east-1");
99
+
100
+ // Verify entity lookup used by-slug endpoint
101
+ expect(fetchMock).toHaveBeenCalledTimes(2);
102
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/entity/by-slug/company/acme");
103
+ expect(String(fetchMock.mock.calls[1][0])).toContain("/sts/vend");
104
+ });
105
+
106
+ it("resolves context by UID directly", async () => {
107
+ const fetchMock = setupFetchMock();
108
+
109
+ const ctx = await resolveEntityContext("cmp_01ABCDEF", mockConfig);
110
+
111
+ expect(ctx.uid).toBe("cmp_01ABCDEF");
112
+ // Verify entity lookup used direct UID endpoint
113
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/entity/cmp_01ABCDEF");
114
+ });
115
+
116
+ it("returns cached context when credentials are fresh", async () => {
117
+ const fetchMock = setupFetchMock();
118
+
119
+ const ctx1 = await resolveEntityContext("acme", mockConfig);
120
+ const ctx2 = await resolveEntityContext("acme", mockConfig);
121
+
122
+ expect(ctx1).toBe(ctx2); // Same reference
123
+ expect(fetchMock).toHaveBeenCalledTimes(2); // Only 1 entity + 1 vend call
124
+ });
125
+
126
+ it("auto-refreshes when credentials expire soon", async () => {
127
+ const almostExpired = new Date(Date.now() + 60 * 1000).toISOString(); // 1 min left
128
+ const fetchMock = setupFetchMock({
129
+ vendBody: {
130
+ credentials: mockVendResponse.credentials,
131
+ expiresAt: almostExpired,
132
+ },
133
+ });
134
+
135
+ const ctx1 = await resolveEntityContext("acme", mockConfig);
136
+
137
+ // Second call should refresh because <2 min remaining
138
+ const ctx2 = await resolveEntityContext("acme", mockConfig);
139
+ expect(ctx2).not.toBe(ctx1);
140
+ expect(fetchMock).toHaveBeenCalledTimes(4); // 2 entity + 2 vend calls
141
+ });
142
+
143
+ it("throws when entity has no bucket", async () => {
144
+ setupFetchMock({
145
+ entityBody: { entity: { ...mockEntity, bucketName: undefined } },
146
+ });
147
+
148
+ await expect(resolveEntityContext("acme", mockConfig)).rejects.toThrow(
149
+ /no bucket provisioned/,
150
+ );
151
+ });
152
+
153
+ it("throws on entity lookup failure", async () => {
154
+ setupFetchMock({ entityStatus: 404 });
155
+
156
+ await expect(resolveEntityContext("nonexistent", mockConfig)).rejects.toThrow(
157
+ /Failed to find entity/,
158
+ );
159
+ });
160
+
161
+ it("throws on STS vend failure", async () => {
162
+ setupFetchMock({ vendStatus: 403 });
163
+
164
+ await expect(resolveEntityContext("acme", mockConfig)).rejects.toThrow(
165
+ /STS vend failed/,
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("refreshEntityContext", () => {
171
+ beforeEach(() => {
172
+ clearContextCache();
173
+ vi.restoreAllMocks();
174
+ });
175
+
176
+ it("evicts cache and fetches fresh credentials", async () => {
177
+ const fetchMock = setupFetchMock();
178
+
179
+ const ctx1 = await resolveEntityContext("acme", mockConfig);
180
+ const ctx2 = await refreshEntityContext("acme", mockConfig);
181
+
182
+ expect(ctx2).not.toBe(ctx1);
183
+ expect(fetchMock).toHaveBeenCalledTimes(4); // 2 initial + 2 refresh
184
+ });
185
+ });
186
+
187
+ describe("isExpiringSoon", () => {
188
+ it("returns false when well within TTL", () => {
189
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
190
+ expect(isExpiringSoon(future)).toBe(false);
191
+ });
192
+
193
+ it("returns true when within 2 minutes", () => {
194
+ const soon = new Date(Date.now() + 90 * 1000).toISOString();
195
+ expect(isExpiringSoon(soon)).toBe(true);
196
+ });
197
+
198
+ it("returns true when already expired", () => {
199
+ const past = new Date(Date.now() - 1000).toISOString();
200
+ expect(isExpiringSoon(past)).toBe(true);
201
+ });
202
+ });