Skip to content

Commit fc43ff8

Browse files
authored
Merge pull request #118 from ProvarTesting/feature/auth-and-quality-hub-api
Feature/auth and quality hub api
2 parents 329fb21 + 9376fbc commit fc43ff8

5 files changed

Lines changed: 74 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Validation runs in two modes: **local only** (structural rules, no key required)
4242
**Requires:** Provar Automation IDE installed with an activated license.
4343

4444
```sh
45-
# 1. Install the plugin (if not already installed)
45+
# 1. Install the plugin — @beta is required for MCP support
4646
sf plugins install @provartesting/provardx-cli@beta
4747

4848
# 2. (Optional) Authenticate for full 170+ rule validation

docs/mcp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ The Provar DX CLI ships with a built-in **Model Context Protocol (MCP) server**
6666
## Quick start
6767

6868
```sh
69-
# 1. Install the plugin
69+
# 1. Install the plugin — @beta is required for MCP support
7070
sf plugins install @provartesting/provardx-cli@beta
7171

7272
# 2. (Optional) Authenticate for full 170+ rule validation

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@provartesting/provardx-cli",
33
"description": "A plugin for the Salesforce CLI to orchestrate testing activities and report quality metrics to Provar Quality Hub",
4-
"version": "1.5.0-beta.4",
4+
"version": "1.5.0-beta.5",
55
"license": "BSD-3-Clause",
66
"plugins": [
77
"@provartesting/provardx-plugins-automation",

src/services/auth/loginFlow.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import crypto from 'node:crypto';
1010
import http from 'node:http';
1111
import https from 'node:https';
12-
import { execFile } from 'node:child_process';
12+
import { spawn, type ChildProcess } from 'node:child_process';
1313
import { URL } from 'node:url';
1414

1515
// All three ports must be pre-registered in the Cognito App Client.
@@ -76,21 +76,35 @@ function isPortFree(port: number): Promise<boolean> {
7676
* Open a URL in the system browser. The URL is passed as an argument — not
7777
* interpolated into a shell string — to avoid command injection.
7878
*/
79-
export function openBrowser(url: string): void {
80-
switch (process.platform) {
79+
/**
80+
* Return the platform-specific command and argument list for opening a URL
81+
* in the system browser. Exported so tests can assert the correct command is
82+
* chosen for each platform without actually spawning a process.
83+
*/
84+
export function getBrowserCommand(url: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } {
85+
switch (platform) {
8186
case 'darwin':
82-
execFile('open', [url]);
83-
break;
87+
return { cmd: 'open', args: [url] };
8488
case 'win32':
8589
// Pass the URL via $args[0] so it is never interpolated into the -Command
8690
// string — avoids quote-breaking and injection risk from special characters.
87-
execFile('powershell.exe', ['-NoProfile', '-Command', 'Start-Process $args[0]', '-args', url]);
88-
break;
91+
return { cmd: 'powershell.exe', args: ['-NoProfile', '-Command', 'Start-Process $args[0]', '-args', url] };
8992
default:
90-
execFile('xdg-open', [url]);
93+
return { cmd: 'xdg-open', args: [url] };
9194
}
9295
}
9396

97+
export function openBrowser(url: string): void {
98+
// detached:true + stdio:'ignore' + unref() is the standard Node.js pattern for
99+
// fire-and-forget child processes — the event loop will not wait for them to exit.
100+
const { cmd, args } = getBrowserCommand(url);
101+
const child: ChildProcess = spawn(cmd, args, { detached: true, stdio: 'ignore' });
102+
// Suppress unhandled-error crashes if the browser executable is not found.
103+
// The login URL is already printed to the terminal so the user can open it manually.
104+
child.on('error', () => { /* intentional no-op */ });
105+
child.unref();
106+
}
107+
94108
// ── Localhost callback server ─────────────────────────────────────────────────
95109

96110
/**
@@ -107,26 +121,33 @@ export function listenForCallback(port: number, expectedState?: string): Promise
107121
const callbackState = parsed.searchParams.get('state');
108122

109123
if (expectedState && callbackState !== expectedState) {
110-
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
124+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
111125
res.end(
112126
'<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
113127
'<h2 style="color:#c23934">Authentication failed</h2>' +
114128
'<p>Invalid state parameter — possible CSRF attack. Please try again.</p>' +
115129
'</body></html>'
116130
);
117131
server.close();
132+
server.closeAllConnections?.();
118133
reject(new Error('OAuth callback state mismatch — possible CSRF. Try again.'));
119134
return;
120135
}
121136

122-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
137+
// 'Connection: close' tells the browser to close the TCP connection after
138+
// this response so server.close() has no lingering keep-alive sockets to
139+
// wait for, allowing the Node.js event loop to exit promptly.
140+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
123141
res.end(
124142
'<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
125143
'<h2 style="color:#0070d2">Authentication complete</h2>' +
126144
'<p>You can close this tab and return to the terminal.</p>' +
127145
'</body></html>'
128146
);
129147
server.close();
148+
// Destroy any sockets that are still open (e.g. a browser that ignores
149+
// the Connection:close header). Requires Node 18.2+.
150+
server.closeAllConnections?.();
130151

131152
if (code) {
132153
resolve(code);

test/unit/commands/provar/auth/login.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { describe, it, beforeEach, afterEach } from 'mocha';
1515
import sinon from 'sinon';
1616
import {
1717
generatePkce,
18+
getBrowserCommand,
1819
loginFlowClient,
1920
type CognitoTokens,
2021
CALLBACK_PORTS,
@@ -59,6 +60,45 @@ function restoreHome(): void {
5960
fs.rmSync(tempDir, { recursive: true, force: true });
6061
}
6162

63+
// ── getBrowserCommand ─────────────────────────────────────────────────────────
64+
65+
describe('getBrowserCommand', () => {
66+
const url = 'https://example.com/login?code=abc&state=xyz';
67+
68+
it('uses "open" on macOS', () => {
69+
const { cmd, args } = getBrowserCommand(url, 'darwin');
70+
assert.equal(cmd, 'open');
71+
assert.deepEqual(args, [url]);
72+
});
73+
74+
it('uses powershell.exe with Start-Process on Windows', () => {
75+
const { cmd, args } = getBrowserCommand(url, 'win32');
76+
assert.equal(cmd, 'powershell.exe');
77+
assert.ok(args.includes('-NoProfile'), 'should pass -NoProfile');
78+
assert.ok(args.includes('Start-Process $args[0]') || args.join(' ').includes('Start-Process'), 'should use Start-Process');
79+
// URL is passed as a separate arg — never interpolated into the command string
80+
assert.equal(args[args.length - 1], url, 'URL must be the last argument');
81+
});
82+
83+
it('uses "xdg-open" on Linux', () => {
84+
const { cmd, args } = getBrowserCommand(url, 'linux');
85+
assert.equal(cmd, 'xdg-open');
86+
assert.deepEqual(args, [url]);
87+
});
88+
89+
it('uses "xdg-open" for unknown platforms', () => {
90+
const { cmd } = getBrowserCommand(url, 'freebsd');
91+
assert.equal(cmd, 'xdg-open');
92+
});
93+
94+
it('includes the full URL in args for all platforms', () => {
95+
for (const platform of ['darwin', 'win32', 'linux'] as NodeJS.Platform[]) {
96+
const { args } = getBrowserCommand(url, platform);
97+
assert.ok(args.includes(url), `URL must appear in args for platform ${platform}`);
98+
}
99+
});
100+
});
101+
62102
// ── generatePkce ──────────────────────────────────────────────────────────────
63103

64104
describe('generatePkce', () => {

0 commit comments

Comments
 (0)