@indigoai-us/hq-cloud 5.1.0 → 5.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +134 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +360 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +648 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +59 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +72 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +160 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +804 -0
- package/src/bin/sync-runner.ts +499 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +94 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +563 -0
- package/src/vault-client.ts +478 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- package/test/share-sync.integration.test.ts +210 -0
package/src/cli/sync.ts
ADDED
|
@@ -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
|
+
});
|
package/src/cognito-auth.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
});
|