circleinbox 0.1.0 → 0.2.0
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/README.md +11 -7
- package/circleinbox-cli.cjs +83 -17
- package/package.json +2 -3
- package/lib/cli/cli-fixes.test.cjs +0 -242
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ npx @circleinbox/cli help
|
|
|
17
17
|
## Quick Start
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
# 1. Login with your API key
|
|
21
|
-
circleinbox login
|
|
20
|
+
# 1. Login with your API key (interactive hidden prompt)
|
|
21
|
+
circleinbox login
|
|
22
22
|
|
|
23
23
|
# 2. Check connection
|
|
24
24
|
circleinbox status
|
|
@@ -37,11 +37,15 @@ circleinbox send --from hello@example.com --to user@gmail.com --subject "Hello"
|
|
|
37
37
|
|
|
38
38
|
API keys are encrypted with AES-256-GCM and stored at `~/.circleinbox/credentials`. On macOS, the encryption key is stored in the system keychain.
|
|
39
39
|
|
|
40
|
+
The API key is never accepted as a command-line argument (that would leak it via shell history and `ps aux`). Provide it through one of these safe channels:
|
|
41
|
+
|
|
40
42
|
```bash
|
|
41
|
-
circleinbox login
|
|
42
|
-
circleinbox login --key
|
|
43
|
-
|
|
44
|
-
circleinbox
|
|
43
|
+
circleinbox login # Interactive hidden prompt
|
|
44
|
+
circleinbox login --key-file ./key.txt # Read from a file (chmod 600)
|
|
45
|
+
echo "$KEY" | circleinbox login --from-stdin # Pipe via stdin
|
|
46
|
+
CIRCLEINBOX_API_KEY=ci_live_... circleinbox login # From the environment
|
|
47
|
+
CIRCLEINBOX_API_KEY=ci_live_... circleinbox status # Env var fallback for any command
|
|
48
|
+
circleinbox logout # Remove credentials
|
|
45
49
|
```
|
|
46
50
|
|
|
47
51
|
## Commands
|
|
@@ -50,7 +54,7 @@ circleinbox logout # Remove credentials
|
|
|
50
54
|
|
|
51
55
|
| Command | Description |
|
|
52
56
|
|---------|-------------|
|
|
53
|
-
| `login [--key <
|
|
57
|
+
| `login [--key-file <path> \| --from-stdin]` | Store API key (encrypted) |
|
|
54
58
|
| `logout` | Remove stored credentials |
|
|
55
59
|
| `status` | Show connection status and org info |
|
|
56
60
|
| `version` | Show CLI version |
|
package/circleinbox-cli.cjs
CHANGED
|
@@ -85,6 +85,30 @@ function success(msg) { log(c.green('✓'), msg); }
|
|
|
85
85
|
function warn(msg) { log(c.yellow('!'), msg); }
|
|
86
86
|
function error(msg) { console.error(c.red('✗'), msg); }
|
|
87
87
|
|
|
88
|
+
// Read a line from the TTY without echoing it (for secret entry).
|
|
89
|
+
function promptHidden(question) {
|
|
90
|
+
const readline = require('readline');
|
|
91
|
+
const { Writable } = require('stream');
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const masked = new Writable({
|
|
94
|
+
write(chunk, enc, cb) {
|
|
95
|
+
if (!masked.isMuted) process.stdout.write(chunk, enc);
|
|
96
|
+
cb();
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
masked.isMuted = false;
|
|
100
|
+
const rl = readline.createInterface({ input: process.stdin, output: masked, terminal: true });
|
|
101
|
+
// Intentional sequencing: rl.question() writes the prompt synchronously
|
|
102
|
+
// (while unmuted), then we mute so the user's keystrokes are not echoed.
|
|
103
|
+
rl.question(question, (answer) => {
|
|
104
|
+
rl.close();
|
|
105
|
+
process.stdout.write('\n');
|
|
106
|
+
resolve(answer.replace(/[\x00-\x1F\x7F]/g, '').trim());
|
|
107
|
+
});
|
|
108
|
+
masked.isMuted = true;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
// ============================================================================
|
|
89
113
|
// Table formatter
|
|
90
114
|
// ============================================================================
|
|
@@ -199,7 +223,7 @@ function getEncryptionKey() {
|
|
|
199
223
|
function encryptCredentials(data) {
|
|
200
224
|
const key = getEncryptionKey();
|
|
201
225
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
202
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
226
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
|
|
203
227
|
|
|
204
228
|
const plaintext = JSON.stringify(data);
|
|
205
229
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
@@ -221,7 +245,8 @@ function decryptCredentials() {
|
|
|
221
245
|
const decipher = crypto.createDecipheriv(
|
|
222
246
|
'aes-256-gcm',
|
|
223
247
|
key,
|
|
224
|
-
Buffer.from(iv, 'hex')
|
|
248
|
+
Buffer.from(iv, 'hex'),
|
|
249
|
+
{ authTagLength: 16 }
|
|
225
250
|
);
|
|
226
251
|
decipher.setAuthTag(Buffer.from(tag, 'hex'));
|
|
227
252
|
|
|
@@ -403,7 +428,7 @@ ${c.bold('USAGE')}
|
|
|
403
428
|
circleinbox <command> [options]
|
|
404
429
|
|
|
405
430
|
${c.bold('GENERAL')}
|
|
406
|
-
login [--key <
|
|
431
|
+
login [--key-file <path> | --from-stdin] Store API key (encrypted)
|
|
407
432
|
logout Remove stored credentials
|
|
408
433
|
status Show connection status and org info
|
|
409
434
|
version Show CLI version
|
|
@@ -456,18 +481,59 @@ ${c.bold('GLOBAL FLAGS')}
|
|
|
456
481
|
|
|
457
482
|
async function commandLogin(args) {
|
|
458
483
|
const { flags, positional } = parseFlags(args);
|
|
459
|
-
let apiKey = flags.key || positional[0];
|
|
460
484
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
485
|
+
// Secrets must never be passed on the command line — they leak via shell
|
|
486
|
+
// history and `ps aux`. Reject every leak-prone form (`--key <v>`,
|
|
487
|
+
// `--key=<v>`, and a bare positional key) and route to safe channels.
|
|
488
|
+
// Three leak-prone forms, each caught explicitly because parseFlags treats
|
|
489
|
+
// them differently: `--key <v>` -> flags.key='<v>'; `--key=<v>` -> a flag
|
|
490
|
+
// literally named "key=<v>" set to true (parseFlags does not split on "=");
|
|
491
|
+
// and a bare positional. (The middle check guards parseFlags' current "="
|
|
492
|
+
// behavior — test/login-security.test.cjs exercises `--key=...` end-to-end to catch drift.)
|
|
493
|
+
const leakedKeyOnCmdline =
|
|
494
|
+
typeof flags.key === 'string' ||
|
|
495
|
+
Object.keys(flags).some((k) => k.startsWith('key=')) ||
|
|
496
|
+
positional.length > 0;
|
|
497
|
+
if (leakedKeyOnCmdline) {
|
|
498
|
+
error('Passing the API key on the command line is insecure (it leaks via shell history and `ps aux`).');
|
|
499
|
+
info('Use one of these instead:');
|
|
500
|
+
info(' circleinbox login --key-file <path> Read the key from a file');
|
|
501
|
+
info(' echo "$KEY" | circleinbox login --from-stdin Pipe the key via stdin');
|
|
502
|
+
info(' CIRCLEINBOX_API_KEY=<key> circleinbox login Read from the environment');
|
|
503
|
+
info(' circleinbox login Interactive hidden prompt');
|
|
504
|
+
info('If you just ran this with a real key, rotate it — it is now in your shell history.');
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let apiKey;
|
|
509
|
+
if (flags['key-file']) {
|
|
510
|
+
const keyPath = flags['key-file'];
|
|
511
|
+
try {
|
|
512
|
+
const st = fs.statSync(keyPath);
|
|
513
|
+
if (process.platform !== 'win32' && (st.mode & 0o077)) {
|
|
514
|
+
warn(`Key file ${keyPath} is group/world-readable (mode ${(st.mode & 0o777).toString(8)}). Run: chmod 600 ${keyPath}`);
|
|
515
|
+
}
|
|
516
|
+
apiKey = fs.readFileSync(keyPath, 'utf8').replace(/[\x00-\x1F\x7F]/g, '').trim();
|
|
517
|
+
} catch (err) {
|
|
518
|
+
error(`Could not read key file: ${err.message}`);
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
521
|
+
} else if (flags['from-stdin']) {
|
|
522
|
+
if (process.stdin.isTTY) {
|
|
523
|
+
info('Reading API key from stdin (press Ctrl-D when done)…');
|
|
524
|
+
}
|
|
525
|
+
const chunks = [];
|
|
526
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
527
|
+
apiKey = Buffer.concat(chunks).toString('utf8').replace(/[\x00-\x1F\x7F]/g, '').trim();
|
|
528
|
+
} else if (process.env.CIRCLEINBOX_API_KEY) {
|
|
529
|
+
info('Using key from CIRCLEINBOX_API_KEY');
|
|
530
|
+
apiKey = process.env.CIRCLEINBOX_API_KEY.replace(/[\x00-\x1F\x7F]/g, '').trim();
|
|
531
|
+
} else if (process.stdin.isTTY) {
|
|
532
|
+
apiKey = await promptHidden('API Key (ci_live_...): ');
|
|
533
|
+
} else {
|
|
534
|
+
error('No API key provided.');
|
|
535
|
+
info('Use --key-file <path>, --from-stdin, the CIRCLEINBOX_API_KEY env var, or run interactively.');
|
|
536
|
+
process.exit(1);
|
|
471
537
|
}
|
|
472
538
|
|
|
473
539
|
if (!apiKey) {
|
|
@@ -480,10 +546,10 @@ async function commandLogin(args) {
|
|
|
480
546
|
process.exit(1);
|
|
481
547
|
}
|
|
482
548
|
|
|
483
|
-
// Test the key
|
|
549
|
+
// Test the key (honor --api-url override, matching getClient resolution)
|
|
484
550
|
info('Verifying API key...');
|
|
485
|
-
const
|
|
486
|
-
const client = new ApiClient(
|
|
551
|
+
const baseUrl = globalFlags.apiUrlOverride || loadConfig().apiUrl || DEFAULT_API_URL;
|
|
552
|
+
const client = new ApiClient(baseUrl, apiKey);
|
|
487
553
|
|
|
488
554
|
try {
|
|
489
555
|
const result = await client.get('/domains');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "circleinbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "CLI for CircleInbox email infrastructure — manage inboxes, send email, store credentials, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "circleinbox-cli.cjs",
|
|
@@ -16,10 +16,9 @@
|
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node --test
|
|
19
|
+
"test": "node --test test/*.test.cjs"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
|
-
"lib/",
|
|
23
22
|
"circleinbox-cli.cjs",
|
|
24
23
|
"README.md",
|
|
25
24
|
"LICENSE"
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
// Tests for the 6 critical/major fixes in circleinbox-cli.cjs
|
|
2
|
-
// Run with: node --test cli/lib/cli/cli-fixes.test.cjs
|
|
3
|
-
|
|
4
|
-
const { describe, it } = require('node:test');
|
|
5
|
-
const assert = require('node:assert/strict');
|
|
6
|
-
const fs = require('node:fs');
|
|
7
|
-
const path = require('node:path');
|
|
8
|
-
|
|
9
|
-
// Read the CLI source to verify structural fixes
|
|
10
|
-
const cliSource = fs.readFileSync(
|
|
11
|
-
path.join(__dirname, '..', '..', 'circleinbox-cli.cjs'),
|
|
12
|
-
'utf8'
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
describe('Fix 1: --api-url must not persist to disk', () => {
|
|
16
|
-
it('should NOT call saveConfig when --api-url is set', () => {
|
|
17
|
-
// The old code had: saveConfig(config); after setting api-url
|
|
18
|
-
// The new code should set globalFlags.apiUrlOverride instead
|
|
19
|
-
const apiUrlBlock = cliSource.match(
|
|
20
|
-
/if \(gf\['api-url'\]\)[\s\S]*?}/
|
|
21
|
-
)?.[0];
|
|
22
|
-
assert.ok(apiUrlBlock, 'api-url handling block exists');
|
|
23
|
-
assert.ok(
|
|
24
|
-
!apiUrlBlock.includes('saveConfig'),
|
|
25
|
-
'saveConfig must not be called in api-url block'
|
|
26
|
-
);
|
|
27
|
-
assert.ok(
|
|
28
|
-
apiUrlBlock.includes('apiUrlOverride'),
|
|
29
|
-
'should set apiUrlOverride in memory'
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('getClient should use apiUrlOverride', () => {
|
|
34
|
-
const getClientBlock = cliSource.match(
|
|
35
|
-
/function getClient\(\)[\s\S]*?^}/m
|
|
36
|
-
)?.[0];
|
|
37
|
-
assert.ok(getClientBlock, 'getClient function exists');
|
|
38
|
-
assert.ok(
|
|
39
|
-
getClientBlock.includes('apiUrlOverride'),
|
|
40
|
-
'getClient must check globalFlags.apiUrlOverride'
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('Fix 2: readline rl.close() ordering', () => {
|
|
46
|
-
it('inboxDelete should close rl inside the question callback', () => {
|
|
47
|
-
const inboxDeleteBlock = cliSource.match(
|
|
48
|
-
/async function inboxDelete[\s\S]*?^}/m
|
|
49
|
-
)?.[0];
|
|
50
|
-
assert.ok(inboxDeleteBlock, 'inboxDelete function exists');
|
|
51
|
-
|
|
52
|
-
// rl.close() must appear INSIDE the callback, not after rl.question
|
|
53
|
-
// The pattern should be: rl.question('...', (answer) => { rl.close(); resolve(...) })
|
|
54
|
-
// NOT: rl.question('...', resolve); rl.close();
|
|
55
|
-
assert.ok(
|
|
56
|
-
!inboxDeleteBlock.match(/rl\.question\([^)]+,\s*resolve\s*\)\s*;\s*\n\s*rl\.close/),
|
|
57
|
-
'rl.close() must NOT be on the line after rl.question with bare resolve'
|
|
58
|
-
);
|
|
59
|
-
// Positive check: rl.close() appears inside a callback with resolve
|
|
60
|
-
assert.ok(
|
|
61
|
-
inboxDeleteBlock.includes('rl.close();\n resolve('),
|
|
62
|
-
'rl.close() should be inside the callback before resolve'
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('commandReset password prompt should close rl inside the callback', () => {
|
|
67
|
-
// Find the commandReset function's readline usage
|
|
68
|
-
const resetBlock = cliSource.match(
|
|
69
|
-
/async function commandReset[\s\S]*?^}/m
|
|
70
|
-
)?.[0];
|
|
71
|
-
assert.ok(resetBlock, 'commandReset function exists');
|
|
72
|
-
|
|
73
|
-
// Should NOT have the pattern: rl.question('...', resolve); rl.close();
|
|
74
|
-
assert.ok(
|
|
75
|
-
!resetBlock.match(/rl\.question\([^)]+,\s*resolve\s*\)\s*;\s*\n\s*rl\.close/),
|
|
76
|
-
'rl.close() must NOT be after rl.question with bare resolve in commandReset'
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('Fix 3: response.json() crash on non-JSON', () => {
|
|
82
|
-
it('should use response.text() + JSON.parse instead of response.json()', () => {
|
|
83
|
-
const requestBlock = cliSource.match(
|
|
84
|
-
/async request\(method[\s\S]*?^\s{2}}/m
|
|
85
|
-
)?.[0];
|
|
86
|
-
assert.ok(requestBlock, 'request method exists');
|
|
87
|
-
|
|
88
|
-
assert.ok(
|
|
89
|
-
!requestBlock.includes('response.json()'),
|
|
90
|
-
'must NOT use response.json() directly'
|
|
91
|
-
);
|
|
92
|
-
assert.ok(
|
|
93
|
-
requestBlock.includes('response.text()'),
|
|
94
|
-
'must use response.text() first'
|
|
95
|
-
);
|
|
96
|
-
assert.ok(
|
|
97
|
-
requestBlock.includes('JSON.parse(text)'),
|
|
98
|
-
'must use JSON.parse on text'
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('should handle parse failures gracefully', () => {
|
|
103
|
-
const requestBlock = cliSource.match(
|
|
104
|
-
/async request\(method[\s\S]*?^\s{2}}/m
|
|
105
|
-
)?.[0];
|
|
106
|
-
// Should have error handling around JSON.parse
|
|
107
|
-
assert.ok(
|
|
108
|
-
requestBlock.includes("catch") && requestBlock.includes("INVALID_RESPONSE"),
|
|
109
|
-
'should catch parse errors and throw with INVALID_RESPONSE code'
|
|
110
|
-
);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('Fix 4: fetch timeout with AbortController', () => {
|
|
115
|
-
it('ApiClient.request should use AbortController with 30s timeout', () => {
|
|
116
|
-
const requestBlock = cliSource.match(
|
|
117
|
-
/async request\(method[\s\S]*?^\s{2}}/m
|
|
118
|
-
)?.[0];
|
|
119
|
-
assert.ok(requestBlock, 'request method exists');
|
|
120
|
-
|
|
121
|
-
assert.ok(
|
|
122
|
-
requestBlock.includes('AbortController'),
|
|
123
|
-
'must create AbortController'
|
|
124
|
-
);
|
|
125
|
-
assert.ok(
|
|
126
|
-
requestBlock.includes('30000'),
|
|
127
|
-
'must set 30s timeout'
|
|
128
|
-
);
|
|
129
|
-
assert.ok(
|
|
130
|
-
requestBlock.includes('signal: controller.signal'),
|
|
131
|
-
'must pass signal to fetch'
|
|
132
|
-
);
|
|
133
|
-
assert.ok(
|
|
134
|
-
requestBlock.includes('clearTimeout'),
|
|
135
|
-
'must clear timeout after fetch'
|
|
136
|
-
);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('resetClearAuth fetch calls should also have timeouts', () => {
|
|
140
|
-
const resetBlock = cliSource.match(
|
|
141
|
-
/async function resetClearAuth[\s\S]*?^}/m
|
|
142
|
-
)?.[0];
|
|
143
|
-
assert.ok(resetBlock, 'resetClearAuth function exists');
|
|
144
|
-
|
|
145
|
-
// Should have AbortController for both fetch calls
|
|
146
|
-
const controllerMatches = resetBlock.match(/new AbortController/g);
|
|
147
|
-
assert.ok(
|
|
148
|
-
controllerMatches && controllerMatches.length >= 2,
|
|
149
|
-
'resetClearAuth must have at least 2 AbortControllers (one per fetch)'
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('Fix 5: subArgs no longer uses fragile indexOf', () => {
|
|
155
|
-
it('should not use allArgs.indexOf(command) for subArgs', () => {
|
|
156
|
-
const mainBlock = cliSource.match(
|
|
157
|
-
/\/\/ Main[\s\S]*$/
|
|
158
|
-
)?.[0];
|
|
159
|
-
assert.ok(mainBlock, 'main block exists');
|
|
160
|
-
|
|
161
|
-
assert.ok(
|
|
162
|
-
!mainBlock.includes('allArgs.indexOf(command)'),
|
|
163
|
-
'must NOT use allArgs.indexOf(command)'
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('should track command position by iterating and skipping global flags', () => {
|
|
168
|
-
const mainBlock = cliSource.match(
|
|
169
|
-
/\/\/ Main[\s\S]*$/
|
|
170
|
-
)?.[0];
|
|
171
|
-
assert.ok(mainBlock, 'main block exists');
|
|
172
|
-
|
|
173
|
-
assert.ok(
|
|
174
|
-
mainBlock.includes('commandIndex'),
|
|
175
|
-
'should use a commandIndex variable'
|
|
176
|
-
);
|
|
177
|
-
assert.ok(
|
|
178
|
-
mainBlock.includes('GLOBAL_FLAGS'),
|
|
179
|
-
'should define known global flags to skip'
|
|
180
|
-
);
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe('Fix 6: HTTPS validation for credentials', () => {
|
|
185
|
-
it('resetClearAuth should refuse HTTP URLs', () => {
|
|
186
|
-
const resetBlock = cliSource.match(
|
|
187
|
-
/async function resetClearAuth[\s\S]*?^}/m
|
|
188
|
-
)?.[0];
|
|
189
|
-
assert.ok(resetBlock, 'resetClearAuth function exists');
|
|
190
|
-
|
|
191
|
-
assert.ok(
|
|
192
|
-
resetBlock.includes("startsWith('https://')"),
|
|
193
|
-
'must check for https:// protocol'
|
|
194
|
-
);
|
|
195
|
-
assert.ok(
|
|
196
|
-
resetBlock.includes('insecure HTTP') || resetBlock.includes('Refusing'),
|
|
197
|
-
'must show error about insecure connection'
|
|
198
|
-
);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// ============================================================================
|
|
203
|
-
// PR #22 Review Fixes
|
|
204
|
-
// ============================================================================
|
|
205
|
-
|
|
206
|
-
describe('Fix 7: Generic password reset flow must also validate HTTPS', () => {
|
|
207
|
-
it('commandReset generic flow should check for HTTPS before proceeding', () => {
|
|
208
|
-
const resetBlock = cliSource.match(
|
|
209
|
-
/async function commandReset[\s\S]*?^}/m
|
|
210
|
-
)?.[0];
|
|
211
|
-
assert.ok(resetBlock, 'commandReset function exists');
|
|
212
|
-
|
|
213
|
-
// The generic flow starts after the ClearAuth branch (if flags.clearauth)
|
|
214
|
-
// It should have its own HTTPS check before the generic flow logic
|
|
215
|
-
const genericFlowSection = resetBlock.split('flags.clearauth')[1];
|
|
216
|
-
assert.ok(genericFlowSection, 'generic flow section exists after clearauth branch');
|
|
217
|
-
|
|
218
|
-
assert.ok(
|
|
219
|
-
genericFlowSection.includes("startsWith('https://')") ||
|
|
220
|
-
genericFlowSection.includes('startsWith("https://")'),
|
|
221
|
-
'generic flow must check for https:// protocol on serviceUrl'
|
|
222
|
-
);
|
|
223
|
-
assert.ok(
|
|
224
|
-
genericFlowSection.includes('insecure') || genericFlowSection.includes('HTTPS'),
|
|
225
|
-
'generic flow must warn about insecure connections'
|
|
226
|
-
);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
describe('Fix 8: Retry jitter to prevent thundering herd', () => {
|
|
231
|
-
it('ApiClient.request should add jitter to retry delays', () => {
|
|
232
|
-
const requestBlock = cliSource.match(
|
|
233
|
-
/async request\(method[\s\S]*?^\s{2}}/m
|
|
234
|
-
)?.[0];
|
|
235
|
-
assert.ok(requestBlock, 'request method exists');
|
|
236
|
-
|
|
237
|
-
assert.ok(
|
|
238
|
-
requestBlock.includes('Math.random()'),
|
|
239
|
-
'retry delay must include random jitter via Math.random()'
|
|
240
|
-
);
|
|
241
|
-
});
|
|
242
|
-
});
|