@indigoai-us/hq-cloud 5.1.0 → 5.1.8
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 +111 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +285 -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 +492 -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 +16 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +19 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +25 -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 +617 -0
- package/src/bin/sync-runner.ts +390 -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 +93 -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 +390 -0
- package/src/vault-client.ts +400 -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/s3.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* S3 operations — upload, download, list, delete
|
|
2
|
+
* S3 operations — upload, download, list, delete.
|
|
3
|
+
*
|
|
4
|
+
* VLT-5: All operations now accept an EntityContext (entity-aware bucket +
|
|
5
|
+
* STS-scoped credentials) instead of reading static env config. The caller
|
|
6
|
+
* is responsible for resolving the context via resolveEntityContext().
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import * as fs from "fs";
|
|
@@ -10,79 +14,56 @@ import {
|
|
|
10
14
|
GetObjectCommand,
|
|
11
15
|
ListObjectsV2Command,
|
|
12
16
|
DeleteObjectCommand,
|
|
17
|
+
HeadObjectCommand,
|
|
13
18
|
} from "@aws-sdk/client-s3";
|
|
14
|
-
import type {
|
|
15
|
-
import { readCredentials, refreshAwsCredentials } from "./auth.js";
|
|
16
|
-
|
|
17
|
-
let s3Client: S3Client | null = null;
|
|
18
|
-
|
|
19
|
-
function getConfig(creds: Credentials): SyncConfig {
|
|
20
|
-
const prefix = creds.teamId
|
|
21
|
-
? `teams/${creds.teamId}/users/${creds.userId}/hq/`
|
|
22
|
-
: `users/${creds.userId}/hq/`;
|
|
23
|
-
return {
|
|
24
|
-
bucket: creds.bucket,
|
|
25
|
-
region: creds.region,
|
|
26
|
-
userId: creds.userId,
|
|
27
|
-
prefix,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function getClient(): Promise<{ client: S3Client; config: SyncConfig }> {
|
|
32
|
-
let creds = readCredentials();
|
|
33
|
-
if (!creds) {
|
|
34
|
-
throw new Error("Not authenticated. Run 'hq sync init' first.");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Refresh if expired or missing access key
|
|
38
|
-
if (!creds.accessKeyId || (creds.expiration && new Date(creds.expiration) < new Date())) {
|
|
39
|
-
creds = await refreshAwsCredentials(creds);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!s3Client) {
|
|
43
|
-
s3Client = new S3Client({
|
|
44
|
-
region: creds.region,
|
|
45
|
-
credentials: {
|
|
46
|
-
accessKeyId: creds.accessKeyId,
|
|
47
|
-
secretAccessKey: creds.secretAccessKey,
|
|
48
|
-
sessionToken: creds.sessionToken,
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
}
|
|
19
|
+
import type { EntityContext } from "./types.js";
|
|
52
20
|
|
|
53
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Build an S3Client from an EntityContext's STS-scoped credentials.
|
|
23
|
+
* A new client is created each time to ensure fresh credentials are used
|
|
24
|
+
* (the caller handles caching/refresh at the EntityContext level).
|
|
25
|
+
*/
|
|
26
|
+
function buildClient(ctx: EntityContext): S3Client {
|
|
27
|
+
return new S3Client({
|
|
28
|
+
region: ctx.region,
|
|
29
|
+
credentials: {
|
|
30
|
+
accessKeyId: ctx.credentials.accessKeyId,
|
|
31
|
+
secretAccessKey: ctx.credentials.secretAccessKey,
|
|
32
|
+
sessionToken: ctx.credentials.sessionToken,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
54
35
|
}
|
|
55
36
|
|
|
56
37
|
export async function uploadFile(
|
|
38
|
+
ctx: EntityContext,
|
|
57
39
|
localPath: string,
|
|
58
|
-
|
|
40
|
+
key: string,
|
|
59
41
|
): Promise<void> {
|
|
60
|
-
const
|
|
61
|
-
const key = `${config.prefix}${relativePath}`;
|
|
42
|
+
const client = buildClient(ctx);
|
|
62
43
|
const body = fs.readFileSync(localPath);
|
|
63
44
|
|
|
64
45
|
await client.send(
|
|
65
46
|
new PutObjectCommand({
|
|
66
|
-
Bucket:
|
|
47
|
+
Bucket: ctx.bucketName,
|
|
67
48
|
Key: key,
|
|
68
49
|
Body: body,
|
|
69
|
-
ContentType: getMimeType(
|
|
70
|
-
})
|
|
50
|
+
ContentType: getMimeType(key),
|
|
51
|
+
}),
|
|
71
52
|
);
|
|
72
53
|
}
|
|
73
54
|
|
|
74
55
|
export async function downloadFile(
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
ctx: EntityContext,
|
|
57
|
+
key: string,
|
|
58
|
+
localPath: string,
|
|
77
59
|
): Promise<void> {
|
|
78
|
-
const
|
|
79
|
-
const key = `${config.prefix}${relativePath}`;
|
|
60
|
+
const client = buildClient(ctx);
|
|
80
61
|
|
|
81
62
|
const response = await client.send(
|
|
82
63
|
new GetObjectCommand({
|
|
83
|
-
Bucket:
|
|
64
|
+
Bucket: ctx.bucketName,
|
|
84
65
|
Key: key,
|
|
85
|
-
})
|
|
66
|
+
}),
|
|
86
67
|
);
|
|
87
68
|
|
|
88
69
|
if (!response.Body) {
|
|
@@ -104,34 +85,33 @@ export async function downloadFile(
|
|
|
104
85
|
|
|
105
86
|
export interface RemoteFile {
|
|
106
87
|
key: string;
|
|
107
|
-
relativePath: string;
|
|
108
88
|
size: number;
|
|
109
89
|
lastModified: Date;
|
|
110
90
|
etag: string;
|
|
111
91
|
}
|
|
112
92
|
|
|
113
|
-
export async function listRemoteFiles(
|
|
114
|
-
|
|
93
|
+
export async function listRemoteFiles(
|
|
94
|
+
ctx: EntityContext,
|
|
95
|
+
prefix?: string,
|
|
96
|
+
): Promise<RemoteFile[]> {
|
|
97
|
+
const client = buildClient(ctx);
|
|
115
98
|
const files: RemoteFile[] = [];
|
|
116
99
|
let continuationToken: string | undefined;
|
|
117
100
|
|
|
118
101
|
do {
|
|
119
102
|
const response = await client.send(
|
|
120
103
|
new ListObjectsV2Command({
|
|
121
|
-
Bucket:
|
|
122
|
-
Prefix:
|
|
104
|
+
Bucket: ctx.bucketName,
|
|
105
|
+
Prefix: prefix,
|
|
123
106
|
ContinuationToken: continuationToken,
|
|
124
|
-
})
|
|
107
|
+
}),
|
|
125
108
|
);
|
|
126
109
|
|
|
127
110
|
for (const obj of response.Contents || []) {
|
|
128
111
|
if (!obj.Key || !obj.Size) continue;
|
|
129
|
-
const relativePath = obj.Key.replace(config.prefix, "");
|
|
130
|
-
if (!relativePath) continue;
|
|
131
112
|
|
|
132
113
|
files.push({
|
|
133
114
|
key: obj.Key,
|
|
134
|
-
relativePath,
|
|
135
115
|
size: obj.Size,
|
|
136
116
|
lastModified: obj.LastModified || new Date(),
|
|
137
117
|
etag: obj.ETag || "",
|
|
@@ -144,18 +124,48 @@ export async function listRemoteFiles(): Promise<RemoteFile[]> {
|
|
|
144
124
|
return files;
|
|
145
125
|
}
|
|
146
126
|
|
|
147
|
-
export async function deleteRemoteFile(
|
|
148
|
-
|
|
149
|
-
|
|
127
|
+
export async function deleteRemoteFile(
|
|
128
|
+
ctx: EntityContext,
|
|
129
|
+
key: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const client = buildClient(ctx);
|
|
150
132
|
|
|
151
133
|
await client.send(
|
|
152
134
|
new DeleteObjectCommand({
|
|
153
|
-
Bucket:
|
|
135
|
+
Bucket: ctx.bucketName,
|
|
154
136
|
Key: key,
|
|
155
|
-
})
|
|
137
|
+
}),
|
|
156
138
|
);
|
|
157
139
|
}
|
|
158
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Check if a remote key exists and return its metadata.
|
|
143
|
+
*/
|
|
144
|
+
export async function headRemoteFile(
|
|
145
|
+
ctx: EntityContext,
|
|
146
|
+
key: string,
|
|
147
|
+
): Promise<{ lastModified: Date; etag: string; size: number } | null> {
|
|
148
|
+
const client = buildClient(ctx);
|
|
149
|
+
try {
|
|
150
|
+
const response = await client.send(
|
|
151
|
+
new HeadObjectCommand({
|
|
152
|
+
Bucket: ctx.bucketName,
|
|
153
|
+
Key: key,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
return {
|
|
157
|
+
lastModified: response.LastModified || new Date(),
|
|
158
|
+
etag: response.ETag || "",
|
|
159
|
+
size: response.ContentLength || 0,
|
|
160
|
+
};
|
|
161
|
+
} catch (err: unknown) {
|
|
162
|
+
if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
function getMimeType(filePath: string): string {
|
|
160
170
|
const ext = path.extname(filePath).toLowerCase();
|
|
161
171
|
const mimeTypes: Record<string, string> = {
|
package/src/types.ts
CHANGED
|
@@ -57,3 +57,40 @@ export interface DaemonState {
|
|
|
57
57
|
startedAt: string;
|
|
58
58
|
hqRoot: string;
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Entity-aware context for vault-backed S3 operations (VLT-5).
|
|
63
|
+
* Resolved from vault-service entity registry + STS vending.
|
|
64
|
+
*/
|
|
65
|
+
export interface EntityContext {
|
|
66
|
+
/** Entity UID (cmp_*) */
|
|
67
|
+
uid: string;
|
|
68
|
+
/** Entity slug (human-readable, stable key for per-company local state). */
|
|
69
|
+
slug: string;
|
|
70
|
+
/** S3 bucket name for this entity */
|
|
71
|
+
bucketName: string;
|
|
72
|
+
/** AWS region */
|
|
73
|
+
region: string;
|
|
74
|
+
/** STS-scoped credentials */
|
|
75
|
+
credentials: VaultCredentials;
|
|
76
|
+
/** When the credentials expire (ISO 8601) */
|
|
77
|
+
expiresAt: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface VaultCredentials {
|
|
81
|
+
accessKeyId: string;
|
|
82
|
+
secretAccessKey: string;
|
|
83
|
+
sessionToken: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Configuration for connecting to the vault-service API.
|
|
88
|
+
*/
|
|
89
|
+
export interface VaultServiceConfig {
|
|
90
|
+
/** Vault API base URL (e.g. https://vault-api.example.com) */
|
|
91
|
+
apiUrl: string;
|
|
92
|
+
/** Cognito JWT token for authentication */
|
|
93
|
+
authToken: string;
|
|
94
|
+
/** AWS region for S3 client (defaults to entity region or us-east-1) */
|
|
95
|
+
region?: string;
|
|
96
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VaultClient unit tests (VLT-7 US-001).
|
|
3
|
+
*
|
|
4
|
+
* Uses mocked fetch to assert retry behavior, error mapping, and auth header injection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
VaultClient,
|
|
10
|
+
VaultAuthError,
|
|
11
|
+
VaultPermissionDeniedError,
|
|
12
|
+
VaultNotFoundError,
|
|
13
|
+
VaultConflictError,
|
|
14
|
+
VaultClientError,
|
|
15
|
+
} from "./vault-client.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
22
|
+
return new Response(JSON.stringify(body), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function textResponse(status: number, body: string): Response {
|
|
29
|
+
return new Response(body, { status });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TEST_CONFIG = {
|
|
33
|
+
apiUrl: "https://vault.test.example.com",
|
|
34
|
+
authToken: "test-jwt-token-123",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let client: VaultClient;
|
|
38
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
client = new VaultClient(TEST_CONFIG);
|
|
42
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Auth header injection
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe("auth header injection", () => {
|
|
54
|
+
it("sends Bearer token on every request", async () => {
|
|
55
|
+
fetchSpy.mockResolvedValueOnce(
|
|
56
|
+
jsonResponse(200, { members: [] }),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await client.listMembersOfCompany("cmp_abc");
|
|
60
|
+
|
|
61
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
62
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
63
|
+
const headers = init.headers as Record<string, string>;
|
|
64
|
+
expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sets Content-Type on POST requests", async () => {
|
|
68
|
+
fetchSpy.mockResolvedValueOnce(
|
|
69
|
+
jsonResponse(200, { membership: {}, inviteToken: "tok" }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await client.createInvite({
|
|
73
|
+
companyUid: "cmp_abc",
|
|
74
|
+
role: "member",
|
|
75
|
+
invitedBy: "psn_xyz",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
79
|
+
const headers = init.headers as Record<string, string>;
|
|
80
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Error mapping
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe("error mapping", () => {
|
|
89
|
+
it("maps 401 to VaultAuthError", async () => {
|
|
90
|
+
fetchSpy.mockResolvedValueOnce(
|
|
91
|
+
jsonResponse(401, { message: "Token expired" }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("maps 403 to VaultPermissionDeniedError", async () => {
|
|
98
|
+
fetchSpy.mockResolvedValueOnce(
|
|
99
|
+
jsonResponse(403, { message: "Admin required" }),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultPermissionDeniedError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("maps 404 to VaultNotFoundError", async () => {
|
|
106
|
+
fetchSpy.mockResolvedValueOnce(
|
|
107
|
+
jsonResponse(404, { message: "Not found" }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await expect(client.entity.get("cmp_missing")).rejects.toThrow(VaultNotFoundError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("maps 409 to VaultConflictError", async () => {
|
|
114
|
+
fetchSpy.mockResolvedValueOnce(
|
|
115
|
+
jsonResponse(409, { message: "Already accepted" }),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
await expect(client.acceptInvite("tok", "psn_abc")).rejects.toThrow(VaultConflictError);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("preserves error message from response body", async () => {
|
|
122
|
+
fetchSpy.mockResolvedValueOnce(
|
|
123
|
+
jsonResponse(403, { message: "Only admins can invite" }),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await client.createInvite({
|
|
128
|
+
companyUid: "cmp_abc",
|
|
129
|
+
role: "member",
|
|
130
|
+
invitedBy: "psn_xyz",
|
|
131
|
+
});
|
|
132
|
+
expect.fail("Should have thrown");
|
|
133
|
+
} catch (err) {
|
|
134
|
+
expect(err).toBeInstanceOf(VaultPermissionDeniedError);
|
|
135
|
+
expect((err as VaultPermissionDeniedError).message).toBe("Only admins can invite");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles non-JSON error bodies gracefully", async () => {
|
|
140
|
+
fetchSpy.mockResolvedValueOnce(
|
|
141
|
+
textResponse(404, "Not Found"),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await client.entity.findBySlug("company", "test");
|
|
146
|
+
expect.fail("Should have thrown");
|
|
147
|
+
} catch (err) {
|
|
148
|
+
expect(err).toBeInstanceOf(VaultNotFoundError);
|
|
149
|
+
expect((err as VaultNotFoundError).message).toBe("Not Found");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Retry behavior
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("retry behavior", () => {
|
|
159
|
+
it("retries on 429 and succeeds on second attempt", async () => {
|
|
160
|
+
fetchSpy
|
|
161
|
+
.mockResolvedValueOnce(jsonResponse(429, { message: "Rate limited" }))
|
|
162
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
163
|
+
|
|
164
|
+
const result = await client.listMembersOfCompany("cmp_abc");
|
|
165
|
+
expect(result).toHaveLength(1);
|
|
166
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("retries on 500 and succeeds on third attempt", async () => {
|
|
170
|
+
fetchSpy
|
|
171
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "Internal error" }))
|
|
172
|
+
.mockResolvedValueOnce(jsonResponse(502, { message: "Bad gateway" }))
|
|
173
|
+
.mockResolvedValueOnce(jsonResponse(200, { membership: { role: "admin" } }));
|
|
174
|
+
|
|
175
|
+
const result = await client.updateRole({
|
|
176
|
+
membershipKey: "psn_1#cmp_abc",
|
|
177
|
+
newRole: "admin",
|
|
178
|
+
updaterUid: "psn_owner",
|
|
179
|
+
companyUid: "cmp_abc",
|
|
180
|
+
});
|
|
181
|
+
expect(result.role).toBe("admin");
|
|
182
|
+
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws after exhausting all retries on persistent 500", async () => {
|
|
186
|
+
fetchSpy.mockImplementation(() =>
|
|
187
|
+
Promise.resolve(jsonResponse(500, { message: "Down" })),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultClientError);
|
|
191
|
+
// 1 initial + 3 retries = 4
|
|
192
|
+
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not retry on 401 (non-transient)", async () => {
|
|
196
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(401, { message: "Expired" }));
|
|
197
|
+
|
|
198
|
+
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
199
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not retry on 403 (non-transient)", async () => {
|
|
203
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
|
|
204
|
+
|
|
205
|
+
await expect(client.createInvite({
|
|
206
|
+
companyUid: "cmp_abc",
|
|
207
|
+
role: "member",
|
|
208
|
+
invitedBy: "psn_xyz",
|
|
209
|
+
})).rejects.toThrow(VaultPermissionDeniedError);
|
|
210
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("retries on network errors (fetch throws)", async () => {
|
|
214
|
+
fetchSpy
|
|
215
|
+
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
|
216
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
217
|
+
|
|
218
|
+
const result = await client.listMembersOfCompany("cmp_abc");
|
|
219
|
+
expect(result).toEqual([]);
|
|
220
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// API surface
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe("API surface", () => {
|
|
229
|
+
it("createInvite sends correct body and URL", async () => {
|
|
230
|
+
fetchSpy.mockResolvedValueOnce(
|
|
231
|
+
jsonResponse(200, {
|
|
232
|
+
membership: { membershipKey: "psn_1#cmp_abc", role: "member", status: "pending" },
|
|
233
|
+
inviteToken: "tok_secure_random",
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const result = await client.createInvite({
|
|
238
|
+
companyUid: "cmp_abc",
|
|
239
|
+
role: "member",
|
|
240
|
+
invitedBy: "psn_owner",
|
|
241
|
+
inviteeEmail: "alice@example.com",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.inviteToken).toBe("tok_secure_random");
|
|
245
|
+
|
|
246
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
247
|
+
expect(url).toBe("https://vault.test.example.com/membership/invite");
|
|
248
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
249
|
+
companyUid: "cmp_abc",
|
|
250
|
+
role: "member",
|
|
251
|
+
invitedBy: "psn_owner",
|
|
252
|
+
inviteeEmail: "alice@example.com",
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("acceptInvite sends token and personUid", async () => {
|
|
257
|
+
fetchSpy.mockResolvedValueOnce(
|
|
258
|
+
jsonResponse(200, {
|
|
259
|
+
membership: { status: "active", role: "member" },
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const result = await client.acceptInvite("tok_abc", "psn_invitee");
|
|
264
|
+
expect(result.membership.status).toBe("active");
|
|
265
|
+
|
|
266
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
267
|
+
expect(url).toBe("https://vault.test.example.com/membership/accept");
|
|
268
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
269
|
+
token: "tok_abc",
|
|
270
|
+
personUid: "psn_invitee",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("updateRole sends correct payload", async () => {
|
|
275
|
+
fetchSpy.mockResolvedValueOnce(
|
|
276
|
+
jsonResponse(200, {
|
|
277
|
+
membership: { role: "guest", allowedPrefixes: ["docs/"] },
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const result = await client.updateRole({
|
|
282
|
+
membershipKey: "psn_1#cmp_abc",
|
|
283
|
+
newRole: "guest",
|
|
284
|
+
allowedPrefixes: ["docs/"],
|
|
285
|
+
updaterUid: "psn_admin",
|
|
286
|
+
companyUid: "cmp_abc",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.role).toBe("guest");
|
|
290
|
+
expect(result.allowedPrefixes).toEqual(["docs/"]);
|
|
291
|
+
|
|
292
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
293
|
+
expect(url).toBe("https://vault.test.example.com/membership/role");
|
|
294
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
295
|
+
membershipKey: "psn_1#cmp_abc",
|
|
296
|
+
newRole: "guest",
|
|
297
|
+
allowedPrefixes: ["docs/"],
|
|
298
|
+
updaterUid: "psn_admin",
|
|
299
|
+
companyUid: "cmp_abc",
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("entity.get calls correct URL", async () => {
|
|
304
|
+
fetchSpy.mockResolvedValueOnce(
|
|
305
|
+
jsonResponse(200, {
|
|
306
|
+
entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const entity = await client.entity.get("cmp_abc");
|
|
311
|
+
expect(entity.slug).toBe("acme");
|
|
312
|
+
|
|
313
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
314
|
+
expect(url).toBe("https://vault.test.example.com/entity/cmp_abc");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("entity.findBySlug calls correct URL", async () => {
|
|
318
|
+
fetchSpy.mockResolvedValueOnce(
|
|
319
|
+
jsonResponse(200, {
|
|
320
|
+
entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const entity = await client.entity.findBySlug("company", "acme");
|
|
325
|
+
expect(entity.uid).toBe("cmp_abc");
|
|
326
|
+
|
|
327
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
328
|
+
expect(url).toBe("https://vault.test.example.com/entity/by-slug/company/acme");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("revokeMembership calls POST /membership/revoke with companyUid", async () => {
|
|
332
|
+
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
333
|
+
|
|
334
|
+
await client.revokeMembership("psn_1#cmp_abc", "cmp_abc");
|
|
335
|
+
|
|
336
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
337
|
+
expect(url).toBe("https://vault.test.example.com/membership/revoke");
|
|
338
|
+
expect(init.method).toBe("POST");
|
|
339
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
340
|
+
membershipKey: "psn_1#cmp_abc",
|
|
341
|
+
companyUid: "cmp_abc",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("listPendingInvites calls correct URL", async () => {
|
|
346
|
+
fetchSpy.mockResolvedValueOnce(
|
|
347
|
+
jsonResponse(200, { invites: [{ status: "pending" }] }),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const invites = await client.listPendingInvites("cmp_abc");
|
|
351
|
+
expect(invites).toHaveLength(1);
|
|
352
|
+
|
|
353
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
354
|
+
expect(url).toBe("https://vault.test.example.com/membership/company/cmp_abc/pending");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("listMyMemberships hits GET /membership/me and unwraps memberships[]", async () => {
|
|
358
|
+
fetchSpy.mockResolvedValueOnce(
|
|
359
|
+
jsonResponse(200, {
|
|
360
|
+
memberships: [
|
|
361
|
+
{ membershipKey: "psn_1#cmp_a", role: "owner", status: "active" },
|
|
362
|
+
{ membershipKey: "psn_1#cmp_b", role: "member", status: "active" },
|
|
363
|
+
],
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const memberships = await client.listMyMemberships();
|
|
368
|
+
expect(memberships).toHaveLength(2);
|
|
369
|
+
expect(memberships[0].membershipKey).toBe("psn_1#cmp_a");
|
|
370
|
+
|
|
371
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
372
|
+
expect(url).toBe("https://vault.test.example.com/membership/me");
|
|
373
|
+
expect(init.method).toBe("GET");
|
|
374
|
+
// No body on GET.
|
|
375
|
+
expect(init.body).toBeUndefined();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("listMyMemberships returns [] for callers with no person entity (bootstrap case)", async () => {
|
|
379
|
+
// Server returns 200 + { memberships: [] } rather than 404 when the
|
|
380
|
+
// caller is signed in but hasn't been provisioned yet. The SDK must
|
|
381
|
+
// surface an empty array, NOT throw — hq-sync-runner relies on this
|
|
382
|
+
// to emit `setup-needed` without catching HTTP errors.
|
|
383
|
+
fetchSpy.mockResolvedValueOnce(
|
|
384
|
+
jsonResponse(200, { memberships: [] }),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const memberships = await client.listMyMemberships();
|
|
388
|
+
expect(memberships).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
});
|