@indigoai-us/hq-cloud 5.18.1 → 5.19.1
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/cli/invite.js +4 -1
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/invite.test.js +3 -0
- package/dist/cli/invite.test.js.map +1 -1
- package/dist/cli/promote.js +3 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/share.test.js +12 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.test.js +12 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/client-info.d.ts +44 -0
- package/dist/client-info.d.ts.map +1 -0
- package/dist/client-info.js +112 -0
- package/dist/client-info.js.map +1 -0
- package/dist/client-info.test.d.ts +11 -0
- package/dist/client-info.test.d.ts.map +1 -0
- package/dist/client-info.test.js +168 -0
- package/dist/client-info.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +117 -20
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +63 -14
- package/dist/context.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +25 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +33 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/invite.test.ts +3 -0
- package/src/cli/invite.ts +4 -1
- package/src/cli/promote.ts +3 -0
- package/src/cli/share.test.ts +12 -1
- package/src/cli/sync.test.ts +12 -0
- package/src/client-info.test.ts +214 -0
- package/src/client-info.ts +121 -0
- package/src/context.test.ts +73 -14
- package/src/context.ts +126 -22
- package/src/index.ts +12 -0
- package/src/types.ts +23 -0
- package/src/vault-client.ts +42 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the client-info helpers and header injection.
|
|
3
|
+
*
|
|
4
|
+
* Two layers are exercised here:
|
|
5
|
+
* 1. The pure functions in `client-info.ts` — buildClientHeaders,
|
|
6
|
+
* clientInfoFromPackage, detectHqCoreVersion.
|
|
7
|
+
* 2. End-to-end injection into VaultClient.request, since "the headers
|
|
8
|
+
* actually land on outbound fetch" is the property consumers care about.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
buildClientHeaders,
|
|
17
|
+
clientInfoFromPackage,
|
|
18
|
+
detectHqCoreVersion,
|
|
19
|
+
HEADER_CLIENT_NAME,
|
|
20
|
+
HEADER_CLIENT_VERSION,
|
|
21
|
+
HEADER_HQ_CORE_VERSION,
|
|
22
|
+
} from "./client-info.js";
|
|
23
|
+
import { VaultClient } from "./vault-client.js";
|
|
24
|
+
|
|
25
|
+
describe("buildClientHeaders", () => {
|
|
26
|
+
it("returns empty object when info is undefined", () => {
|
|
27
|
+
expect(buildClientHeaders(undefined)).toEqual({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("emits User-Agent + x-hq-client-{name,version} from name/version", () => {
|
|
31
|
+
const headers = buildClientHeaders({
|
|
32
|
+
name: "@indigoai-us/hq-cli",
|
|
33
|
+
version: "5.15.0",
|
|
34
|
+
});
|
|
35
|
+
expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
|
|
36
|
+
expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
|
|
37
|
+
expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
|
|
38
|
+
expect(headers[HEADER_HQ_CORE_VERSION]).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("includes x-hq-core-version only when hqCoreVersion is set", () => {
|
|
42
|
+
const headers = buildClientHeaders({
|
|
43
|
+
name: "@indigoai-us/hq-cli",
|
|
44
|
+
version: "5.15.0",
|
|
45
|
+
hqCoreVersion: "14.1.0",
|
|
46
|
+
});
|
|
47
|
+
expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits arbitrary extra fields as x-hq-client-<key> headers", () => {
|
|
51
|
+
const headers = buildClientHeaders({
|
|
52
|
+
name: "x",
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
extra: { machine: "ec2-bot-7", channel: "stable" },
|
|
55
|
+
});
|
|
56
|
+
expect(headers["x-hq-client-machine"]).toBe("ec2-bot-7");
|
|
57
|
+
expect(headers["x-hq-client-channel"]).toBe("stable");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("clientInfoFromPackage", () => {
|
|
62
|
+
it("extracts name and version from a parsed package.json", () => {
|
|
63
|
+
expect(clientInfoFromPackage({ name: "foo", version: "1.2.3" })).toEqual({
|
|
64
|
+
name: "foo",
|
|
65
|
+
version: "1.2.3",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("throws when name is missing", () => {
|
|
70
|
+
expect(() => clientInfoFromPackage({ version: "1.0.0" })).toThrow(/name/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws when version is missing", () => {
|
|
74
|
+
expect(() => clientInfoFromPackage({ name: "foo" })).toThrow(/version/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws on non-string fields", () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
clientInfoFromPackage({ name: 42 as unknown as string, version: "1.0.0" }),
|
|
80
|
+
).toThrow();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("detectHqCoreVersion", () => {
|
|
85
|
+
let tmpRoot: string;
|
|
86
|
+
const origHqHome = process.env.HQ_HOME;
|
|
87
|
+
const origHqRoot = process.env.HQ_ROOT;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "hq-core-detect-"));
|
|
91
|
+
delete process.env.HQ_HOME;
|
|
92
|
+
delete process.env.HQ_ROOT;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
97
|
+
if (origHqHome !== undefined) process.env.HQ_HOME = origHqHome;
|
|
98
|
+
if (origHqRoot !== undefined) process.env.HQ_ROOT = origHqRoot;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function seedHqCore(root: string, version: string): void {
|
|
102
|
+
mkdirSync(join(root, "core"), { recursive: true });
|
|
103
|
+
mkdirSync(join(root, "companies"), { recursive: true });
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(root, "core", "core.yaml"),
|
|
106
|
+
`version: 1\nhqVersion: "${version}"\nupdatedAt: "2026-05-13T00:00:00Z"\n`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it("returns undefined when nothing on the walk-up has core.yaml", () => {
|
|
111
|
+
expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns hqVersion when startDir is the hq-core root", () => {
|
|
115
|
+
seedHqCore(tmpRoot, "14.1.0");
|
|
116
|
+
expect(detectHqCoreVersion(tmpRoot)).toBe("14.1.0");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("walks upward to find core.yaml from a nested cwd", () => {
|
|
120
|
+
seedHqCore(tmpRoot, "14.2.0");
|
|
121
|
+
const nested = join(tmpRoot, "companies", "acme", "projects", "p1");
|
|
122
|
+
mkdirSync(nested, { recursive: true });
|
|
123
|
+
expect(detectHqCoreVersion(nested)).toBe("14.2.0");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("ignores directories that have core.yaml but no companies/ — disambiguates fixtures", () => {
|
|
127
|
+
mkdirSync(join(tmpRoot, "core"), { recursive: true });
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(tmpRoot, "core", "core.yaml"),
|
|
130
|
+
`version: 1\nhqVersion: "99.0.0"\n`,
|
|
131
|
+
);
|
|
132
|
+
// No companies/ at this level → not an hq-core root.
|
|
133
|
+
expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("honors HQ_HOME env override before walking cwd", () => {
|
|
137
|
+
seedHqCore(tmpRoot, "14.3.0");
|
|
138
|
+
process.env.HQ_HOME = tmpRoot;
|
|
139
|
+
// startDir intentionally points elsewhere — env should win.
|
|
140
|
+
const elsewhere = mkdtempSync(join(tmpdir(), "elsewhere-"));
|
|
141
|
+
try {
|
|
142
|
+
expect(detectHqCoreVersion(elsewhere)).toBe("14.3.0");
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(elsewhere, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("parses unquoted hqVersion values too", () => {
|
|
149
|
+
mkdirSync(join(tmpRoot, "core"), { recursive: true });
|
|
150
|
+
mkdirSync(join(tmpRoot, "companies"), { recursive: true });
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(tmpRoot, "core", "core.yaml"),
|
|
153
|
+
`version: 1\nhqVersion: 14.4.0\n`,
|
|
154
|
+
);
|
|
155
|
+
expect(detectHqCoreVersion(tmpRoot)).toBe("14.4.0");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("VaultClient stamps client headers on outbound requests", () => {
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
vi.restoreAllMocks();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("includes x-hq-client-name + x-hq-client-version when clientInfo is set", async () => {
|
|
165
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
166
|
+
new Response(JSON.stringify({ memberships: [] }), {
|
|
167
|
+
status: 200,
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const client = new VaultClient({
|
|
173
|
+
apiUrl: "https://vault.test.example.com",
|
|
174
|
+
authToken: "tok",
|
|
175
|
+
clientInfo: {
|
|
176
|
+
name: "@indigoai-us/hq-cli",
|
|
177
|
+
version: "5.15.0",
|
|
178
|
+
hqCoreVersion: "14.1.0",
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await client.listMyMemberships();
|
|
183
|
+
|
|
184
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
185
|
+
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
|
186
|
+
const headers = init.headers as Record<string, string>;
|
|
187
|
+
expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
|
|
188
|
+
expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
|
|
189
|
+
expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
|
|
190
|
+
expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("omits client headers when clientInfo is not set — back-compat", async () => {
|
|
194
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
195
|
+
new Response(JSON.stringify({ memberships: [] }), {
|
|
196
|
+
status: 200,
|
|
197
|
+
headers: { "Content-Type": "application/json" },
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const client = new VaultClient({
|
|
202
|
+
apiUrl: "https://vault.test.example.com",
|
|
203
|
+
authToken: "tok",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await client.listMyMemberships();
|
|
207
|
+
|
|
208
|
+
const headers = (fetchSpy.mock.calls[0]?.[1] as RequestInit)
|
|
209
|
+
.headers as Record<string, string>;
|
|
210
|
+
expect(headers[HEADER_CLIENT_NAME]).toBeUndefined();
|
|
211
|
+
expect(headers[HEADER_CLIENT_VERSION]).toBeUndefined();
|
|
212
|
+
expect(headers["User-Agent"]).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client identification — every HQ client that talks to hq-cloud-api should
|
|
3
|
+
* stamp its name and version on outbound requests so the server can attribute
|
|
4
|
+
* traffic, gate on minimum versions, and surface deprecation warnings.
|
|
5
|
+
*
|
|
6
|
+
* The CLI in particular sends a third header (`x-hq-core-version`) when it's
|
|
7
|
+
* invoked from inside an hq-core checkout, so the server sees which scaffold
|
|
8
|
+
* generation the user is running against.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import type { ClientInfo } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export const HEADER_CLIENT_NAME = "x-hq-client-name";
|
|
16
|
+
export const HEADER_CLIENT_VERSION = "x-hq-client-version";
|
|
17
|
+
export const HEADER_HQ_CORE_VERSION = "x-hq-core-version";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the set of `x-hq-*` headers (plus a derived `User-Agent`) for a
|
|
21
|
+
* ClientInfo. Returns an empty object when info is undefined so callers can
|
|
22
|
+
* spread the result unconditionally.
|
|
23
|
+
*/
|
|
24
|
+
export function buildClientHeaders(
|
|
25
|
+
info: ClientInfo | undefined,
|
|
26
|
+
): Record<string, string> {
|
|
27
|
+
if (!info) return {};
|
|
28
|
+
|
|
29
|
+
const headers: Record<string, string> = {
|
|
30
|
+
"User-Agent": `${info.name}/${info.version}`,
|
|
31
|
+
[HEADER_CLIENT_NAME]: info.name,
|
|
32
|
+
[HEADER_CLIENT_VERSION]: info.version,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (info.hqCoreVersion) {
|
|
36
|
+
headers[HEADER_HQ_CORE_VERSION] = info.hqCoreVersion;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (info.extra) {
|
|
40
|
+
for (const [k, v] of Object.entries(info.extra)) {
|
|
41
|
+
headers[`x-hq-client-${k}`] = v;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return headers;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a ClientInfo from a parsed package.json. Most consumers will call this
|
|
50
|
+
* once at startup and pass the result into every VaultServiceConfig.
|
|
51
|
+
*/
|
|
52
|
+
export function clientInfoFromPackage(pkg: {
|
|
53
|
+
name?: unknown;
|
|
54
|
+
version?: unknown;
|
|
55
|
+
}): ClientInfo {
|
|
56
|
+
if (typeof pkg.name !== "string" || pkg.name.length === 0) {
|
|
57
|
+
throw new Error("clientInfoFromPackage: package.json is missing a name");
|
|
58
|
+
}
|
|
59
|
+
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
60
|
+
throw new Error("clientInfoFromPackage: package.json is missing a version");
|
|
61
|
+
}
|
|
62
|
+
return { name: pkg.name, version: pkg.version };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk upward from `startDir` looking for `core/core.yaml`. When found, parse
|
|
67
|
+
* out the `hqVersion` field and return it. Returns undefined if we never find
|
|
68
|
+
* an hq-core checkout — i.e. the caller is running from a plain user dir.
|
|
69
|
+
*
|
|
70
|
+
* Honors `HQ_HOME` env var as an explicit override so multi-checkout setups
|
|
71
|
+
* (e.g. CI bots, the menubar app pointing at a non-cwd HQ root) can pin the
|
|
72
|
+
* detection without relying on cwd.
|
|
73
|
+
*
|
|
74
|
+
* Why a regex instead of a YAML parser: `core.yaml` lives at the very top of
|
|
75
|
+
* the file and the field has a stable shape — adding a YAML dep just to read
|
|
76
|
+
* one string would balloon every consumer's bundle. The current shape is
|
|
77
|
+
* `hqVersion: "X.Y.Z"` (quoted) per the canonical seed; we tolerate unquoted
|
|
78
|
+
* too.
|
|
79
|
+
*/
|
|
80
|
+
export function detectHqCoreVersion(startDir?: string): string | undefined {
|
|
81
|
+
const fromEnv = process.env.HQ_HOME ?? process.env.HQ_ROOT;
|
|
82
|
+
if (fromEnv) {
|
|
83
|
+
const v = readCoreVersionAt(fromEnv);
|
|
84
|
+
if (v) return v;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
88
|
+
while (true) {
|
|
89
|
+
const v = readCoreVersionAt(dir);
|
|
90
|
+
if (v) return v;
|
|
91
|
+
const parent = dirname(dir);
|
|
92
|
+
if (parent === dir) return undefined;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readCoreVersionAt(hqRoot: string): string | undefined {
|
|
98
|
+
// hq-core identity requires BOTH core/core.yaml AND companies/ — matches the
|
|
99
|
+
// CLI's own detection (commit bc827d0). Without this guard, any directory
|
|
100
|
+
// containing a stray `core/core.yaml` (e.g. a test fixture) would be
|
|
101
|
+
// misidentified as an hq-core root.
|
|
102
|
+
const yamlPath = join(hqRoot, "core", "core.yaml");
|
|
103
|
+
const companiesPath = join(hqRoot, "companies");
|
|
104
|
+
let content: string;
|
|
105
|
+
try {
|
|
106
|
+
content = readFileSync(yamlPath, "utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
// statSync would be marginally cleaner, but readdirSync of a nonexistent
|
|
112
|
+
// path throws synchronously which is what we want.
|
|
113
|
+
readFileSync(companiesPath, { flag: "r" });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const e = err as NodeJS.ErrnoException;
|
|
116
|
+
// EISDIR is the success case — companies/ exists and is a directory.
|
|
117
|
+
if (e.code !== "EISDIR") return undefined;
|
|
118
|
+
}
|
|
119
|
+
const match = /^hqVersion:\s*["']?([^"'\s]+)/m.exec(content);
|
|
120
|
+
return match ? match[1] : undefined;
|
|
121
|
+
}
|
package/src/context.test.ts
CHANGED
|
@@ -47,6 +47,31 @@ function setupFetchMock(overrides?: {
|
|
|
47
47
|
fetchMock.mockImplementation(async (url: string) => {
|
|
48
48
|
const urlStr = String(url);
|
|
49
49
|
|
|
50
|
+
// New per-user-namespace slug resolver (hq-pro PR 67). Maps slug
|
|
51
|
+
// lookups to `{available: false, conflictingCompanyUid}` so the
|
|
52
|
+
// caller follows up with `/entity/{uid}`, which lands in the
|
|
53
|
+
// `/entity/cmp_` branch below — that's where `entityBody` and
|
|
54
|
+
// `entityStatus` overrides apply. The check-slug branch only
|
|
55
|
+
// honors `entityStatus` (so tests can simulate a 404/500 on the
|
|
56
|
+
// namespace lookup itself); its response shape stays fixed.
|
|
57
|
+
if (urlStr.includes("/entity/check-slug/me")) {
|
|
58
|
+
return {
|
|
59
|
+
ok: (overrides?.entityStatus ?? 200) < 400,
|
|
60
|
+
status: overrides?.entityStatus ?? 200,
|
|
61
|
+
json: async () => ({
|
|
62
|
+
available: false,
|
|
63
|
+
conflictingCompanyUid: mockEntity.uid,
|
|
64
|
+
}),
|
|
65
|
+
text: async () =>
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
available: false,
|
|
68
|
+
conflictingCompanyUid: mockEntity.uid,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Kept for any tests that still mock the legacy global endpoint
|
|
74
|
+
// directly (none should, post-PR 67 — but harmless if invoked).
|
|
50
75
|
if (urlStr.includes("/entity/by-slug/")) {
|
|
51
76
|
return {
|
|
52
77
|
ok: (overrides?.entityStatus ?? 200) < 400,
|
|
@@ -97,10 +122,16 @@ describe("resolveEntityContext", () => {
|
|
|
97
122
|
expect(ctx.credentials.accessKeyId).toBe("ASIA_TEST_KEY");
|
|
98
123
|
expect(ctx.region).toBe("us-east-1");
|
|
99
124
|
|
|
100
|
-
// Verify entity lookup used
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
expect(String(fetchMock.mock.calls[
|
|
125
|
+
// Verify entity lookup used the new per-user-namespace endpoint
|
|
126
|
+
// (PR 67) + a follow-up `/entity/{uid}` materialization + STS.
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
128
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain(
|
|
129
|
+
"/entity/check-slug/me?type=company&slug=acme",
|
|
130
|
+
);
|
|
131
|
+
expect(String(fetchMock.mock.calls[1][0])).toContain(
|
|
132
|
+
`/entity/${mockEntity.uid}`,
|
|
133
|
+
);
|
|
134
|
+
expect(String(fetchMock.mock.calls[2][0])).toContain("/sts/vend");
|
|
104
135
|
});
|
|
105
136
|
|
|
106
137
|
it("resolves context by UID directly", async () => {
|
|
@@ -120,7 +151,9 @@ describe("resolveEntityContext", () => {
|
|
|
120
151
|
const ctx2 = await resolveEntityContext("acme", mockConfig);
|
|
121
152
|
|
|
122
153
|
expect(ctx1).toBe(ctx2); // Same reference
|
|
123
|
-
|
|
154
|
+
// 3 fetches per resolve under the new model: check-slug + entity.get + sts/vend.
|
|
155
|
+
// 1 resolve here (second call hits cache, no new fetches).
|
|
156
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
124
157
|
});
|
|
125
158
|
|
|
126
159
|
it("auto-refreshes when credentials expire soon", async () => {
|
|
@@ -137,7 +170,8 @@ describe("resolveEntityContext", () => {
|
|
|
137
170
|
// Second call should refresh because <2 min remaining
|
|
138
171
|
const ctx2 = await resolveEntityContext("acme", mockConfig);
|
|
139
172
|
expect(ctx2).not.toBe(ctx1);
|
|
140
|
-
|
|
173
|
+
// 2 resolves × 3 fetches each = 6 under the new model.
|
|
174
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
141
175
|
});
|
|
142
176
|
|
|
143
177
|
it("throws when entity has no bucket", async () => {
|
|
@@ -153,8 +187,11 @@ describe("resolveEntityContext", () => {
|
|
|
153
187
|
it("throws on entity lookup failure", async () => {
|
|
154
188
|
setupFetchMock({ entityStatus: 404 });
|
|
155
189
|
|
|
190
|
+
// The namespace lookup fails first under the new model — error
|
|
191
|
+
// message now reflects "Failed to check slug" before the
|
|
192
|
+
// entity.get(uid) step is reached.
|
|
156
193
|
await expect(resolveEntityContext("nonexistent", mockConfig)).rejects.toThrow(
|
|
157
|
-
/Failed to
|
|
194
|
+
/Failed to check slug/,
|
|
158
195
|
);
|
|
159
196
|
});
|
|
160
197
|
|
|
@@ -201,12 +238,20 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
201
238
|
expect(vendCalls[0]).toContain("/sts/vend-self");
|
|
202
239
|
});
|
|
203
240
|
|
|
204
|
-
it("foo_bar slug: entity resolved via /entity/
|
|
241
|
+
it("foo_bar slug: entity resolved via /entity/check-slug/me + /entity/<uid> and credentials via /sts/vend", async () => {
|
|
205
242
|
const calls: string[] = [];
|
|
206
243
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
207
244
|
const u = String(url);
|
|
208
245
|
calls.push(u);
|
|
209
|
-
if (u.includes("/entity/
|
|
246
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
status: 200,
|
|
250
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
251
|
+
text: async () => "",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
210
255
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
211
256
|
}
|
|
212
257
|
if (u.includes("/sts/vend")) {
|
|
@@ -217,19 +262,29 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
217
262
|
|
|
218
263
|
await resolveEntityContext("foo_bar", mockConfig);
|
|
219
264
|
|
|
220
|
-
expect(
|
|
265
|
+
expect(
|
|
266
|
+
calls.some((u) => u.includes("/entity/check-slug/me?type=company&slug=foo_bar")),
|
|
267
|
+
).toBe(true);
|
|
221
268
|
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
222
269
|
expect(vendCalls).toHaveLength(1);
|
|
223
270
|
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
224
271
|
expect(vendCalls[0]).toContain("/sts/vend");
|
|
225
272
|
});
|
|
226
273
|
|
|
227
|
-
it("team_alpha slug: entity resolved via /entity/
|
|
274
|
+
it("team_alpha slug: entity resolved via /entity/check-slug/me + /entity/<uid> and credentials via /sts/vend", async () => {
|
|
228
275
|
const calls: string[] = [];
|
|
229
276
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
230
277
|
const u = String(url);
|
|
231
278
|
calls.push(u);
|
|
232
|
-
if (u.includes("/entity/
|
|
279
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
status: 200,
|
|
283
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
284
|
+
text: async () => "",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
233
288
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
234
289
|
}
|
|
235
290
|
if (u.includes("/sts/vend")) {
|
|
@@ -240,7 +295,9 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
240
295
|
|
|
241
296
|
await resolveEntityContext("team_alpha", mockConfig);
|
|
242
297
|
|
|
243
|
-
expect(
|
|
298
|
+
expect(
|
|
299
|
+
calls.some((u) => u.includes("/entity/check-slug/me?type=company&slug=team_alpha")),
|
|
300
|
+
).toBe(true);
|
|
244
301
|
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
245
302
|
expect(vendCalls).toHaveLength(1);
|
|
246
303
|
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
@@ -283,7 +340,9 @@ describe("refreshEntityContext", () => {
|
|
|
283
340
|
const ctx2 = await refreshEntityContext("acme", mockConfig);
|
|
284
341
|
|
|
285
342
|
expect(ctx2).not.toBe(ctx1);
|
|
286
|
-
|
|
343
|
+
// 2 resolves × 3 fetches each = 6 under the new model
|
|
344
|
+
// (check-slug + entity.get + sts/vend each).
|
|
345
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
287
346
|
});
|
|
288
347
|
});
|
|
289
348
|
|