unbound-cli 0.8.0 → 0.8.2
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/package.json +1 -1
- package/src/api.js +6 -3
- package/src/auth.js +13 -6
- package/src/commands/login.js +34 -21
- package/src/commands/onboard.js +37 -8
- package/src/commands/setup.js +29 -6
- package/src/config.js +32 -10
- package/src/index.js +10 -5
- package/test/config-normalize-url.test.js +144 -0
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -29,15 +29,18 @@ class ApiError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function request(method, path, { body, query, apiKey } = {}) {
|
|
33
|
-
|
|
32
|
+
function request(method, path, { body, query, apiKey, baseUrl } = {}) {
|
|
33
|
+
// Explicit baseUrl wins over env-var-aware getBaseUrl(). Used by login /
|
|
34
|
+
// setup / onboard so a user's just-passed --backend-url isn't shadowed by a
|
|
35
|
+
// stale UNBOUND_API_URL env var from a prior shell session.
|
|
36
|
+
const resolvedBaseUrl = baseUrl || config.getBaseUrl();
|
|
34
37
|
const key = apiKey || config.getApiKey();
|
|
35
38
|
|
|
36
39
|
if (!key) {
|
|
37
40
|
return Promise.reject(new Error('Not logged in. Run `unbound login` first.'));
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
const url = new URL(path,
|
|
43
|
+
const url = new URL(path, resolvedBaseUrl);
|
|
41
44
|
if (query) {
|
|
42
45
|
for (const [k, v] of Object.entries(query)) {
|
|
43
46
|
if (v !== undefined && v !== null) {
|
package/src/auth.js
CHANGED
|
@@ -107,13 +107,17 @@ async function loginWithBrowser(frontendUrl) {
|
|
|
107
107
|
* Shows a spinner during validation and a success message with user info.
|
|
108
108
|
*
|
|
109
109
|
* @param {string} apiKey - The API key to validate and store
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @param {string} [opts.baseUrl] - Explicit backend URL override. Used when the
|
|
112
|
+
* caller just persisted a tenant URL via setUrls and wants validation to hit
|
|
113
|
+
* that exact backend, regardless of any stale UNBOUND_API_URL env var.
|
|
110
114
|
* @returns {Promise<true>}
|
|
111
115
|
*/
|
|
112
|
-
async function loginWithApiKey(apiKey) {
|
|
116
|
+
async function loginWithApiKey(apiKey, { baseUrl } = {}) {
|
|
113
117
|
const spin = output.spinner('Authenticating...');
|
|
114
118
|
let response;
|
|
115
119
|
try {
|
|
116
|
-
response = await api.get('/api/v1/users/privileges/', { apiKey });
|
|
120
|
+
response = await api.get('/api/v1/users/privileges/', { apiKey, baseUrl });
|
|
117
121
|
} catch (err) {
|
|
118
122
|
let msg;
|
|
119
123
|
if (err.statusCode === 401 || err.statusCode === 403) {
|
|
@@ -143,10 +147,14 @@ async function loginWithApiKey(apiKey) {
|
|
|
143
147
|
* Ensures the user is logged in. If an apiKey is provided, validates and
|
|
144
148
|
* stores it first. If not logged in, triggers browser-based login.
|
|
145
149
|
* Returns true if logged in (or just logged in), false on failure.
|
|
150
|
+
*
|
|
151
|
+
* `baseUrl` and `frontendUrl` are explicit overrides for the URLs to validate
|
|
152
|
+
* against / open in the browser. Used by login/setup/onboard so a just-passed
|
|
153
|
+
* --backend-url / --frontend-url isn't shadowed by a stale env var.
|
|
146
154
|
*/
|
|
147
|
-
async function ensureLoggedIn({ apiKey } = {}) {
|
|
155
|
+
async function ensureLoggedIn({ apiKey, baseUrl, frontendUrl } = {}) {
|
|
148
156
|
if (apiKey) {
|
|
149
|
-
await loginWithApiKey(apiKey);
|
|
157
|
+
await loginWithApiKey(apiKey, { baseUrl });
|
|
150
158
|
return true;
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -155,8 +163,7 @@ async function ensureLoggedIn({ apiKey } = {}) {
|
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
output.warn('Not logged in. Opening browser to authenticate...');
|
|
158
|
-
const
|
|
159
|
-
const result = await loginWithBrowser(frontendUrl);
|
|
166
|
+
const result = await loginWithBrowser(frontendUrl || config.getFrontendUrl());
|
|
160
167
|
|
|
161
168
|
const parts = [];
|
|
162
169
|
if (result.email) parts.push(`as ${result.email}`);
|
package/src/commands/login.js
CHANGED
|
@@ -9,9 +9,10 @@ function register(program) {
|
|
|
9
9
|
.command('login')
|
|
10
10
|
.description('Authenticate with Unbound. Opens a browser for interactive login, or use --api-key for CI/CD environments.')
|
|
11
11
|
.option('--api-key <key>', 'Authenticate with an API key directly (non-interactive)')
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
12
|
+
.option('--gateway-url <url>', 'Tenant AI gateway URL (e.g. https://api.acme.com). Persisted to config.')
|
|
13
|
+
.option('--frontend-url <url>', 'Tenant frontend URL (e.g. https://gateway.acme.com). Persisted to config.')
|
|
14
|
+
.option('--backend-url <url>', 'Tenant backend REST API URL (e.g. https://backend.acme.com). Persisted to config.')
|
|
15
|
+
.addOption(new Option('--base-url <url>', 'Hidden alias of --backend-url, kept for back-compat.').hideHelp())
|
|
15
16
|
.option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
|
|
16
17
|
.addHelpText('after', `
|
|
17
18
|
Authentication methods:
|
|
@@ -24,32 +25,43 @@ Authentication methods:
|
|
|
24
25
|
Non-interactive login for CI/CD or automation. Stores the provided
|
|
25
26
|
API key directly without browser interaction.
|
|
26
27
|
|
|
28
|
+
Tenant deployments (URL flags persist to ~/.unbound/config.json):
|
|
29
|
+
Pass --gateway-url, --frontend-url, --backend-url at login time and the
|
|
30
|
+
CLI stores them all at once — no separate config step needed.
|
|
31
|
+
|
|
27
32
|
Options:
|
|
28
33
|
--domain sets a custom domain for organizations with self-hosted frontends.
|
|
29
|
-
|
|
30
|
-
Tenant deployments:
|
|
31
|
-
Before logging in to a non-default Unbound tenant, set all three URLs:
|
|
32
|
-
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
33
|
-
$ unbound login --api-key <YOUR_API_KEY>
|
|
34
|
+
Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
|
|
34
35
|
|
|
35
36
|
Examples:
|
|
36
37
|
$ unbound login # Login via default gateway
|
|
37
|
-
$ unbound login --domain custom.example.com # Login via custom domain
|
|
38
38
|
$ unbound login --api-key sk-abc123 # Non-interactive login
|
|
39
|
-
$ unbound
|
|
40
|
-
|
|
39
|
+
$ unbound login --api-key sk-abc123 \\
|
|
40
|
+
--gateway-url https://api.acme.com \\
|
|
41
|
+
--frontend-url https://gateway.acme.com \\
|
|
42
|
+
--backend-url https://backend.acme.com # Tenant onboarding (one shot)
|
|
43
|
+
$ unbound login --domain custom.example.com # Login via custom domain
|
|
41
44
|
`)
|
|
42
45
|
.action(async (opts) => {
|
|
43
46
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// --backend-url is the canonical visible flag; --base-url is a hidden
|
|
48
|
+
// back-compat alias. setUrls writes all provided URLs atomically so
|
|
49
|
+
// a malformed --frontend-url can't leave a fresh --backend-url half-applied.
|
|
50
|
+
const written = config.setUrls({
|
|
51
|
+
backend: opts.backendUrl || opts.baseUrl,
|
|
52
|
+
frontend: opts.frontendUrl,
|
|
53
|
+
gateway: opts.gatewayUrl,
|
|
54
|
+
});
|
|
55
|
+
// Prefer just-persisted values over env-var-aware getters so a stale
|
|
56
|
+
// UNBOUND_*_URL from a prior shell session can't shadow the user's
|
|
57
|
+
// explicit --backend-url / --frontend-url and route validation at the
|
|
58
|
+
// wrong tenant.
|
|
59
|
+
const explicitBaseUrl = written.base_url;
|
|
60
|
+
const explicitFrontendUrl = written.frontend_url;
|
|
49
61
|
|
|
50
62
|
if (opts.apiKey) {
|
|
51
63
|
try {
|
|
52
|
-
await loginWithApiKey(opts.apiKey);
|
|
64
|
+
await loginWithApiKey(opts.apiKey, { baseUrl: explicitBaseUrl });
|
|
53
65
|
} catch {
|
|
54
66
|
process.exitCode = 1;
|
|
55
67
|
return;
|
|
@@ -61,12 +73,13 @@ Examples:
|
|
|
61
73
|
if (opts.domain) {
|
|
62
74
|
frontendUrl = opts.domain.startsWith('http') ? opts.domain : `https://${opts.domain}`;
|
|
63
75
|
} else {
|
|
64
|
-
frontendUrl = config.getFrontendUrl();
|
|
76
|
+
frontendUrl = explicitFrontendUrl || config.getFrontendUrl();
|
|
65
77
|
}
|
|
66
78
|
await loginWithBrowser(frontendUrl);
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
79
|
+
// Validate the just-stored key against the explicit backend if one
|
|
80
|
+
// was just persisted; otherwise fall back to the env-var-aware
|
|
81
|
+
// getter. api.get reads the stored key from config internally.
|
|
82
|
+
await api.get('/api/v1/users/privileges/', { baseUrl: explicitBaseUrl });
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
const cfg = config.readConfig();
|
package/src/commands/onboard.js
CHANGED
|
@@ -50,12 +50,31 @@ Examples:
|
|
|
50
50
|
`)
|
|
51
51
|
.action(async (opts) => {
|
|
52
52
|
let setupSucceeded = false;
|
|
53
|
-
|
|
54
|
-
const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
|
|
55
|
-
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
56
|
-
const discoveryDomain = opts.domain || backendUrl;
|
|
53
|
+
let discoveryDomain;
|
|
57
54
|
try {
|
|
58
|
-
|
|
55
|
+
// Persist URLs first, then login, then setup — order matters so the
|
|
56
|
+
// login validates against the new backend and setup wires tools at the
|
|
57
|
+
// new gateway. setUrls is atomic; a malformed URL throws before any
|
|
58
|
+
// disk write so the three URLs never end up out of sync.
|
|
59
|
+
const written = config.setUrls({
|
|
60
|
+
backend: opts.backendUrl,
|
|
61
|
+
frontend: opts.frontendUrl,
|
|
62
|
+
gateway: opts.gatewayUrl,
|
|
63
|
+
});
|
|
64
|
+
// Prefer the values we JUST persisted over the env-var-aware getters —
|
|
65
|
+
// a stale UNBOUND_*_URL from a prior shell session could otherwise
|
|
66
|
+
// silently shadow the user's explicit --*-url flag and route login or
|
|
67
|
+
// setup at the wrong tenant.
|
|
68
|
+
const backendUrl = written.base_url || config.getBaseUrl();
|
|
69
|
+
const frontendUrl = written.frontend_url || config.getFrontendUrl();
|
|
70
|
+
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
71
|
+
discoveryDomain = opts.domain || backendUrl;
|
|
72
|
+
|
|
73
|
+
await ensureLoggedIn({
|
|
74
|
+
apiKey: opts.apiKey,
|
|
75
|
+
baseUrl: written.base_url,
|
|
76
|
+
frontendUrl: written.frontend_url,
|
|
77
|
+
});
|
|
59
78
|
const apiKey = config.getApiKey();
|
|
60
79
|
|
|
61
80
|
console.log('');
|
|
@@ -111,10 +130,20 @@ Examples:
|
|
|
111
130
|
`)
|
|
112
131
|
.action(async (opts) => {
|
|
113
132
|
let setupSucceeded = false;
|
|
114
|
-
|
|
115
|
-
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
116
|
-
const discoveryDomain = opts.domain || backendUrl;
|
|
133
|
+
let discoveryDomain;
|
|
117
134
|
try {
|
|
135
|
+
// Persist URLs first, then login, then setup — order matters so this
|
|
136
|
+
// MDM run wires tools at the new tenant. Prefer just-written values
|
|
137
|
+
// over env-var-aware getters so a stale UNBOUND_*_URL can't shadow the
|
|
138
|
+
// user's explicit --*-url flag.
|
|
139
|
+
const written = config.setUrls({
|
|
140
|
+
backend: opts.backendUrl,
|
|
141
|
+
gateway: opts.gatewayUrl,
|
|
142
|
+
});
|
|
143
|
+
const backendUrl = written.base_url || config.getBaseUrl();
|
|
144
|
+
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
145
|
+
discoveryDomain = opts.domain || backendUrl;
|
|
146
|
+
|
|
118
147
|
checkRoot('onboard-mdm');
|
|
119
148
|
|
|
120
149
|
console.log('');
|
package/src/commands/setup.js
CHANGED
|
@@ -367,11 +367,26 @@ automatically to authenticate before proceeding.
|
|
|
367
367
|
`)
|
|
368
368
|
.action(async (tools, opts) => {
|
|
369
369
|
try {
|
|
370
|
-
|
|
370
|
+
// Persist URLs first, login, then setup. setUrls is atomic; a malformed
|
|
371
|
+
// URL throws before any disk write.
|
|
372
|
+
const written = config.setUrls({
|
|
373
|
+
backend: opts.backendUrl,
|
|
374
|
+
frontend: opts.frontendUrl,
|
|
375
|
+
gateway: opts.gatewayUrl,
|
|
376
|
+
});
|
|
377
|
+
// Prefer just-persisted values over env-var-aware getters so a stale
|
|
378
|
+
// UNBOUND_*_URL from a prior shell session can't silently shadow the
|
|
379
|
+
// user's explicit --*-url flag and route login/setup at the wrong tenant.
|
|
380
|
+
const backendUrl = written.base_url || config.getBaseUrl();
|
|
381
|
+
const frontendUrl = written.frontend_url || config.getFrontendUrl();
|
|
382
|
+
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
383
|
+
|
|
384
|
+
await ensureLoggedIn({
|
|
385
|
+
apiKey: opts.apiKey,
|
|
386
|
+
baseUrl: written.base_url,
|
|
387
|
+
frontendUrl: written.frontend_url,
|
|
388
|
+
});
|
|
371
389
|
const apiKey = config.getApiKey();
|
|
372
|
-
const backendUrl = opts.backendUrl || config.getBaseUrl();
|
|
373
|
-
const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
|
|
374
|
-
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
375
390
|
const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
|
|
376
391
|
|
|
377
392
|
// --all expands to the default bundle. Cannot be combined with explicit tool names.
|
|
@@ -583,8 +598,16 @@ Examples:
|
|
|
583
598
|
// --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
|
|
584
599
|
// Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
|
|
585
600
|
const globalOpts = command.optsWithGlobals();
|
|
586
|
-
|
|
587
|
-
|
|
601
|
+
// Persist URLs first so this MDM run wires tools at the new tenant
|
|
602
|
+
// and any subsequent non-MDM command on the same machine inherits.
|
|
603
|
+
// Prefer just-persisted values over env-var-aware getters so a stale
|
|
604
|
+
// UNBOUND_*_URL can't shadow the explicit --*-url flag.
|
|
605
|
+
const written = config.setUrls({
|
|
606
|
+
backend: globalOpts.backendUrl,
|
|
607
|
+
gateway: globalOpts.gatewayUrl,
|
|
608
|
+
});
|
|
609
|
+
const backendUrl = written.base_url || config.getBaseUrl();
|
|
610
|
+
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
588
611
|
|
|
589
612
|
if (globalOpts.all && tools.length > 0) {
|
|
590
613
|
output.error('Cannot combine --all with specific tool names. Use one or the other.');
|
package/src/config.js
CHANGED
|
@@ -103,12 +103,24 @@ function setGatewayUrl(url) {
|
|
|
103
103
|
return config.gateway_url;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Atomic multi-URL setter. Accepts any subset of `{gateway, frontend, backend}`
|
|
108
|
+
* (each field is independently optional). Normalizes every provided value up
|
|
109
|
+
* front; if any one is invalid it throws BEFORE touching disk so the caller
|
|
110
|
+
* never lands a partial write that leaves the three URLs out of sync.
|
|
111
|
+
*
|
|
112
|
+
* Use this from any code path that may persist more than one URL flag in the
|
|
113
|
+
* same command (login/setup/onboard) so a malformed --frontend-url can't
|
|
114
|
+
* succeed in writing a fresh --backend-url first.
|
|
115
|
+
*
|
|
116
|
+
* Returns an object with only the keys that were updated, normalized.
|
|
117
|
+
*/
|
|
118
|
+
function setUrls({ gateway, frontend, backend } = {}) {
|
|
119
|
+
const normalized = {};
|
|
120
|
+
if (gateway !== undefined) normalized.gateway_url = normalizeUrl(gateway);
|
|
121
|
+
if (frontend !== undefined) normalized.frontend_url = normalizeUrl(frontend);
|
|
122
|
+
if (backend !== undefined) normalized.base_url = normalizeUrl(backend);
|
|
123
|
+
if (Object.keys(normalized).length === 0) return normalized;
|
|
112
124
|
const config = readConfig();
|
|
113
125
|
Object.assign(config, normalized);
|
|
114
126
|
writeConfig(config);
|
|
@@ -125,16 +137,26 @@ function isLoggedIn() {
|
|
|
125
137
|
return !!getApiKey();
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Refreshes cached user identity (email, org_name) from a backend response.
|
|
142
|
+
* Always overwrites when the response carries a non-empty value so that
|
|
143
|
+
* switching tenants under the same API key (or rotating the key to a new org)
|
|
144
|
+
* shows the correct organization in `whoami` / `status` instead of stale data.
|
|
145
|
+
* Defensive: leaves an existing cached value untouched if the response field
|
|
146
|
+
* is missing or empty, so a partial API response can't blank the local config.
|
|
147
|
+
*/
|
|
128
148
|
function backfillUserInfo(apiResponse) {
|
|
129
149
|
if (!apiResponse) return;
|
|
130
150
|
const cfg = readConfig();
|
|
131
151
|
let changed = false;
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
const apiEmail = apiResponse.email;
|
|
153
|
+
if (apiEmail && cfg.email !== apiEmail) {
|
|
154
|
+
cfg.email = apiEmail;
|
|
134
155
|
changed = true;
|
|
135
156
|
}
|
|
136
|
-
|
|
137
|
-
|
|
157
|
+
const apiOrg = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
|
|
158
|
+
if (apiOrg && cfg.org_name !== apiOrg) {
|
|
159
|
+
cfg.org_name = apiOrg;
|
|
138
160
|
changed = true;
|
|
139
161
|
}
|
|
140
162
|
if (changed) writeConfig(cfg);
|
package/src/index.js
CHANGED
|
@@ -32,12 +32,17 @@ AUTHENTICATION
|
|
|
32
32
|
$ unbound whoami Show current user and organization
|
|
33
33
|
$ unbound status Show CLI status and API connectivity
|
|
34
34
|
|
|
35
|
-
Tenant deployments —
|
|
36
|
-
$ unbound
|
|
37
|
-
|
|
35
|
+
Tenant deployments — pass URL flags on login; they persist to ~/.unbound/config.json:
|
|
36
|
+
$ unbound login --api-key <YOUR_API_KEY> \\
|
|
37
|
+
--gateway-url <gw> --frontend-url <fe> --backend-url <be>
|
|
38
38
|
Example:
|
|
39
|
-
$ unbound
|
|
40
|
-
|
|
39
|
+
$ unbound login --api-key sk-... \\
|
|
40
|
+
--gateway-url https://api.acme.com \\
|
|
41
|
+
--frontend-url https://gateway.acme.com \\
|
|
42
|
+
--backend-url https://backend.acme.com
|
|
43
|
+
|
|
44
|
+
Or set URLs separately (any time):
|
|
45
|
+
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
41
46
|
|
|
42
47
|
ONBOARDING (one-step install + discover)
|
|
43
48
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
@@ -96,3 +96,147 @@ test('setBaseUrl/setFrontendUrl/setGatewayUrl return the normalized written valu
|
|
|
96
96
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
// WEB-4103 (PR #22 review): setUrls must be atomic — if any URL fails
|
|
101
|
+
// normalization, NONE of them should be persisted. Previously, separate
|
|
102
|
+
// setBaseUrl/setFrontendUrl/setGatewayUrl calls did sequential writes,
|
|
103
|
+
// so a malformed --frontend-url would land --backend-url half-applied.
|
|
104
|
+
test('setUrls is atomic — partial failure leaves config unchanged', () => {
|
|
105
|
+
const fs = require('node:fs');
|
|
106
|
+
const os = require('node:os');
|
|
107
|
+
const path = require('node:path');
|
|
108
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
|
|
109
|
+
const origHome = process.env.HOME;
|
|
110
|
+
process.env.HOME = tmp;
|
|
111
|
+
delete require.cache[require.resolve('../src/config')];
|
|
112
|
+
const c = require('../src/config');
|
|
113
|
+
try {
|
|
114
|
+
// Seed a known starting state (simulating an existing tenant config).
|
|
115
|
+
c.writeConfig({
|
|
116
|
+
api_key: 'sk-x',
|
|
117
|
+
base_url: 'https://old-backend.example.com',
|
|
118
|
+
frontend_url: 'https://old-frontend.example.com',
|
|
119
|
+
gateway_url: 'https://old-gateway.example.com',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Attempt to set all three; the frontend URL is malformed.
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => c.setUrls({
|
|
125
|
+
backend: 'https://new-backend.example.com',
|
|
126
|
+
frontend: 'javascript:alert(1)',
|
|
127
|
+
gateway: 'https://new-gateway.example.com',
|
|
128
|
+
}),
|
|
129
|
+
/Invalid URL|http or https/,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Critical: NO field should have been persisted — all three still old.
|
|
133
|
+
const cfg = c.readConfig();
|
|
134
|
+
assert.equal(cfg.base_url, 'https://old-backend.example.com');
|
|
135
|
+
assert.equal(cfg.frontend_url, 'https://old-frontend.example.com');
|
|
136
|
+
assert.equal(cfg.gateway_url, 'https://old-gateway.example.com');
|
|
137
|
+
|
|
138
|
+
// Partial input still works atomically.
|
|
139
|
+
c.setUrls({ backend: 'new.example.com' });
|
|
140
|
+
assert.equal(c.readConfig().base_url, 'https://new.example.com');
|
|
141
|
+
assert.equal(c.readConfig().frontend_url, 'https://old-frontend.example.com');
|
|
142
|
+
|
|
143
|
+
// Empty input is a no-op.
|
|
144
|
+
const result = c.setUrls({});
|
|
145
|
+
assert.deepEqual(result, {});
|
|
146
|
+
c.setUrls();
|
|
147
|
+
} finally {
|
|
148
|
+
process.env.HOME = origHome;
|
|
149
|
+
delete require.cache[require.resolve('../src/config')];
|
|
150
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// WEB-4107: setUrls returns the just-written values so callers can use them
|
|
155
|
+
// directly instead of going back through getBaseUrl/etc. (which give env-var
|
|
156
|
+
// precedence over config). A stale UNBOUND_*_URL from a prior shell session
|
|
157
|
+
// would otherwise silently shadow the user's explicit --*-url flag.
|
|
158
|
+
test('setUrls return value reflects what was persisted, bypassing env-var shadowing', () => {
|
|
159
|
+
const fs = require('node:fs');
|
|
160
|
+
const os = require('node:os');
|
|
161
|
+
const path = require('node:path');
|
|
162
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
|
|
163
|
+
const origHome = process.env.HOME;
|
|
164
|
+
const origGw = process.env.UNBOUND_GATEWAY_URL;
|
|
165
|
+
const origBe = process.env.UNBOUND_API_URL;
|
|
166
|
+
process.env.HOME = tmp;
|
|
167
|
+
// Stale env vars from a prior tenant — these would otherwise win in getXxxUrl().
|
|
168
|
+
process.env.UNBOUND_GATEWAY_URL = 'https://gw.STALE.com';
|
|
169
|
+
process.env.UNBOUND_API_URL = 'https://be.STALE.com';
|
|
170
|
+
delete require.cache[require.resolve('../src/config')];
|
|
171
|
+
const c = require('../src/config');
|
|
172
|
+
try {
|
|
173
|
+
const written = c.setUrls({
|
|
174
|
+
backend: 'be.NEW.com',
|
|
175
|
+
frontend: 'fe.NEW.com',
|
|
176
|
+
gateway: 'gw.NEW.com',
|
|
177
|
+
});
|
|
178
|
+
// The return value reflects WHAT WAS WRITTEN — the user's explicit input.
|
|
179
|
+
assert.equal(written.base_url, 'https://be.new.com');
|
|
180
|
+
assert.equal(written.frontend_url, 'https://fe.new.com');
|
|
181
|
+
assert.equal(written.gateway_url, 'https://gw.new.com');
|
|
182
|
+
// The getters would still respect the env vars (existing convention).
|
|
183
|
+
// Callers that need the user's intent must use the setUrls return value.
|
|
184
|
+
assert.equal(c.getGatewayUrl(), 'https://gw.STALE.com');
|
|
185
|
+
assert.equal(c.getBaseUrl(), 'https://be.STALE.com');
|
|
186
|
+
} finally {
|
|
187
|
+
process.env.HOME = origHome;
|
|
188
|
+
if (origGw === undefined) delete process.env.UNBOUND_GATEWAY_URL;
|
|
189
|
+
else process.env.UNBOUND_GATEWAY_URL = origGw;
|
|
190
|
+
if (origBe === undefined) delete process.env.UNBOUND_API_URL;
|
|
191
|
+
else process.env.UNBOUND_API_URL = origBe;
|
|
192
|
+
delete require.cache[require.resolve('../src/config')];
|
|
193
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// WEB-4103: backfillUserInfo must refresh email/org_name when the API returns
|
|
198
|
+
// a different value (e.g. user switched tenants), not just fill if missing.
|
|
199
|
+
// Defensive: must NOT blank the cached value on a partial/empty API response.
|
|
200
|
+
test('backfillUserInfo refreshes stale email/org but preserves on empty', () => {
|
|
201
|
+
const fs = require('node:fs');
|
|
202
|
+
const os = require('node:os');
|
|
203
|
+
const path = require('node:path');
|
|
204
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
|
|
205
|
+
const origHome = process.env.HOME;
|
|
206
|
+
process.env.HOME = tmp;
|
|
207
|
+
delete require.cache[require.resolve('../src/config')];
|
|
208
|
+
const c = require('../src/config');
|
|
209
|
+
try {
|
|
210
|
+
// Seed config as if the user logged in to org1 yesterday.
|
|
211
|
+
c.writeConfig({ api_key: 'sk-x', email: 'user@old.com', org_name: 'OldOrg' });
|
|
212
|
+
|
|
213
|
+
// API now returns the new tenant's identity → cache must update.
|
|
214
|
+
c.backfillUserInfo({ email: 'user@new.com', org_name: 'NewOrg' });
|
|
215
|
+
assert.equal(c.readConfig().email, 'user@new.com');
|
|
216
|
+
assert.equal(c.readConfig().org_name, 'NewOrg');
|
|
217
|
+
|
|
218
|
+
// Subsequent call with same data → no-op (idempotent).
|
|
219
|
+
c.backfillUserInfo({ email: 'user@new.com', org_name: 'NewOrg' });
|
|
220
|
+
assert.equal(c.readConfig().email, 'user@new.com');
|
|
221
|
+
|
|
222
|
+
// Partial / missing API response → cache preserved, NOT blanked.
|
|
223
|
+
c.backfillUserInfo({ email: 'user@new.com' });
|
|
224
|
+
assert.equal(c.readConfig().org_name, 'NewOrg');
|
|
225
|
+
c.backfillUserInfo({});
|
|
226
|
+
assert.equal(c.readConfig().email, 'user@new.com');
|
|
227
|
+
assert.equal(c.readConfig().org_name, 'NewOrg');
|
|
228
|
+
|
|
229
|
+
// Backend returning the legacy `organization_name` key still works.
|
|
230
|
+
c.backfillUserInfo({ organization_name: 'AnotherOrg' });
|
|
231
|
+
assert.equal(c.readConfig().org_name, 'AnotherOrg');
|
|
232
|
+
|
|
233
|
+
// Null / undefined input is a safe no-op.
|
|
234
|
+
c.backfillUserInfo(null);
|
|
235
|
+
c.backfillUserInfo(undefined);
|
|
236
|
+
assert.equal(c.readConfig().email, 'user@new.com');
|
|
237
|
+
} finally {
|
|
238
|
+
process.env.HOME = origHome;
|
|
239
|
+
delete require.cache[require.resolve('../src/config')];
|
|
240
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|