mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-01 16:44:16 +00:00
Feat: Support PAC file upload (#7651)
* Add proxy .pac file resolver chore(dependencies): update package-lock.json with new dependencies and version upgrades - Added new dependencies: ajv, git-url-parse, @opencollection/types, and storybook packages. - Updated existing dependencies to their latest versions, including eslint and babel packages. - Removed deprecated entries and cleaned up the package-lock structure for better maintainability. * tests * wip * wip * wip * wip * feat: file upload .pac * wip * wip * wip * wip * wip * wip * wip * wip * feat: Refactor proxy settings to use a new structure for PAC configuration. Introduced 'source' field to determine proxy type (manual or PAC) and updated related validation and state management. Removed deprecated 'pacUrl' field in favor of 'pac.source'. Updated preferences schema and test data accordingly. * fix: Update proxy settings to correctly reference 'source' field for PAC configuration. Adjusted state management and validation logic to align with new structure. Enhanced tests for backward compatibility and new format handling. * feat: Enhance proxy configuration by adding 'proxyModeReason' to provide context for proxy settings. Updated related functions to accommodate the new parameter and improved logging for proxy mode changes. * wip * refactor: Update proxy settings to remove 'inherit' field and replace it with 'source' for better clarity. Adjusted validation schema, default preferences, and migration logic to align with the new structure. Enhanced tests to ensure compatibility with the updated proxy configuration. * wip * wip * wip * wip * wip * chore: consistent path check * chore: consistency * tests(pac): fix unit params --------- Co-authored-by: Gianluca D'Abrosca <gianluca.dabrosca.1999@gmail.com> Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
120
package-lock.json
generated
120
package-lock.json
generated
@@ -17654,6 +17654,32 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
||||
"integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ast-types": "^0.13.4",
|
||||
"escodegen": "^2.1.0",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator/node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -18560,7 +18586,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
@@ -18582,7 +18607,6 @@
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
@@ -18965,7 +18989,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -24678,6 +24701,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/netmask": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
|
||||
"integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/new-github-issue-url": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz",
|
||||
@@ -25190,6 +25222,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
|
||||
"integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"degenerator": "^5.0.0",
|
||||
"netmask": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -35969,6 +36014,8 @@
|
||||
"http-proxy-agent": "~7.0.2",
|
||||
"https-proxy-agent": "~7.0.6",
|
||||
"is-ip": "^5.0.1",
|
||||
"pac-resolver": "^7.0.1",
|
||||
"quickjs-emscripten": "^0.32.0",
|
||||
"shell-env": "^4.0.1",
|
||||
"socks-proxy-agent": "~8.0.5",
|
||||
"system-ca": "^2.0.1",
|
||||
@@ -36010,6 +36057,48 @@
|
||||
"npm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
|
||||
"integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
|
||||
"integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -36033,6 +36122,31 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-requests/node_modules/quickjs-emscripten": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
|
||||
"integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
|
||||
"quickjs-emscripten-core": "0.32.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-requests/node_modules/tough-cookie": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
|
||||
@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -26,6 +26,57 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.pac-mode-toggle {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pac-mode-btn {
|
||||
height: 34px;
|
||||
padding: 0.1rem 0.6rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.button.secondary.bg};
|
||||
color: ${(props) => props.theme.button.secondary.color};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.pac-source-input {
|
||||
width: 265px;
|
||||
}
|
||||
|
||||
.pac-file-btn {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pac-hint {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-top: 4px;
|
||||
padding-left: 100px;
|
||||
}
|
||||
|
||||
.system-proxy-settings {
|
||||
label {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
|
||||
@@ -17,7 +17,22 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
const proxySchema = Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
inherit: Yup.boolean().required(),
|
||||
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
|
||||
pac: Yup.object({
|
||||
source: Yup.string()
|
||||
.optional()
|
||||
.test('pac-url', 'Specify a valid PAC URL', (value) => {
|
||||
if (!value) return true;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.max(2048)
|
||||
.nullable()
|
||||
}).optional(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -39,7 +54,10 @@ const ProxySettings = ({ close }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
source: preferences.proxy.source || 'manual',
|
||||
pac: {
|
||||
source: preferences.proxy.pac?.source || ''
|
||||
},
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
@@ -86,15 +104,26 @@ const ProxySettings = ({ close }) => {
|
||||
);
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
if (preferences.proxy.source === 'pac') return 'pac';
|
||||
if (preferences.proxy.source === 'inherit') return 'inherit';
|
||||
return 'manual';
|
||||
});
|
||||
const [pacInputMode, setPacInputMode] = useState(() =>
|
||||
preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
// Don't auto-save PAC mode until a URL or file is actually selected.
|
||||
if (proxyMode === 'pac' && !formik.values.pac.source) return;
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -110,10 +139,10 @@ const ProxySettings = ({ close }) => {
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="off"
|
||||
checked={formik.values.disabled === true}
|
||||
checked={proxyMode === 'off'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('off');
|
||||
formik.setFieldValue('disabled', true);
|
||||
formik.setFieldValue('inherit', false);
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -123,11 +152,12 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="on"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === false}
|
||||
value="manual"
|
||||
checked={proxyMode === 'manual'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('manual');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', false);
|
||||
formik.setFieldValue('source', 'manual');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -137,24 +167,40 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="system"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === true}
|
||||
value="inherit"
|
||||
checked={proxyMode === 'inherit'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('inherit');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', true);
|
||||
formik.setFieldValue('source', 'inherit');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
System Proxy
|
||||
</label>
|
||||
<label className="flex items-center ml-4 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="pac"
|
||||
checked={proxyMode === 'pac'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('pac');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('source', 'pac');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
PAC
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{formik.values.disabled === false && formik.values.inherit === true ? (
|
||||
{proxyMode === 'inherit' ? (
|
||||
<div className="mb-3 pt-1 text-muted system-proxy-settings">
|
||||
<SystemProxy />
|
||||
</div>
|
||||
) : null}
|
||||
{formik.values.disabled === false && formik.values.inherit === false ? (
|
||||
{proxyMode === 'manual' ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
@@ -335,6 +381,79 @@ const ProxySettings = ({ close }) => {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{proxyMode === 'pac' ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<label className="settings-label">PAC</label>
|
||||
<div className="pac-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`pac-mode-btn ${pacInputMode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPacInputMode('url');
|
||||
formik.setFieldValue('pac.source', '');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`pac-mode-btn ${pacInputMode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPacInputMode('file');
|
||||
formik.setFieldValue('pac.source', '');
|
||||
}}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
{pacInputMode === 'url' ? (
|
||||
<input
|
||||
id="pac.source"
|
||||
type="text"
|
||||
name="pac.source"
|
||||
className="block textbox pac-source-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.pac.source || ''}
|
||||
placeholder="https://example.com/proxy.pac"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="textbox pac-source-input pac-file-btn"
|
||||
onClick={() => {
|
||||
window.ipcRenderer
|
||||
.invoke('renderer:browse-pac-file')
|
||||
.then((fileUrl) => {
|
||||
if (fileUrl) {
|
||||
formik.setFieldValue('pac.source', fileUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('Failed to open file picker'));
|
||||
}}
|
||||
>
|
||||
{formik.values.pac.source
|
||||
? decodeURIComponent(formik.values.pac.source.split('/').pop())
|
||||
: 'Choose file...'}
|
||||
</button>
|
||||
)}
|
||||
{formik.touched.pac?.source && formik.errors.pac?.source ? (
|
||||
<div className="ml-3 text-red-500">{formik.errors.pac.source}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="pac-hint">
|
||||
{pacInputMode === 'url'
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"watch": "rollup -c -w",
|
||||
"prepack": "npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-typescript": "^7.27.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"babel-jest": "^29.7.0",
|
||||
@@ -52,7 +54,6 @@
|
||||
"rollup": "3.30.0",
|
||||
"rollup-plugin-dts": "^5.0.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -7,10 +7,11 @@ const peerDepsExternal = require('rollup-plugin-peer-deps-external');
|
||||
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) {
|
||||
function createBuildConfig({ inputDir, input, cjsOutput, esmOutput, dtsOutput, external = [] }) {
|
||||
return [
|
||||
{
|
||||
input,
|
||||
external,
|
||||
output: [
|
||||
{
|
||||
file: cjsOutput,
|
||||
@@ -36,30 +37,38 @@ function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) {
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
}
|
||||
},
|
||||
{
|
||||
input,
|
||||
external,
|
||||
output: { file: dtsOutput, format: 'es' },
|
||||
plugins: [dts.default({ tsconfig: './tsconfig.json' })]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// todo: configure declarations
|
||||
module.exports = [
|
||||
// Main package build
|
||||
...createBuildConfig({
|
||||
inputDir: 'src/**/*',
|
||||
input: 'src/index.ts',
|
||||
cjsOutput: packageJson.main,
|
||||
esmOutput: packageJson.module
|
||||
esmOutput: packageJson.module,
|
||||
dtsOutput: packageJson.types
|
||||
}),
|
||||
// reports/html
|
||||
...createBuildConfig({
|
||||
inputDir: 'src/runner/**/*',
|
||||
input: 'src/runner/index.ts',
|
||||
cjsOutput: 'dist/runner/cjs/index.js',
|
||||
esmOutput: 'dist/runner/esm/index.js'
|
||||
esmOutput: 'dist/runner/esm/index.js',
|
||||
dtsOutput: 'dist/runner/index.d.ts'
|
||||
}),
|
||||
...createBuildConfig({
|
||||
inputDir: 'src/utils/**/*',
|
||||
input: 'src/utils/index.ts',
|
||||
cjsOutput: 'dist/utils/cjs/index.js',
|
||||
esmOutput: 'dist/utils/esm/index.js'
|
||||
})
|
||||
esmOutput: 'dist/utils/esm/index.js',
|
||||
dtsOutput: 'dist/utils/index.d.ts'
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { ipcMain, dialog } = require('electron');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const {
|
||||
browseDirectory,
|
||||
@@ -27,6 +28,15 @@ const registerFilesystemIpc = (mainWindow) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:browse-pac-file', async () => {
|
||||
const { filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'PAC Files', extensions: ['pac', 'js'] }]
|
||||
});
|
||||
if (!filePaths || filePaths.length === 0) return null;
|
||||
return pathToFileURL(filePaths[0]).href;
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:exists-sync', async (_, filePath) => {
|
||||
try {
|
||||
const normalizedPath = normalizeAndResolvePath(filePath);
|
||||
|
||||
@@ -73,6 +73,7 @@ const checkConnection = (host, port) =>
|
||||
*/
|
||||
function makeAxiosInstance({
|
||||
proxyMode = 'off',
|
||||
proxyModeReason = '',
|
||||
proxyConfig = {},
|
||||
requestMaxRedirects = 5,
|
||||
httpsAgentRequestFields = {},
|
||||
@@ -202,19 +203,17 @@ function makeAxiosInstance({
|
||||
};
|
||||
|
||||
try {
|
||||
// Now call setupProxyAgents and pass the timeline
|
||||
setupProxyAgents({
|
||||
// Now call setupProxyAgents and pass the timeline (async - may perform PAC resolution)
|
||||
await setupProxyAgents({
|
||||
requestConfig: config,
|
||||
proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings
|
||||
proxyConfig: proxyConfig,
|
||||
proxyMode,
|
||||
proxyModeReason,
|
||||
proxyConfig,
|
||||
httpsAgentRequestFields: agentOptions,
|
||||
interpolationOptions: interpolationOptions, // Provide your interpolation options
|
||||
interpolationOptions,
|
||||
timeline
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.timeline) {
|
||||
timeline = err.timeline;
|
||||
}
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
@@ -270,7 +269,7 @@ function makeAxiosInstance({
|
||||
response.timeline = timeline;
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
const timeline = config?.metadata?.timeline || [];
|
||||
timeline?.push({
|
||||
@@ -417,9 +416,10 @@ function makeAxiosInstance({
|
||||
}
|
||||
|
||||
try {
|
||||
setupProxyAgents({
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode,
|
||||
proxyModeReason,
|
||||
proxyConfig,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
|
||||
@@ -121,6 +121,7 @@ const getCertsAndProxyConfig = async ({
|
||||
*/
|
||||
let proxyMode = 'off';
|
||||
let proxyConfig = {};
|
||||
let proxyModeReason = '';
|
||||
|
||||
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
|
||||
const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false);
|
||||
@@ -135,24 +136,32 @@ const getCertsAndProxyConfig = async ({
|
||||
// Inherit from global preferences
|
||||
const globalProxy = preferencesUtil.getGlobalProxyConfig();
|
||||
const globalDisabled = get(globalProxy, 'disabled', false);
|
||||
const globalInherit = get(globalProxy, 'inherit', false);
|
||||
const globalProxyConfigData = get(globalProxy, 'config', globalProxy);
|
||||
const globalProxySource = get(globalProxy, 'source', 'manual');
|
||||
const globalProxyConfigData = get(globalProxy, 'config', {});
|
||||
|
||||
if (!globalDisabled && !globalInherit) {
|
||||
// Use global custom proxy
|
||||
proxyConfig = globalProxyConfigData;
|
||||
proxyMode = 'on';
|
||||
} else if (!globalDisabled && globalInherit) {
|
||||
// Use system proxy (cached at app startup)
|
||||
proxyMode = 'system';
|
||||
const systemProxyConfig = await getCachedSystemProxy();
|
||||
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
|
||||
if (!globalDisabled) {
|
||||
if (globalProxySource === 'pac') {
|
||||
proxyMode = 'pac';
|
||||
proxyConfig = {
|
||||
pac: globalProxy.pac ?? {}
|
||||
};
|
||||
} else if (globalProxySource === 'inherit') {
|
||||
proxyMode = 'system';
|
||||
const systemProxyConfig = await getCachedSystemProxy();
|
||||
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
|
||||
} else {
|
||||
// source === 'manual'
|
||||
proxyConfig = globalProxyConfigData;
|
||||
proxyMode = 'on';
|
||||
}
|
||||
} else {
|
||||
proxyModeReason = 'App-level proxy is disabled';
|
||||
}
|
||||
// else: global proxy is disabled, proxyMode stays 'off'
|
||||
} else {
|
||||
proxyModeReason = 'Collection-level proxy is disabled';
|
||||
}
|
||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||
|
||||
return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
|
||||
return { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ const prepareGrpcRequest = require('./prepare-grpc-request');
|
||||
const { normalizeAndResolvePath } = require('../../utils/filesystem');
|
||||
const { configureRequest } = require('./prepare-grpc-request');
|
||||
const { shouldUseProxy } = require('../../utils/proxy-util');
|
||||
const { getPacResolver } = require('@usebruno/requests');
|
||||
|
||||
// Creating grpcClient at module level so it can be accessed from window-all-closed event
|
||||
let grpcClient;
|
||||
@@ -29,7 +30,37 @@ let grpcClient;
|
||||
* @param {Object} interpolationOptions - Variable interpolation options
|
||||
* @returns {{ proxyUrl: string | null }}
|
||||
*/
|
||||
const resolveGrpcProxyConfig = (proxyMode, proxyConfig, requestUrl, interpolationOptions) => {
|
||||
const resolveGrpcProxyConfig = async (proxyMode, proxyConfig, requestUrl, interpolationOptions) => {
|
||||
if (proxyMode === 'pac') {
|
||||
const pacSource = get(proxyConfig, 'pac.source');
|
||||
if (!pacSource || !requestUrl) return { proxyUrl: null };
|
||||
|
||||
try {
|
||||
const resolver = await getPacResolver({ pacSource });
|
||||
const directives = await resolver.resolve(requestUrl);
|
||||
if (!directives || !directives.length) return { proxyUrl: null };
|
||||
|
||||
for (const directive of directives) {
|
||||
if (/^DIRECT$/i.test(directive)) return { proxyUrl: null };
|
||||
if (/^(PROXY|HTTP)\s+/i.test(directive)) {
|
||||
const hostPort = directive.split(/\s+/)[1];
|
||||
return { proxyUrl: `http://${hostPort}` };
|
||||
}
|
||||
if (/^HTTPS\s+/i.test(directive)) {
|
||||
console.warn('gRPC proxy: PAC returned an HTTPS proxy directive which is not supported for gRPC connections. Skipping.');
|
||||
continue;
|
||||
}
|
||||
if (/^SOCKS/i.test(directive)) {
|
||||
console.warn('gRPC proxy: PAC returned a SOCKS proxy directive which is not supported for gRPC connections. Skipping.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('gRPC proxy: PAC resolution failed:', e.message);
|
||||
}
|
||||
return { proxyUrl: null };
|
||||
}
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (!shouldProxy) return { proxyUrl: null };
|
||||
@@ -170,7 +201,7 @@ const registerGrpcEventHandlers = (window) => {
|
||||
const pfx = httpsAgentRequestFields.pfx;
|
||||
|
||||
// Resolve proxy configuration for gRPC
|
||||
const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
|
||||
const requestSent = {
|
||||
type: 'request',
|
||||
@@ -330,7 +361,7 @@ const registerGrpcEventHandlers = (window) => {
|
||||
const pfx = httpsAgentRequestFields.pfx;
|
||||
|
||||
// Resolve proxy configuration for gRPC
|
||||
const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
|
||||
// Send OAuth credentials update if available
|
||||
if (preparedRequest?.oauth2Credentials) {
|
||||
|
||||
@@ -8,139 +8,139 @@ const emptyInterpolationOptions = {};
|
||||
|
||||
describe('resolveGrpcProxyConfig', () => {
|
||||
describe('proxyMode "off"', () => {
|
||||
it('should return null proxyUrl', () => {
|
||||
expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
it('should return null proxyUrl', async () => {
|
||||
await expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxyMode "on"', () => {
|
||||
it('should return proxy URL without auth', () => {
|
||||
it('should return proxy URL without auth', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
auth: { disabled: true }
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://proxy.example.com:8080' });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://proxy.example.com:8080' });
|
||||
});
|
||||
|
||||
it('should return proxy URL with auth when auth is enabled', () => {
|
||||
it('should return proxy URL with auth when auth is enabled', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
auth: { disabled: false, username: 'user', password: 'pass' }
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' });
|
||||
});
|
||||
|
||||
it('should URL-encode special characters in credentials', () => {
|
||||
it('should URL-encode special characters in credentials', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
auth: { disabled: false, username: 'user@domain', password: 'p@ss:word' }
|
||||
};
|
||||
const result = resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions);
|
||||
const result = await resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions);
|
||||
expect(result.proxyUrl).toBe('http://user%40domain:p%40ss%3Aword@proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should reject SOCKS proxy protocols', () => {
|
||||
it('should reject SOCKS proxy protocols', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'socks5',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '1080'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should reject HTTPS proxy protocol', () => {
|
||||
it('should reject HTTPS proxy protocol', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null when request URL is in bypassProxy list', () => {
|
||||
it('should return null when request URL is in bypassProxy list', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
bypassProxy: 'localhost,api.example.com'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should omit port when not provided', () => {
|
||||
it('should omit port when not provided', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
auth: { disabled: true }
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://proxy.example.com' });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://proxy.example.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxyMode "system"', () => {
|
||||
it('should use https_proxy when available', () => {
|
||||
it('should use https_proxy when available', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'http://system-proxy.example.com:3128',
|
||||
http_proxy: 'http://fallback-proxy.example.com:3128'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' });
|
||||
});
|
||||
|
||||
it('should fall back to http_proxy when https_proxy is not set', () => {
|
||||
it('should fall back to http_proxy when https_proxy is not set', async () => {
|
||||
const proxyConfig = {
|
||||
http_proxy: 'http://fallback-proxy.example.com:3128'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' });
|
||||
});
|
||||
|
||||
it('should return null when no system proxy is configured', () => {
|
||||
expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
it('should return null when no system proxy is configured', async () => {
|
||||
await expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should reject non-HTTP system proxy protocols', () => {
|
||||
it('should reject non-HTTP system proxy protocols', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'socks5://system-proxy.example.com:1080'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null when request URL matches no_proxy', () => {
|
||||
it('should return null when request URL matches no_proxy', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'http://system-proxy.example.com:3128',
|
||||
no_proxy: 'api.example.com'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null for invalid system proxy URL', () => {
|
||||
it('should return null for invalid system proxy URL', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'not-a-valid-url'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null when proxyConfig is null', () => {
|
||||
expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
it('should return null when proxyConfig is null', async () => {
|
||||
await expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,9 +144,10 @@ const configureRequest = async (
|
||||
request.maxRedirects = 0;
|
||||
|
||||
const { promptVariables = {} } = collection;
|
||||
let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
|
||||
let { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
|
||||
let axiosInstance = makeAxiosInstance({
|
||||
proxyMode,
|
||||
proxyModeReason,
|
||||
proxyConfig,
|
||||
requestMaxRedirects,
|
||||
httpsAgentRequestFields,
|
||||
|
||||
@@ -30,7 +30,8 @@ const defaultPreferences = {
|
||||
codeFontSize: 13
|
||||
},
|
||||
proxy: {
|
||||
inherit: true,
|
||||
source: 'inherit',
|
||||
pac: { source: '' },
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
@@ -93,7 +94,10 @@ const preferencesSchema = Yup.object().shape({
|
||||
}),
|
||||
proxy: Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
inherit: Yup.boolean().required(),
|
||||
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
|
||||
pac: Yup.object({
|
||||
source: Yup.string().optional().max(2048).nullable()
|
||||
}).optional(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -150,7 +154,7 @@ class PreferencesStore {
|
||||
// New users (empty preferences) will get defaultPreferences.proxy via merge
|
||||
if (Object.keys(preferences).length > 0 && !preferences.proxy) {
|
||||
preferences.proxy = {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
disabled: true,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
@@ -173,7 +177,8 @@ class PreferencesStore {
|
||||
|
||||
if (hasOldFormat) {
|
||||
let newProxy = {
|
||||
inherit: true,
|
||||
source: 'inherit',
|
||||
pac: { source: '' },
|
||||
config: {
|
||||
protocol: proxy.protocol || 'http',
|
||||
hostname: proxy.hostname || '',
|
||||
@@ -188,19 +193,17 @@ class PreferencesStore {
|
||||
|
||||
// Handle old format 1: enabled (boolean)
|
||||
if (proxy.hasOwnProperty('enabled') && typeof proxy.enabled === 'boolean') {
|
||||
newProxy.source = 'manual';
|
||||
newProxy.disabled = !proxy.enabled;
|
||||
newProxy.inherit = false;
|
||||
} else if (proxy.hasOwnProperty('mode')) {
|
||||
// Handle old format 2: mode ('off' | 'on' | 'system')
|
||||
if (proxy.mode === 'off') {
|
||||
newProxy.source = 'manual';
|
||||
newProxy.disabled = true;
|
||||
newProxy.inherit = false;
|
||||
} else if (proxy.mode === 'on') {
|
||||
newProxy.disabled = false;
|
||||
newProxy.inherit = false;
|
||||
newProxy.source = 'manual';
|
||||
} else if (proxy.mode === 'system') {
|
||||
newProxy.disabled = false;
|
||||
newProxy.inherit = true;
|
||||
newProxy.source = 'inherit';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +211,6 @@ class PreferencesStore {
|
||||
if (get(proxy, 'auth.enabled') === false) {
|
||||
newProxy.config.auth.disabled = true;
|
||||
}
|
||||
// If auth.enabled is true or undefined, omit disabled (defaults to false)
|
||||
|
||||
// Omit disabled: false at top level (optional field)
|
||||
if (newProxy.disabled === false) {
|
||||
@@ -220,6 +222,18 @@ class PreferencesStore {
|
||||
}
|
||||
|
||||
preferences.proxy = newProxy;
|
||||
this.store.set('preferences', preferences);
|
||||
}
|
||||
|
||||
// Migrate intermediate format: inherit boolean → source string
|
||||
if (!hasOldFormat && proxy.hasOwnProperty('inherit')) {
|
||||
if (proxy.inherit === true) {
|
||||
preferences.proxy.source = 'inherit';
|
||||
} else if (!proxy.source) {
|
||||
preferences.proxy.source = 'manual';
|
||||
}
|
||||
delete preferences.proxy.inherit;
|
||||
this.store.set('preferences', preferences);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
||||
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const { preferencesUtil } = require('../store/preferences');
|
||||
const { getPacResolver } = require('@usebruno/requests');
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
ftp: 21,
|
||||
@@ -103,14 +104,23 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
}
|
||||
}
|
||||
|
||||
function setupProxyAgents({
|
||||
async function setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode = 'off',
|
||||
proxyModeReason = '',
|
||||
proxyConfig,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
timeline
|
||||
}) {
|
||||
if (timeline) {
|
||||
let modeMsg = `Proxy mode: ${proxyMode}`;
|
||||
if (proxyMode === 'pac') modeMsg += ` | PAC URL: ${get(proxyConfig, 'pac.source') || '(empty)'}`;
|
||||
else if (proxyMode === 'on') modeMsg += ` | ${get(proxyConfig, 'protocol')}://${get(proxyConfig, 'hostname')}:${get(proxyConfig, 'port')}`;
|
||||
else if (proxyMode === 'off' && proxyModeReason) modeMsg += ` (${proxyModeReason})`;
|
||||
timeline.push({ timestamp: new Date(), type: 'info', message: modeMsg });
|
||||
}
|
||||
|
||||
// Clear stale agents so we always recreate them for the current URL
|
||||
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
|
||||
delete requestConfig.httpAgent;
|
||||
@@ -153,14 +163,6 @@ function setupProxyAgents({
|
||||
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
|
||||
}
|
||||
|
||||
if (timeline) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using proxy: ${proxyProtocol}://${proxyHostname}${uriPort}`
|
||||
});
|
||||
}
|
||||
|
||||
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
|
||||
// (e.g., ca certs) even for plain HTTP requests
|
||||
const isHttpsProxy = proxyProtocol === 'https';
|
||||
@@ -218,6 +220,38 @@ function setupProxyAgents({
|
||||
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'pac') {
|
||||
const pacSource = get(proxyConfig, 'pac.source');
|
||||
if (pacSource) {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
|
||||
try {
|
||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
|
||||
const directives = await resolver.resolve(requestConfig.url);
|
||||
if (directives && directives.length) {
|
||||
const first = directives[0];
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` });
|
||||
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
|
||||
const parts = first.split(/\s+/);
|
||||
const keyword = parts[0].toUpperCase();
|
||||
const hostPort = parts[1];
|
||||
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
|
||||
const proxyUri = `${scheme}://${hostPort}`;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
|
||||
} else if (/^SOCKS/i.test(first)) {
|
||||
const hostPort = first.split(/\s+/)[1];
|
||||
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
|
||||
const proxyUri = `${proto}://${hostPort}`;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: 'PAC resolved: DIRECT (no proxy)' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'error', message: `PAC resolution failed: ${err.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
|
||||
|
||||
148
packages/bruno-electron/test/proxy-util.test.js
Normal file
148
packages/bruno-electron/test/proxy-util.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const jestClearModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
/** Mock every external dependency that proxy-util pulls in so tests are isolated. */
|
||||
const setupMocks = ({ pacDirectives = ['PROXY p.example:8080'] } = {}) => {
|
||||
// Preferences — controls SSL session cache flag
|
||||
jest.doMock('../src/store/preferences', () => ({
|
||||
preferencesUtil: {
|
||||
isSslSessionCachingEnabled: () => false
|
||||
}
|
||||
}));
|
||||
|
||||
// @usebruno/requests — agent factories + pac resolver
|
||||
jest.doMock('@usebruno/requests', () => ({
|
||||
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
||||
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
||||
getPacResolver: jest.fn(async () => ({
|
||||
resolve: async () => pacDirectives,
|
||||
dispose: () => {}
|
||||
})),
|
||||
clearPacCache: jest.fn()
|
||||
}));
|
||||
};
|
||||
|
||||
describe('proxy-util', () => {
|
||||
beforeEach(() => jestClearModules());
|
||||
afterEach(() => jestClearModules());
|
||||
|
||||
test('shouldUseProxy respects wildcard bypass', () => {
|
||||
const { shouldUseProxy } = require('../src/utils/proxy-util');
|
||||
expect(shouldUseProxy('http://example.com', '*')).toBe(false);
|
||||
});
|
||||
|
||||
test('setupProxyAgents: PAC PROXY directive sets http and https agents', async () => {
|
||||
setupMocks({ pacDirectives: ['PROXY p.example:8080', 'DIRECT'] });
|
||||
const { setupProxyAgents } = require('../src/utils/proxy-util');
|
||||
const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests');
|
||||
|
||||
const requestConfig = { url: 'http://example.com/resource' };
|
||||
const timeline = [];
|
||||
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode: 'pac',
|
||||
proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } },
|
||||
httpsAgentRequestFields: {},
|
||||
interpolationOptions: {},
|
||||
timeline
|
||||
});
|
||||
|
||||
expect(requestConfig.httpsAgent).toBeDefined();
|
||||
expect(requestConfig.httpAgent).toBeDefined();
|
||||
expect(getOrCreateHttpsAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: 'http://p.example:8080' }));
|
||||
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: 'http://p.example:8080' }));
|
||||
|
||||
const hasPacInfo = timeline.some((t) => t.type === 'info' && /PAC directives/.test(t.message));
|
||||
expect(hasPacInfo).toBe(true);
|
||||
});
|
||||
|
||||
test('setupProxyAgents: PAC DIRECT directive bypasses proxy and uses fallback agent', async () => {
|
||||
setupMocks({ pacDirectives: ['DIRECT'] });
|
||||
const { setupProxyAgents } = require('../src/utils/proxy-util');
|
||||
const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests');
|
||||
|
||||
const requestConfig = { url: 'http://example.com/resource' };
|
||||
const timeline = [];
|
||||
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode: 'pac',
|
||||
proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } },
|
||||
httpsAgentRequestFields: {},
|
||||
interpolationOptions: {},
|
||||
timeline
|
||||
});
|
||||
|
||||
// DIRECT → no proxy agents set inside PAC block, fallback sets httpAgent for http request
|
||||
expect(requestConfig.httpAgent).toBeDefined();
|
||||
// httpsAgent should NOT have been set (http request, not https)
|
||||
expect(requestConfig.httpsAgent).toBeUndefined();
|
||||
// Fallback agent called with null proxyUri
|
||||
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: null }));
|
||||
expect(getOrCreateHttpsAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setupProxyAgents: PAC SOCKS directive sets socks agents', async () => {
|
||||
setupMocks({ pacDirectives: ['SOCKS5 socks.example:1080'] });
|
||||
const { setupProxyAgents } = require('../src/utils/proxy-util');
|
||||
const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests');
|
||||
|
||||
const requestConfig = { url: 'http://example.com/resource' };
|
||||
const timeline = [];
|
||||
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode: 'pac',
|
||||
proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } },
|
||||
httpsAgentRequestFields: {},
|
||||
interpolationOptions: {},
|
||||
timeline
|
||||
});
|
||||
|
||||
expect(requestConfig.httpsAgent).toBeDefined();
|
||||
expect(requestConfig.httpAgent).toBeDefined();
|
||||
expect(getOrCreateHttpsAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ proxyUri: 'socks5://socks.example:1080' })
|
||||
);
|
||||
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ proxyUri: 'socks5://socks.example:1080' })
|
||||
);
|
||||
});
|
||||
|
||||
test('setupProxyAgents: PAC resolution error logs to timeline and falls back to direct agent', async () => {
|
||||
jest.doMock('../src/store/preferences', () => ({
|
||||
preferencesUtil: { isSslSessionCachingEnabled: () => false }
|
||||
}));
|
||||
jest.doMock('@usebruno/requests', () => ({
|
||||
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
||||
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
||||
getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
|
||||
clearPacCache: jest.fn()
|
||||
}));
|
||||
|
||||
const { setupProxyAgents } = require('../src/utils/proxy-util');
|
||||
const { getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
|
||||
const requestConfig = { url: 'http://example.com/resource' };
|
||||
const timeline = [];
|
||||
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode: 'pac',
|
||||
proxyConfig: { pac: { source: 'http://unreachable/proxy.pac' } },
|
||||
httpsAgentRequestFields: {},
|
||||
interpolationOptions: {},
|
||||
timeline
|
||||
});
|
||||
|
||||
// Error should be logged to timeline
|
||||
const hasError = timeline.some((t) => t.type === 'error' && /PAC resolution failed/.test(t.message));
|
||||
expect(hasError).toBe(true);
|
||||
// Fallback direct agent should be set for the http request
|
||||
expect(requestConfig.httpAgent).toBeDefined();
|
||||
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: null }));
|
||||
});
|
||||
});
|
||||
@@ -15,44 +15,35 @@ const { getPreferences, savePreferences } = require('../../src/store/preferences
|
||||
|
||||
describe('Proxy Preferences Migration', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock store data before each test
|
||||
mockStoreData = {};
|
||||
});
|
||||
|
||||
describe('Default Proxy Settings', () => {
|
||||
it('should default to inherit: true for new users (empty preferences)', () => {
|
||||
// New user - no preferences.json exists, store returns empty object
|
||||
it('should default to source: inherit for new users (empty preferences)', () => {
|
||||
mockStoreData['preferences'] = {};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// New users get the default proxy settings with inherit: true
|
||||
expect(preferences.proxy.inherit).toBe(true);
|
||||
expect(preferences.proxy.source).toBe('inherit');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config).toBeDefined();
|
||||
expect(preferences.proxy.config.protocol).toBe('http');
|
||||
expect(preferences.proxy.config.hostname).toBe('');
|
||||
expect(preferences.proxy.config.port).toBeNull();
|
||||
});
|
||||
|
||||
it('should default to disabled: true, inherit: false for existing users without proxy settings', () => {
|
||||
// Existing user - has preferences but no proxy property
|
||||
it('should default to source: manual, disabled: true for existing users without proxy settings', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
request: {
|
||||
sslVerification: true
|
||||
},
|
||||
font: {
|
||||
codeFont: 'default',
|
||||
codeFontSize: 13
|
||||
}
|
||||
request: { sslVerification: true },
|
||||
font: { codeFont: 'default', codeFontSize: 13 }
|
||||
};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// Existing users without proxy get disabled proxy by default
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.config).toBeDefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config.protocol).toBe('http');
|
||||
expect(preferences.proxy.config.hostname).toBe('');
|
||||
expect(preferences.proxy.config.port).toBeNull();
|
||||
@@ -62,279 +53,295 @@ describe('Proxy Preferences Migration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('New Format (no migration needed)', () => {
|
||||
it('should handle new format with inherit: false', () => {
|
||||
const newFormatProxy = {
|
||||
describe('v3 Format (no migration needed)', () => {
|
||||
it('should handle source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
},
|
||||
auth: { username: 'user', password: 'pass' },
|
||||
bypassProxy: 'localhost'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = newFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// Verify key fields are preserved from stored preferences
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.config.protocol).toBe('http');
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
|
||||
expect(preferences.proxy.config.port).toBe(8080);
|
||||
expect(preferences.proxy.config.auth.username).toBe('user');
|
||||
expect(preferences.proxy.config.auth.password).toBe('pass');
|
||||
expect(preferences.proxy.config.bypassProxy).toBe('localhost');
|
||||
});
|
||||
|
||||
it('should handle new format with inherit: true', () => {
|
||||
const newFormatProxy = {
|
||||
it('should handle source: inherit', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: true,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
bypassProxy: ''
|
||||
}
|
||||
source: 'inherit',
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = newFormatProxy;
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.source).toBe('inherit');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle source: pac', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
source: 'pac',
|
||||
pac: { source: 'http://internal/proxy.pac' },
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.inherit).toBe(true);
|
||||
expect(preferences.proxy.config).toBeDefined();
|
||||
expect(preferences.proxy.source).toBe('pac');
|
||||
expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle new format with disabled: true', () => {
|
||||
const newFormatProxy = {
|
||||
it('should handle disabled: true with source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
disabled: true,
|
||||
inherit: false,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
bypassProxy: ''
|
||||
}
|
||||
source: 'manual',
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = newFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// disabled: true is preserved from stored preferences
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.config).toBeDefined();
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle new format with auth.disabled: true', () => {
|
||||
const newFormatProxy = {
|
||||
it('should handle auth.disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
disabled: true,
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
},
|
||||
auth: { disabled: true, username: 'user', password: 'pass' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = newFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// auth.disabled: true is preserved from stored preferences
|
||||
expect(preferences.proxy.config.auth.disabled).toBe(true);
|
||||
expect(preferences.proxy.config.auth.username).toBe('user');
|
||||
expect(preferences.proxy.config.auth.password).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old Format 1: enabled (boolean)', () => {
|
||||
it('should migrate enabled: true to disabled: false, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
describe('v2 → v3 Migration (inherit boolean → source string)', () => {
|
||||
it('should migrate inherit: true → source: system', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: true,
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.source).toBe('inherit');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate inherit: false (no source) → source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: false,
|
||||
config: { protocol: 'http', hostname: 'proxy.example.com', port: 8080, auth: { username: 'user', password: 'pass' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
|
||||
});
|
||||
|
||||
it('should migrate inherit: false, source: pac → source: pac (preserved)', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: false,
|
||||
source: 'pac',
|
||||
pac: { source: 'http://internal/proxy.pac' },
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.source).toBe('pac');
|
||||
expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should save migrated v2 → v3 back to disk', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: true,
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
getPreferences();
|
||||
|
||||
expect(mockStoreData['preferences'].proxy.inherit).toBeUndefined();
|
||||
expect(mockStoreData['preferences'].proxy.source).toBe('inherit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 → v3 Migration (enabled boolean)', () => {
|
||||
it('should migrate enabled: true → source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
enabled: true,
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
enabled: true,
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
},
|
||||
auth: { enabled: true, username: 'user', password: 'pass' },
|
||||
bypassProxy: 'localhost'
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// After migration, inherit should be false (old enabled: true maps to inherit: false)
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
// Values are preserved from stored preferences
|
||||
expect(preferences.proxy.config.protocol).toBe('http');
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
|
||||
expect(preferences.proxy.config.port).toBe(8080);
|
||||
expect(preferences.proxy.config.auth.username).toBe('user');
|
||||
expect(preferences.proxy.config.auth.password).toBe('pass');
|
||||
expect(preferences.proxy.config.bypassProxy).toBe('localhost');
|
||||
expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted
|
||||
});
|
||||
|
||||
it('should migrate enabled: false to disabled: true, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate enabled: false → source: manual, disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
enabled: false,
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: { enabled: false, username: '', password: '' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// After migration, enabled: false becomes disabled: true, inherit: false
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate auth.enabled: false to auth.disabled: true', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate auth.enabled: false → auth.disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
enabled: true,
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
enabled: false,
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
},
|
||||
auth: { enabled: false, username: 'user', password: 'pass' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// auth.disabled: true is preserved from stored preferences
|
||||
expect(preferences.proxy.config.auth.disabled).toBe(true);
|
||||
expect(preferences.proxy.config.auth.username).toBe('user');
|
||||
});
|
||||
|
||||
it('should save migrated v1 → v3 back to disk', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
enabled: true,
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: { enabled: true, username: 'user', password: 'pass' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
getPreferences();
|
||||
|
||||
expect(mockStoreData['preferences'].proxy.enabled).toBeUndefined();
|
||||
expect(mockStoreData['preferences'].proxy.source).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old Format 2: mode (string)', () => {
|
||||
it('should migrate mode: "off" to disabled: true, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
describe('v1 → v3 Migration (mode string)', () => {
|
||||
it('should migrate mode: off → source: manual, disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
mode: 'off',
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
auth: { enabled: false, username: '', password: '' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// disabled: true is preserved from migration
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate mode: "on" to disabled: false, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate mode: on → source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
mode: 'on',
|
||||
protocol: 'https',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8443,
|
||||
auth: {
|
||||
enabled: true,
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
},
|
||||
auth: { enabled: true, username: 'user', password: 'pass' },
|
||||
bypassProxy: '*.local'
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
// Values are preserved from stored preferences
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config.protocol).toBe('https');
|
||||
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
|
||||
expect(preferences.proxy.config.port).toBe(8443);
|
||||
});
|
||||
|
||||
it('should migrate mode: "system" to disabled: false, inherit: true', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate mode: system → source: inherit', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
mode: 'system',
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
auth: { enabled: false, username: '', password: '' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted
|
||||
expect(preferences.proxy.inherit).toBe(true);
|
||||
expect(preferences.proxy.source).toBe('inherit');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"@grpc/proto-loader": "^0.7.15",
|
||||
"@types/qs": "^6.9.18",
|
||||
"axios": "1.13.6",
|
||||
"pac-resolver": "^7.0.1",
|
||||
"quickjs-emscripten": "^0.32.0",
|
||||
"debug": "^4.4.3",
|
||||
"google-protobuf": "^4.0.0",
|
||||
"grpc-js-reflection-client": "^1.3.0",
|
||||
|
||||
@@ -39,6 +39,6 @@ module.exports = [
|
||||
typescript({ tsconfig: './tsconfig.json' }),
|
||||
terser()
|
||||
],
|
||||
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env'].includes(id)
|
||||
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env', 'pac-resolver', 'quickjs-emscripten'].includes(id)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -10,6 +10,8 @@ export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node
|
||||
export { getHttpHttpsAgents } from './utils/http-https-agents';
|
||||
export { initializeShellEnv } from './utils/shell-env';
|
||||
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
|
||||
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
|
||||
export type { PacWrapper, GetPacResolverParams } from './utils/pac-resolver';
|
||||
|
||||
export * as scripting from './scripting';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isEmpty, get, isUndefined, isNull } from 'lodash';
|
||||
import { getCACertificates } from './ca-cert';
|
||||
import { transformProxyConfig } from './proxy-util';
|
||||
import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache';
|
||||
import { getPacResolver } from './pac-resolver';
|
||||
import type { TimelineEntry } from './timeline-agent';
|
||||
|
||||
const DEFAULT_PORTS: Record<string, number> = {
|
||||
@@ -23,7 +24,7 @@ const DEFAULT_PORTS: Record<string, number> = {
|
||||
wss: 443
|
||||
};
|
||||
|
||||
type ProxyMode = 'on' | 'off' | 'system';
|
||||
type ProxyMode = 'on' | 'off' | 'system' | 'pac';
|
||||
|
||||
type ProxyAuth = {
|
||||
enabled: boolean;
|
||||
@@ -39,6 +40,9 @@ type ProxyConfig = {
|
||||
auth?: ProxyAuth;
|
||||
bypassProxy?: string;
|
||||
mode?: ProxyMode;
|
||||
pac?: {
|
||||
source: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SystemProxyConfig = {
|
||||
@@ -309,7 +313,7 @@ const getCertsAndProxyConfig = ({
|
||||
/**
|
||||
* Proxy configuration
|
||||
*
|
||||
* Preferences proxyMode has three possible values: on, off, system
|
||||
* Preferences proxyMode has four possible values: on, off, system, pac
|
||||
* Collection proxyMode has three possible values: true, false, global
|
||||
*
|
||||
* When collection proxyMode is true, it overrides the app-level proxy settings
|
||||
@@ -337,18 +341,22 @@ const getCertsAndProxyConfig = ({
|
||||
// Inherit from app-level proxy settings
|
||||
if (appLevelProxyConfig) {
|
||||
const globalDisabled = get(appLevelProxyConfig, 'disabled', false);
|
||||
const globalInherit = get(appLevelProxyConfig, 'inherit', false);
|
||||
const globalProxyConfigData = get(appLevelProxyConfig, 'config', appLevelProxyConfig);
|
||||
const globalProxySource = get(appLevelProxyConfig, 'source', 'inherit');
|
||||
const globalProxyConfigData = get(appLevelProxyConfig, 'config', {});
|
||||
|
||||
if (!globalDisabled && !globalInherit) {
|
||||
// Use app-level custom proxy
|
||||
proxyConfig = globalProxyConfigData;
|
||||
proxyMode = 'on';
|
||||
} else if (!globalDisabled && globalInherit) {
|
||||
// App-level also inherits, fall through to system proxy
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
if (!globalDisabled) {
|
||||
if (globalProxySource === 'pac') {
|
||||
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
|
||||
proxyMode = 'pac';
|
||||
} else if (globalProxySource === 'inherit') {
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
} else {
|
||||
// source === 'manual'
|
||||
proxyConfig = globalProxyConfigData;
|
||||
proxyMode = 'on';
|
||||
}
|
||||
}
|
||||
// else: app-level proxy is disabled, proxyMode stays 'off'
|
||||
@@ -374,7 +382,7 @@ function extractHostname(url: string | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function createAgents({
|
||||
async function createAgents({
|
||||
requestUrl,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
@@ -383,7 +391,7 @@ function createAgents({
|
||||
httpsAgentRequestFields,
|
||||
timeline,
|
||||
disableCache = true
|
||||
}: CreateAgentsParams): AgentResult {
|
||||
}: CreateAgentsParams): Promise<AgentResult> {
|
||||
// Ensure TLS options are properly set
|
||||
const tlsOptions: TlsOptions = {
|
||||
...httpsAgentRequestFields,
|
||||
@@ -447,6 +455,40 @@ function createAgents({
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'pac') {
|
||||
const pacSource = get(proxyConfig, 'pac.source');
|
||||
if (pacSource && requestUrl) {
|
||||
try {
|
||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: { ca: tlsOptions.ca, rejectUnauthorized: tlsOptions.rejectUnauthorized, minVersion: tlsOptions.minVersion } });
|
||||
const directives = await resolver.resolve(requestUrl);
|
||||
if (directives && directives.length) {
|
||||
const first = directives[0];
|
||||
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
|
||||
const parts = first.split(/\s+/);
|
||||
const keyword = parts[0].toUpperCase();
|
||||
const hostPort = parts[1];
|
||||
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
|
||||
const proxyUri = `${scheme}://${hostPort}`;
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
} else if (/^SOCKS/i.test(first)) {
|
||||
const hostPort = first.split(/\s+/)[1];
|
||||
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
|
||||
const proxyUri = `${proto}://${hostPort}`;
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PAC resolution failed — fall through to direct connection
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
const http_proxy = get(systemProxyConfig, 'http_proxy');
|
||||
const https_proxy = get(systemProxyConfig, 'https_proxy');
|
||||
@@ -514,7 +556,7 @@ const getHttpHttpsAgents = async ({
|
||||
httpsAgentRequestFields.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const { httpAgent, httpsAgent } = createAgents({
|
||||
const { httpAgent, httpsAgent } = await createAgents({
|
||||
requestUrl,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
|
||||
@@ -188,6 +188,7 @@ function createVaultClient(config: VaultConfig = {}): VaultClient {
|
||||
method: method as any,
|
||||
url: uri,
|
||||
headers,
|
||||
proxy: false,
|
||||
validateStatus: () => true // Don't throw on non-2xx status
|
||||
};
|
||||
|
||||
|
||||
281
packages/bruno-requests/src/utils/pac-resolver.spec.ts
Normal file
281
packages/bruno-requests/src/utils/pac-resolver.spec.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
describe('pac-resolver (shared)', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const { clearPacCache } = require('./pac-resolver');
|
||||
clearPacCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
/** Mock pac-resolver (v7: { createPacResolver }) and quickjs-emscripten */
|
||||
const setupPacMocks = (resolverFn: (...args: any[]) => Promise<any> = async () => 'PROXY p.example:8080; DIRECT') => {
|
||||
jest.doMock('quickjs-emscripten', () => ({
|
||||
getQuickJS: jest.fn(async () => ({}))
|
||||
}));
|
||||
const createPacResolverMock = jest.fn((_qjs: any, _script: any) => resolverFn);
|
||||
jest.doMock('pac-resolver', () => ({ createPacResolver: createPacResolverMock }));
|
||||
return { createPacResolverMock };
|
||||
};
|
||||
|
||||
const mockFsReadSuccess = (content: string) => {
|
||||
jest.doMock('fs/promises', () => ({
|
||||
readFile: jest.fn().mockResolvedValue(content)
|
||||
}));
|
||||
};
|
||||
|
||||
const mockFsReadError = (err: Error) => {
|
||||
jest.doMock('fs/promises', () => ({
|
||||
readFile: jest.fn().mockRejectedValue(err)
|
||||
}));
|
||||
};
|
||||
|
||||
const mockAxiosSuccess = (text: string) => {
|
||||
jest.doMock('axios', () => ({ get: jest.fn().mockResolvedValue({ data: text }) }));
|
||||
};
|
||||
|
||||
const mockAxiosHttpError = (status: number) => {
|
||||
const err = Object.assign(new Error(`Request failed with status code ${status}`), {
|
||||
response: { status }
|
||||
});
|
||||
jest.doMock('axios', () => ({ get: jest.fn().mockRejectedValue(err) }));
|
||||
};
|
||||
|
||||
const mockAxiosNetworkError = (message: string) => {
|
||||
jest.doMock('axios', () => ({ get: jest.fn().mockRejectedValue(new Error(message)) }));
|
||||
};
|
||||
|
||||
test('throws when pacSource is not provided', async () => {
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
await expect(getPacResolver({})).rejects.toThrow('pacSource must be provided');
|
||||
});
|
||||
|
||||
test('downloads PAC via axios and returns resolver that splits directives', async () => {
|
||||
const pacScript = 'function FindProxyForURL(url, host) { return "PROXY p.example:8080; DIRECT"; }';
|
||||
mockAxiosSuccess(pacScript);
|
||||
const { createPacResolverMock } = setupPacMocks(async () => 'PROXY p.example:8080; DIRECT');
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const { get: axiosGet } = require('axios');
|
||||
|
||||
const pacSource = 'http://example.com/proxy.pac';
|
||||
const wrapper = await getPacResolver({ pacSource });
|
||||
|
||||
const directives = await wrapper.resolve('http://foo.example/');
|
||||
expect(directives).toEqual(['PROXY p.example:8080', 'DIRECT']);
|
||||
expect(createPacResolverMock).toHaveBeenCalledWith(expect.any(Object), pacScript);
|
||||
expect(axiosGet).toHaveBeenCalledWith(pacSource, expect.objectContaining({ proxy: false }));
|
||||
});
|
||||
|
||||
test('passes TLS options to https.Agent for HTTPS pac URLs', async () => {
|
||||
mockAxiosSuccess('script');
|
||||
setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const mockAgentConstructor = jest.fn();
|
||||
jest.doMock('https', () => ({ Agent: mockAgentConstructor }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
|
||||
const httpsAgentRequestFields = {
|
||||
ca: 'ca-cert-data',
|
||||
rejectUnauthorized: false,
|
||||
minVersion: 'TLSv1.2'
|
||||
};
|
||||
await getPacResolver({ pacSource: 'https://secure.example.com/proxy.pac', httpsAgentRequestFields });
|
||||
|
||||
expect(mockAgentConstructor).toHaveBeenCalledWith({
|
||||
ca: 'ca-cert-data',
|
||||
rejectUnauthorized: false,
|
||||
minVersion: 'TLSv1.2'
|
||||
});
|
||||
});
|
||||
|
||||
test('does not create https.Agent for HTTP pac URLs', async () => {
|
||||
mockAxiosSuccess('script');
|
||||
setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const mockAgentConstructor = jest.fn();
|
||||
jest.doMock('https', () => ({ Agent: mockAgentConstructor }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
await getPacResolver({ pacSource: 'http://example.com/proxy.pac' });
|
||||
|
||||
expect(mockAgentConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caches resolver and returns same wrapper on repeated calls', async () => {
|
||||
mockAxiosSuccess('script');
|
||||
const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const { getPacResolver, _CACHE } = require('./pac-resolver');
|
||||
const pacSource = 'http://example.com/proxy.pac';
|
||||
|
||||
const w1 = await getPacResolver({ pacSource });
|
||||
const w2 = await getPacResolver({ pacSource });
|
||||
|
||||
expect(w1).toBe(w2);
|
||||
expect(_CACHE.size).toBeGreaterThan(0);
|
||||
expect(createPacResolverMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('returns empty array when resolver returns non-string', async () => {
|
||||
mockAxiosSuccess('script');
|
||||
setupPacMocks(async () => null);
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const wrapper = await getPacResolver({ pacSource: 'http://example.com/proxy.pac' });
|
||||
expect(await wrapper.resolve('http://example.com/')).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects when axios throws a network error', async () => {
|
||||
mockAxiosNetworkError('ECONNREFUSED');
|
||||
jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() }));
|
||||
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
await expect(getPacResolver({ pacSource: 'http://unreachable/proxy.pac' })).rejects.toThrow('ECONNREFUSED');
|
||||
});
|
||||
|
||||
test('rejects with readable message when PAC server returns non-2xx', async () => {
|
||||
mockAxiosHttpError(404);
|
||||
jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() }));
|
||||
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
await expect(getPacResolver({ pacSource: 'http://example.com/missing.pac' })).rejects.toThrow('Failed to fetch PAC (404)');
|
||||
});
|
||||
|
||||
test('re-downloads PAC after cache TTL expires', async () => {
|
||||
const axiosGetMock = jest.fn().mockResolvedValue({ data: 'script' });
|
||||
jest.doMock('axios', () => ({ get: axiosGetMock }));
|
||||
const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const pacSource = 'http://example.com/proxy.pac';
|
||||
const ttlMs = 100;
|
||||
|
||||
const w1 = await getPacResolver({ pacSource, opts: { cacheTtlMs: ttlMs } });
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const realNow = Date.now;
|
||||
Date.now = () => realNow() + ttlMs + 1;
|
||||
try {
|
||||
const w2 = await getPacResolver({ pacSource, opts: { cacheTtlMs: ttlMs } });
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(2);
|
||||
expect(w2).not.toBe(w1);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolve propagates error from a malformed PAC script', async () => {
|
||||
mockAxiosSuccess('not valid JS {{{{');
|
||||
setupPacMocks(async () => { throw new Error('invalid PAC script'); });
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const wrapper = await getPacResolver({ pacSource: 'http://example.com/bad.pac' });
|
||||
await expect(wrapper.resolve('http://example.com/')).rejects.toThrow('invalid PAC script');
|
||||
});
|
||||
|
||||
/** file:// PAC tests */
|
||||
test('reads PAC from filesystem for file:// URL and does not call axios', async () => {
|
||||
const pacScript = 'function FindProxyForURL(url, host) { return "PROXY p.example:8080"; }';
|
||||
const expectedPath = '/Users/test/proxy.pac';
|
||||
mockFsReadSuccess(pacScript);
|
||||
const { createPacResolverMock } = setupPacMocks(async () => 'PROXY p.example:8080');
|
||||
const axiosGetMock = jest.fn();
|
||||
jest.doMock('axios', () => ({ get: axiosGetMock }));
|
||||
jest.doMock('url', () => ({ fileURLToPath: jest.fn(() => expectedPath) }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const { readFile } = require('fs/promises');
|
||||
|
||||
const pacSource = 'file:///Users/test/proxy.pac';
|
||||
const wrapper = await getPacResolver({ pacSource });
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8');
|
||||
expect(axiosGetMock).not.toHaveBeenCalled();
|
||||
expect(createPacResolverMock).toHaveBeenCalledWith(expect.any(Object), pacScript);
|
||||
|
||||
const directives = await wrapper.resolve('http://foo.example/');
|
||||
expect(directives).toEqual(['PROXY p.example:8080']);
|
||||
});
|
||||
|
||||
test('resolves Windows file:// URL to correct OS path', async () => {
|
||||
const pacScript = 'function FindProxyForURL(url, host) { return "DIRECT"; }';
|
||||
mockFsReadSuccess(pacScript);
|
||||
setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
jest.doMock('url', () => ({
|
||||
fileURLToPath: jest.fn(() => 'C:\\Users\\test\\proxy.pac')
|
||||
}));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const { readFile } = require('fs/promises');
|
||||
const { fileURLToPath } = require('url');
|
||||
|
||||
await getPacResolver({ pacSource: 'file:///C:/Users/test/proxy.pac' });
|
||||
|
||||
expect(fileURLToPath).toHaveBeenCalledWith('file:///C:/Users/test/proxy.pac');
|
||||
expect(readFile).toHaveBeenCalledWith('C:\\Users\\test\\proxy.pac', 'utf8');
|
||||
});
|
||||
|
||||
test('rejects when file:// PAC file does not exist', async () => {
|
||||
const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' });
|
||||
mockFsReadError(err);
|
||||
jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() }));
|
||||
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
await expect(getPacResolver({ pacSource: 'file:///nonexistent/proxy.pac' })).rejects.toThrow('no such file or directory');
|
||||
});
|
||||
|
||||
test('caches resolver for file:// URL and reads file only once', async () => {
|
||||
mockFsReadSuccess('script');
|
||||
const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
const { readFile } = require('fs/promises');
|
||||
|
||||
const pacSource = 'file:///Users/test/proxy.pac';
|
||||
const w1 = await getPacResolver({ pacSource });
|
||||
const w2 = await getPacResolver({ pacSource });
|
||||
|
||||
expect(w1).toBe(w2);
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(createPacResolverMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not create https.Agent for file:// URL', async () => {
|
||||
mockFsReadSuccess('script');
|
||||
setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const mockAgentConstructor = jest.fn();
|
||||
jest.doMock('https', () => ({ Agent: mockAgentConstructor }));
|
||||
|
||||
const { getPacResolver } = require('./pac-resolver');
|
||||
await getPacResolver({ pacSource: 'file:///Users/test/proxy.pac' });
|
||||
|
||||
expect(mockAgentConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearPacCache clears entries by prefix and entirely', async () => {
|
||||
mockAxiosSuccess('script');
|
||||
setupPacMocks(async () => 'DIRECT');
|
||||
|
||||
const { getPacResolver, _CACHE, clearPacCache } = require('./pac-resolver');
|
||||
await getPacResolver({ pacSource: 'http://one/pac' });
|
||||
await getPacResolver({ pacSource: 'http://two/pac' });
|
||||
|
||||
expect(_CACHE.size).toBeGreaterThanOrEqual(2);
|
||||
|
||||
clearPacCache('url:http://one');
|
||||
for (const key of Array.from(_CACHE.keys()) as string[]) {
|
||||
expect(key.startsWith('url:http://one')).toBe(false);
|
||||
}
|
||||
|
||||
clearPacCache();
|
||||
expect(_CACHE.size).toBe(0);
|
||||
});
|
||||
});
|
||||
118
packages/bruno-requests/src/utils/pac-resolver.ts
Normal file
118
packages/bruno-requests/src/utils/pac-resolver.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import axios from 'axios';
|
||||
import crypto from 'node:crypto';
|
||||
import { readFile } from 'fs/promises';
|
||||
import https, { type AgentOptions } from 'https';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createPacResolver } from 'pac-resolver';
|
||||
import { getQuickJS } from 'quickjs-emscripten';
|
||||
|
||||
const CACHE = new Map<string, { wrapper: Promise<PacWrapper>; ts: number }>();
|
||||
|
||||
type TlsOptions = {
|
||||
ca?: string | string[];
|
||||
rejectUnauthorized?: boolean;
|
||||
minVersion?: string;
|
||||
};
|
||||
|
||||
export type PacWrapper = {
|
||||
resolve: (url: string) => Promise<string[]>;
|
||||
};
|
||||
|
||||
async function downloadPac(pacSource: string, tlsOptions: TlsOptions, timeoutMs: number): Promise<string> {
|
||||
if (pacSource.startsWith('file://')) {
|
||||
return readFile(fileURLToPath(pacSource), 'utf8');
|
||||
}
|
||||
|
||||
const config: Record<string, any> = {
|
||||
timeout: timeoutMs,
|
||||
proxy: false,
|
||||
responseType: 'text',
|
||||
maxRedirects: 3
|
||||
};
|
||||
|
||||
if (pacSource.startsWith('https://')) {
|
||||
const agentOpts: AgentOptions = {
|
||||
ca: tlsOptions.ca,
|
||||
rejectUnauthorized: tlsOptions.rejectUnauthorized,
|
||||
minVersion: tlsOptions.minVersion as AgentOptions['minVersion']
|
||||
};
|
||||
config.httpsAgent = new https.Agent(agentOpts);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(pacSource, config);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
if (err.response) throw new Error(`Failed to fetch PAC (${err.response.status})`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type GetPacResolverParams = {
|
||||
pacSource: string;
|
||||
httpsAgentRequestFields?: TlsOptions;
|
||||
opts?: { cacheTtlMs?: number; timeoutMs?: number };
|
||||
};
|
||||
|
||||
export async function getPacResolver({ pacSource, httpsAgentRequestFields = {}, opts = {} }: GetPacResolverParams): Promise<PacWrapper> {
|
||||
if (!pacSource) throw new Error('pacSource must be provided');
|
||||
|
||||
const cacheTtlMs = opts.cacheTtlMs ?? 5 * 60 * 1000;
|
||||
let key: string;
|
||||
if (pacSource.startsWith('https://')) {
|
||||
const caRaw = httpsAgentRequestFields.ca;
|
||||
const caHash = caRaw
|
||||
? crypto.createHash('sha256').update(Array.isArray(caRaw) ? caRaw.join('|') : caRaw).digest('hex').slice(0, 16)
|
||||
: '';
|
||||
key = `url:${pacSource}|ca:${caHash}|ru:${httpsAgentRequestFields.rejectUnauthorized ?? ''}|mv:${httpsAgentRequestFields.minVersion ?? ''}`;
|
||||
} else {
|
||||
// file:// and http:// — no TLS options involved in fetching
|
||||
key = `url:${pacSource}`;
|
||||
}
|
||||
const now = Date.now();
|
||||
const cached = CACHE.get(key);
|
||||
if (cached && now - cached.ts < cacheTtlMs) return cached.wrapper;
|
||||
|
||||
const wrapperPromise: Promise<PacWrapper> = (async () => {
|
||||
const script = await downloadPac(pacSource, httpsAgentRequestFields, opts.timeoutMs ?? 5000);
|
||||
|
||||
// pac-resolver v7 uses QuickJS WASM sandbox — not affected by CVE GHSA-9j49-mfvp-vmhm (<v5)
|
||||
const qjs = await getQuickJS();
|
||||
const resolverFn = createPacResolver(qjs, script);
|
||||
|
||||
return {
|
||||
resolve: async (url: string) => {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = await resolverFn(url, host);
|
||||
if (!out || typeof out !== 'string') return [];
|
||||
return out.split(';').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
CACHE.set(key, { wrapper: wrapperPromise, ts: now });
|
||||
|
||||
try {
|
||||
return await wrapperPromise;
|
||||
} catch (err) {
|
||||
CACHE.delete(key);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPacCache(keyPrefix?: string): void {
|
||||
if (!keyPrefix) {
|
||||
CACHE.clear();
|
||||
return;
|
||||
}
|
||||
for (const key of Array.from(CACHE.keys())) {
|
||||
if (key.startsWith(keyPrefix)) CACHE.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export const _CACHE = CACHE;
|
||||
@@ -200,6 +200,63 @@ describe('transformProxyConfig', () => {
|
||||
|
||||
expect(result).toEqual(newConfig);
|
||||
});
|
||||
|
||||
// Backward compat: old manual users have no source field — pass through unchanged
|
||||
test('should not modify new format without source field (backward compat: treated as manual)', () => {
|
||||
const newConfig = {
|
||||
inherit: false,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: { username: 'user', password: 'pass' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformProxyConfig(newConfig);
|
||||
|
||||
expect(result).toEqual(newConfig);
|
||||
expect((result as any).source).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not modify new format with source: manual', () => {
|
||||
const newConfig = {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
pac: { source: '' },
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: { username: 'user', password: 'pass' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformProxyConfig(newConfig);
|
||||
|
||||
expect(result).toEqual(newConfig);
|
||||
});
|
||||
|
||||
test('should not modify new format with source: pac', () => {
|
||||
const newConfig = {
|
||||
inherit: false,
|
||||
source: 'pac',
|
||||
pac: { source: 'http://internal/proxy.pac' },
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: { username: '', password: '' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformProxyConfig(newConfig);
|
||||
|
||||
expect(result).toEqual(newConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
6
tests/proxy/pac/fixtures/collection/bruno.json
Normal file
6
tests/proxy/pac/fixtures/collection/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "pac-proxy-test",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
21
tests/proxy/pac/fixtures/collection/direct.bru
Normal file
21
tests/proxy/pac/fixtures/collection/direct.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: direct
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:19000/direct
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("request bypassed proxy (PAC returned DIRECT)", function() {
|
||||
expect(res.headers['x-proxied']).to.be.undefined;
|
||||
});
|
||||
}
|
||||
21
tests/proxy/pac/fixtures/collection/proxied.bru
Normal file
21
tests/proxy/pac/fixtures/collection/proxied.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: proxied
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:19000/proxied
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("request was routed through PAC proxy", function() {
|
||||
expect(res.headers['x-proxied']).to.equal('test-proxy');
|
||||
});
|
||||
}
|
||||
6
tests/proxy/pac/fixtures/pac-files/test.pac
Normal file
6
tests/proxy/pac/fixtures/pac-files/test.pac
Normal file
@@ -0,0 +1,6 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
if (url.indexOf('/proxied') !== -1) {
|
||||
return 'PROXY localhost:18888';
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
17
tests/proxy/pac/init-user-data/preferences.json
Normal file
17
tests/proxy/pac/init-user-data/preferences.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": ["{{projectRoot}}/tests/proxy/pac/fixtures/collection"],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
},
|
||||
"proxy": {
|
||||
"source": "pac",
|
||||
"pac": {
|
||||
"source": "{{pacUrl}}"
|
||||
},
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
tests/proxy/pac/pac-proxy.spec.ts
Normal file
68
tests/proxy/pac/pac-proxy.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { test } from '../../../playwright';
|
||||
import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page';
|
||||
import { startServers, stopServers, PAC_PORT, type TestServers } from './server';
|
||||
|
||||
test.describe('PAC Proxy', () => {
|
||||
let servers: TestServers;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
servers = await startServers();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (servers) {
|
||||
await stopServers(servers);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verifies end-to-end PAC proxy resolution:
|
||||
*
|
||||
* - The PAC file routes /proxied paths to the local test proxy (port 18888).
|
||||
* - The local test proxy injects `x-proxied: test-proxy` into every response.
|
||||
* - /direct paths are returned DIRECT — no proxy header added.
|
||||
*
|
||||
* Both assertions live inside the collection's `tests {}` blocks, so
|
||||
* validateRunnerResults confirms the full flow passed.
|
||||
*/
|
||||
test('routes requests per PAC directive (PROXY and DIRECT) via HTTP URL', async ({ launchElectronApp }) => {
|
||||
const pacUrl = `http://localhost:${PAC_PORT}/test.pac`;
|
||||
const initUserDataPath = path.join(__dirname, 'init-user-data');
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { pacUrl } });
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await setSandboxMode(page, 'pac-proxy-test', 'developer');
|
||||
await runCollection(page, 'pac-proxy-test');
|
||||
await validateRunnerResults(page, {
|
||||
totalRequests: 2,
|
||||
passed: 2,
|
||||
failed: 0,
|
||||
skipped: 0
|
||||
});
|
||||
});
|
||||
|
||||
test('routes requests via file:// PAC URL', async ({ launchElectronApp }) => {
|
||||
// Compute the file:// URL at runtime so it is correct on every OS:
|
||||
// Mac/Linux → file:///abs/path/to/test.pac
|
||||
// Windows → file:///C:/abs/path/to/test.pac
|
||||
const pacUrl = pathToFileURL(path.join(__dirname, 'fixtures', 'pac-files', 'test.pac')).href;
|
||||
const initUserDataPath = path.join(__dirname, 'init-user-data');
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { pacUrl } });
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await setSandboxMode(page, 'pac-proxy-test', 'developer');
|
||||
await runCollection(page, 'pac-proxy-test');
|
||||
await validateRunnerResults(page, {
|
||||
totalRequests: 2,
|
||||
passed: 2,
|
||||
failed: 0,
|
||||
skipped: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
113
tests/proxy/pac/server/index.ts
Normal file
113
tests/proxy/pac/server/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export const PAC_PORT = 18080;
|
||||
export const PROXY_PORT = 18888;
|
||||
export const TARGET_PORT = 19000;
|
||||
|
||||
export interface TestServers {
|
||||
pacServer: http.Server;
|
||||
proxyServer: http.Server;
|
||||
targetServer: http.Server;
|
||||
}
|
||||
|
||||
/** Serves .pac files from the pac-files/ directory. */
|
||||
function createPacServer(): Promise<http.Server> {
|
||||
const pacDir = path.join(__dirname, 'pac-files');
|
||||
const server = http.createServer((req, res) => {
|
||||
const filename = (req.url ?? '/').replace(/^\//, '') || 'test.pac';
|
||||
const filepath = path.join(pacDir, filename);
|
||||
fs.readFile(filepath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
return listen(server, PAC_PORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain HTTP proxy. Forwards requests and injects `x-proxied: test-proxy`
|
||||
* into every response so tests can confirm traffic went through it.
|
||||
*/
|
||||
function createProxyServer(): Promise<http.Server> {
|
||||
const server = http.createServer((clientReq, clientRes) => {
|
||||
let targetUrl: URL;
|
||||
try {
|
||||
targetUrl = new URL(clientReq.url!);
|
||||
} catch {
|
||||
clientRes.writeHead(400);
|
||||
clientRes.end('Bad request URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const options: http.RequestOptions = {
|
||||
hostname: targetUrl.hostname,
|
||||
port: targetUrl.port || 80,
|
||||
path: targetUrl.pathname + targetUrl.search,
|
||||
method: clientReq.method,
|
||||
headers: { ...clientReq.headers, host: targetUrl.host }
|
||||
};
|
||||
delete (options.headers as Record<string, string>)['proxy-connection'];
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
const headers = { ...proxyRes.headers, 'x-proxied': 'test-proxy' };
|
||||
clientRes.writeHead(proxyRes.statusCode!, headers);
|
||||
proxyRes.pipe(clientRes);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(502);
|
||||
}
|
||||
clientRes.end(`Proxy error: ${err.message}`);
|
||||
});
|
||||
|
||||
clientReq.pipe(proxyReq);
|
||||
});
|
||||
return listen(server, PROXY_PORT);
|
||||
}
|
||||
|
||||
/** Simple JSON echo server — the requests' target. */
|
||||
function createTargetServer(): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, path: req.url }));
|
||||
});
|
||||
return listen(server, TARGET_PORT);
|
||||
}
|
||||
|
||||
function listen(server: http.Server, port: number): Promise<http.Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(port, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
export async function startServers(): Promise<TestServers> {
|
||||
const [pacServer, proxyServer, targetServer] = await Promise.all([
|
||||
createPacServer(),
|
||||
createProxyServer(),
|
||||
createTargetServer()
|
||||
]);
|
||||
return { pacServer, proxyServer, targetServer };
|
||||
}
|
||||
|
||||
export async function stopServers(servers: TestServers): Promise<void> {
|
||||
await Promise.all([
|
||||
close(servers.pacServer),
|
||||
close(servers.proxyServer),
|
||||
close(servers.targetServer)
|
||||
]);
|
||||
}
|
||||
7
tests/proxy/pac/server/pac-files/test.pac
Normal file
7
tests/proxy/pac/server/pac-files/test.pac
Normal file
@@ -0,0 +1,7 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
// Route requests whose path starts with /proxied through the local test proxy
|
||||
if (url.indexOf("/proxied") > -1) {
|
||||
return "PROXY 127.0.0.1:18888";
|
||||
}
|
||||
return "DIRECT";
|
||||
}
|
||||
Reference in New Issue
Block a user