ca certs fixes and tests (#5429)

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
This commit is contained in:
lohit
2025-09-07 23:06:44 +05:30
committed by GitHub
parent 1bc7a1f655
commit 3c656270b3
57 changed files with 1853 additions and 50 deletions

View File

@@ -0,0 +1,26 @@
name: 'Setup Node Dependencies'
description: 'Install Node.js and npm dependencies'
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: v22.17.0
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
shell: bash
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore

View File

@@ -0,0 +1,36 @@
name: 'Run Basic SSL CLI Tests - Linux'
description: 'Run basic SSL CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to basic SSL test collection directory
cd tests/ssl/basic-ssl/collections/badssl
echo "basic ssl success"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "with default/system ca certs"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
# navigate to self-signed SSL test collection directory
cd ../self-signed-badssl
echo "self-signed ssl with validation disabled"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
echo "self-signed ssl with default/system ca certs"
echo "request will error"
# should fail
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,33 @@
name: 'Run Custom CA Certs CLI Tests - Linux'
description: 'Run custom CA certs CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to CA certificates test collection directory
cd tests/ssl/custom-ca-certs/collection
echo "custom valid ca cert"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "custom valid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert"
echo "request will error"
# should fail
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,19 @@
name: 'Run SSL E2E Tests - Linux'
description: 'Run SSL E2E tests on Linux'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-linux
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,26 @@
name: 'Setup CA Certificates - Linux'
description: 'Setup CA certificates and start test server for custom CA certs tests on Linux'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "starting server in background"
node index.js &
echo "server started with PID: $!"

View File

@@ -0,0 +1,14 @@
name: 'Setup Custom CA Certs Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for custom CA certs tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for custom CA certs
shell: bash
run: |
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb libxml2-utils
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox

View File

@@ -0,0 +1,36 @@
name: 'Run Basic SSL CLI Tests - macOS'
description: 'Run basic SSL CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to basic SSL test collection directory
cd tests/ssl/basic-ssl/collections/badssl
echo "basic ssl success"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "with default/system ca certs"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
# navigate to self-signed SSL test collection directory
cd ../self-signed-badssl
echo "self-signed ssl with validation disabled"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
echo "self-signed ssl with default/system ca certs"
echo "request will error"
# should fail
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,33 @@
name: 'Run Custom CA Certs CLI Tests - macOS'
description: 'Run custom CA certs CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to CA certificates test collection directory
cd tests/ssl/custom-ca-certs/collection
echo "custom valid ca cert"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "custom valid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert"
echo "request will error"
# should fail
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,17 @@
name: 'Run SSL E2E Tests - macOS'
description: 'Run SSL E2E tests on macOS'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: bash
run: |
npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-macos
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,26 @@
name: 'Setup CA Certificates - macOS'
description: 'Setup CA certificates and start test server for custom CA certs tests on macOS'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "starting server in background"
node index.js &
echo "server started with PID: $!"

View File

@@ -0,0 +1,9 @@
name: 'Setup Custom CA Certs Feature Dependencies - macOS'
description: 'Setup feature-specific dependencies for custom CA certs tests on macOS'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for custom CA certs
shell: bash
run: |
brew install libxml2

View File

@@ -0,0 +1,50 @@
name: 'Run Basic SSL CLI Tests - Windows'
description: 'Run basic SSL CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# navigate to basic SSL test collection directory
Set-Location tests\ssl\basic-ssl\collections\badssl
Write-Host "basic ssl success"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml1 = Get-Content junit1.xml
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount1 -ne 1) { exit 1 }
Write-Host "with default/system ca certs"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml2 = Get-Content junit2.xml
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount2 -ne 1) { exit 1 }
# navigate to self-signed SSL test collection directory
Set-Location ..\self-signed-badssl
Write-Host "self-signed ssl with validation disabled"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml3 = Get-Content junit3.xml
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount3 -ne 1) { exit 1 }
Write-Host "self-signed ssl with default/system ca certs"
Write-Host "request will error"
# should fail
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
# Ignore the exit code - we expect this to fail
[xml]$xml4 = Get-Content junit4.xml
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
if ($errorCount4 -ne 1) { exit 1 }

View File

@@ -0,0 +1,47 @@
name: 'Run Custom CA Certs CLI Tests - Windows'
description: 'Run custom CA certs CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# navigate to CA certificates test collection directory
Set-Location tests\ssl\custom-ca-certs\collection
Write-Host "custom valid ca cert"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --cacert ..\server\certs\ca-cert.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml1 = Get-Content junit1.xml
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount1 -ne 1) { exit 1 }
Write-Host "custom valid ca cert with defaults"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --cacert ..\server\certs\ca-cert.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml2 = Get-Content junit2.xml
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount2 -ne 1) { exit 1 }
Write-Host "custom invalid ca cert"
Write-Host "request will error"
# should fail
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --cacert ..\server\certs\ca-key.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
# Ignore the exit code - we expect this to fail
[xml]$xml3 = Get-Content junit3.xml
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
if ($errorCount3 -ne 1) { exit 1 }
Write-Host "custom invalid ca cert with defaults"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --cacert ..\server\certs\ca-key.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml4 = Get-Content junit4.xml
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount4 -ne 1) { exit 1 }

View File

@@ -0,0 +1,17 @@
name: 'Run SSL E2E Tests - Windows'
description: 'Run SSL E2E tests on Windows'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: pwsh
run: |
npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-windows
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,25 @@
name: 'Setup CA Certificates - Windows'
description: 'Setup CA certificates and start test server for custom CA certs tests on Windows'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Set-Location tests\ssl\custom-ca-certs\server
Write-Host "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location tests\ssl\custom-ca-certs\server
Write-Host "starting server in background"
Start-Process -FilePath "node" -ArgumentList "index.js" -PassThru -WindowStyle Hidden

91
.github/workflows/ssl-tests.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: SSL Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests-for-linux:
name: SSL Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/linux/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
tests-for-macos:
name: SSL Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/macos/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
tests-for-windows:
name: SSL Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/windows/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests

View File

@@ -65,7 +65,8 @@
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
},

View File

@@ -25,7 +25,7 @@ const { createFormData } = require('../utils/form-data');
const { getOAuth2Token } = require('./oauth2');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { addDigestInterceptor, getCACertificates } = require('@usebruno/requests');
const { encodeUrl } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
@@ -151,21 +151,16 @@ const runSingleRequest = async function (
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const httpsAgentRequestFields = {};
if (insecure) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
} else {
const caCertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS];
const caCert = caCertArray.find((el) => el);
if (caCert && caCert.length > 1) {
try {
let caCertBuffer = fs.readFileSync(caCert);
if (!options['ignoreTruststore']) {
caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
}
httpsAgentRequestFields['ca'] = caCertBuffer;
} catch (err) {
console.log('Error reading CA cert file:' + caCert, err);
}
const caCertArray = [options['cacert'], process.env.SSL_CERT_FILE];
const caCertFilePath = caCertArray.find((el) => el);
let caCertificatesWithCertType = getCACertificates({ caCertFilePath, shouldKeepDefaultCerts: !options['ignoreTruststore'] });
let caCertificates = caCertificatesWithCertType.map(certData => certData.certificate);
if (caCertificates?.length > 0) {
httpsAgentRequestFields['ca'] = caCertificates;
}
}

View File

@@ -1,7 +1,7 @@
const fs = require('fs');
const tls = require('tls');
const fs = require('node:fs');
const path = require('path');
const { get } = require('lodash');
const { getCACertificates } = require('@usebruno/requests');
const { preferencesUtil } = require('../../store/preferences');
const { getBrunoConfig } = require('../../store/bruno-config');
const { interpolateString } = require('./interpolate-string');
@@ -26,15 +26,28 @@ const getCertsAndProxyConfig = async ({
httpsAgentRequestFields['rejectUnauthorized'] = false;
}
if (preferencesUtil.shouldUseCustomCaCertificate()) {
const caCertFilePath = preferencesUtil.getCustomCaCertificateFilePath();
if (caCertFilePath) {
let caCertBuffer = fs.readFileSync(caCertFilePath);
if (preferencesUtil.shouldKeepDefaultCaCertificates()) {
caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
}
httpsAgentRequestFields['ca'] = caCertBuffer;
}
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
let caCertificatesWithCertType = getCACertificates({
caCertFilePath,
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
});
let caCertificates = caCertificatesWithCertType.map(certData => certData.certificate);
let caCertificateDetails = caCertificatesWithCertType.reduce((details, certificateData) => {
// get the count for each certificate type
details[certificateData.type] += 1;
return details;
}, {
custom: 0,
bundled: 0,
system: 0,
extra: 0
});
// configure HTTPS agent with aggregated CA certificates
if (caCertificates?.length > 0) {
httpsAgentRequestFields['caCertificateDetails'] = caCertificateDetails;
httpsAgentRequestFields['ca'] = caCertificates;
}
const brunoConfig = getBrunoConfig(collectionUid);

View File

@@ -1,3 +1,4 @@
const path = require('node:path');
const _ = require('lodash');
const Store = require('electron-store');
const { isDirectory } = require('../utils/filesystem');
@@ -12,7 +13,9 @@ class LastOpenedCollections {
}
getAll() {
return this.store.get('lastOpenedCollections') || [];
let collections = this.store.get('lastOpenedCollections') || [];
collections = collections.map(collection => path.resolve(collection));
return collections;
}
add(collectionPath) {

View File

@@ -1,5 +1,5 @@
const parseUrl = require('url').parse;
const https = require('https');
const https = require('node:https');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { interpolateString } = require('../ipc/network/interpolate-string');
const { SocksProxyAgent } = require('socks-proxy-agent');
@@ -87,6 +87,10 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
function createTimelineAgentClass(BaseAgentClass) {
return class extends BaseAgentClass {
constructor(options, timeline) {
let caCertificateDetails = options.caCertificateDetails || {};
delete options.caCertificateDetails;
// For proxy agents, the first argument is the proxy URI and the second is options
if (options?.proxy) {
const { proxy: proxyUri, ...agentOptions } = options;
@@ -118,7 +122,7 @@ function createTimelineAgentClass(BaseAgentClass) {
const tlsOptions = {
...options,
rejectUnauthorized: options.rejectUnauthorized ?? true,
};
};
super(tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
@@ -131,6 +135,8 @@ function createTimelineAgentClass(BaseAgentClass) {
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`,
});
}
this.caCertificateDetails = caCertificateDetails;
}
@@ -146,20 +152,16 @@ function createTimelineAgentClass(BaseAgentClass) {
});
}
// Log CAfile and CApath (if possible)
if (this.caProvided) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `CA certificates provided`,
});
} else {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `Using system default CA certificates`,
});
}
const bundledCerts = this.caCertificateDetails.bundled || 0;
const systemCerts = this.caCertificateDetails.system || 0;
const extraCerts = this.caCertificateDetails.extra || 0;
const customCerts = this.caCertificateDetails.custom || 0;
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `CA Certificates: ${bundledCerts} bundled, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`,
});
// Log "Trying host:port..."
this.timeline.push({

View File

@@ -2,6 +2,6 @@ export { addDigestInterceptor, getOAuth2Token } from './auth';
export { GrpcClient, generateGrpcSampleMessage } from './grpc';
export { default as cookies } from './cookies';
export * as network from './network';
export { getCACertificates } from './network';
export * as scripting from './scripting';

View File

@@ -0,0 +1,165 @@
import * as fs from 'node:fs';
import { spawnSync } from 'node:child_process';
type T_CACertSource = 'bundled' | 'system' | 'extra'
type T_CACertificateData = {
type: T_CACertSource | 'custom';
certificate: string
}
/**
* Safely executes tls.getCACertificates in a separate Node.js process
* Returns empty array if the process fails or exits
*/
const safeTlsGetCACertificates = (certType: T_CACertSource): string[] => {
try {
// adding seperate script for each cert type
// to make sure no unexpected code can be included in the script
const getBundledCACertificatesScript = `
const tls = require('node:tls');
try {
const result = tls.getCACertificates('bundled');
console.log(JSON.stringify(result || []));
} catch (error) {
console.log('[]');
}
`;
const getSystemCACertificatesScript = `
const tls = require('node:tls');
try {
const result = tls.getCACertificates('system');
console.log(JSON.stringify(result || []));
} catch (error) {
console.log('[]');
}
`;
const getExtraCACertificatesScript = `
const tls = require('node:tls');
try {
const result = tls.getCACertificates('extra');
console.log(JSON.stringify(result || []));
} catch (error) {
console.log('[]');
}
`;
// bundled
let script = getBundledCACertificatesScript;
// system
if (certType === 'system') script = getSystemCACertificatesScript;
// extra
if (certType === 'extra') script = getExtraCACertificatesScript;
const result = spawnSync('node', ['-e', script], {
encoding: 'utf8',
timeout: 5000, // 5 second timeout
stdio: 'pipe',
maxBuffer: 1024 * 1024 * 50
});
if (result.error || result.status !== 0) {
return [];
}
const output = result.stdout.trim();
return JSON.parse(output);
} catch (error) {
// Return empty array if child process fails
return [];
}
};
/**
* retrieves default CA certificates from multiple sources using Node.js TLS API
*
* this function aggregates CA certificates from three sources:
* - 'bundled': mozilla CA certificates bundled with Node.js (same as tls.rootCertificates)
* - 'system': CA certificates from the system's trusted certificate store
* - 'extra': additional CA certificates loaded from `NODE_EXTRA_CA_CERTS` environment variable
*
* @returns {string[]} Array of PEM-encoded CA certificate strings
* @see https://nodejs.org/docs/latest-v22.x/api/tls.html#tlsgetcacertificatestype
*/
const getCerts = (sources: T_CACertSource[] = ['bundled', 'system', 'extra']): T_CACertificateData[] => {
let certificates: T_CACertificateData[] = [];
// iterate through different certificate store types to build comprehensive CA list
(sources).forEach(certType => {
try {
// get certificates from specific store type
const certList = safeTlsGetCACertificates(certType);
if (certList && Array.isArray(certList)) {
// filter out empty/invalid certificates to ensure we only include valid data
const validCertificates = certList.filter(cert => cert && cert.trim());
const validCertificatesWithCertType = validCertificates.map(certificate => ({
type: certType,
certificate
}));
certificates.push(...validCertificatesWithCertType);
}
} catch (err) {
console.warn(`Failed to load ${certType} CA certificates:`, (err as Error).message);
}
});
return certificates;
};
const getCACertificates = ({ caCertFilePath, shouldKeepDefaultCerts = true }: { caCertFilePath: string, shouldKeepDefaultCerts: boolean }) : T_CACertificateData[] => {
// CA certificate configuration
try {
let caCertificates: T_CACertificateData[] = [];
// handle user-provided custom CA certificate file with optional default certificates
if (caCertFilePath) {
// validate custom CA certificate file
if (fs.existsSync(caCertFilePath)) {
try {
const customCert = fs.readFileSync(caCertFilePath, 'utf8');
if (customCert && customCert.trim()) {
caCertificates.push({
type: 'custom',
certificate: customCert.trim()
});
}
} catch (err) {
console.error(`Failed to read custom CA certificate from ${caCertFilePath}:`, (err as Error).message);
throw new Error(`Unable to load custom CA certificate: ${(err as Error).message}`);
}
}
// optionally augment custom CA with default certificates
if (shouldKeepDefaultCerts) {
const defaultCertificates = getCerts(['bundled', 'system', 'extra']);
if (defaultCertificates?.length > 0) {
caCertificates.push(...defaultCertificates);
}
}
} else {
// use default CA certificates when no custom configuration is specified
const defaultCertificates = getCerts(['bundled', 'system', 'extra']);
if (defaultCertificates?.length > 0) {
caCertificates.push(...defaultCertificates);
}
}
return caCertificates;
} catch (err) {
console.error('Error configuring CA certificates:', (err as Error).message);
throw err; // Re-throw certificate loading errors as they're critical
}
}
export {
getCACertificates
};

View File

@@ -1 +1,3 @@
export { makeAxiosInstance } from './axios-instance';
export { makeAxiosInstance } from './axios-instance';
export { getCACertificates } from './ca-cert';

View File

@@ -1 +1 @@
v20
v22.17.0

View File

@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig } from '@playwright/test';
const reporter: any[] = [['list'], ['html']];
@@ -7,7 +7,6 @@ if (process.env.CI) {
}
export default defineConfig({
testDir: './tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
@@ -20,7 +19,15 @@ export default defineConfig({
projects: [
{
name: 'Bruno Electron App'
name: 'default',
testDir: './tests',
testIgnore: [
'ssl/**' // custom CA certificate tests require separate server setup and certificate generation
]
},
{
name: 'ssl',
testDir: './tests/ssl'
}
],

View File

@@ -4,7 +4,9 @@ const { _electron: electron } = require('playwright');
const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
exports.startApp = async () => {
const app = await electron.launch({ args: [electronAppPath] });
const app = await electron.launch({
args: [electronAppPath]
});
const context = await app.context();
app.process().stdout.on('data', (data) => {

View File

@@ -48,7 +48,7 @@ export const test = baseTest.extend<
if (initUserDataPath) {
const replacements = {
projectRoot: path.join(__dirname, '..')
projectRoot: path.posix.join(__dirname, '..')
};
for (const file of await fs.promises.readdir(initUserDataPath)) {

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "badssl",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "badssl",
"version": "1.0.0",
"description": "Bruno test collection for basic ssl testing"
}

View File

@@ -0,0 +1,15 @@
meta {
name: request
type: http
seq: 6
}
get {
url: https://www.badssl.com
body: none
auth: inherit
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "self-signed-badssl",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "self-signed-badssl",
"version": "1.0.0",
"description": "Bruno test collection for basic ssl testing"
}

View File

@@ -0,0 +1,15 @@
meta {
name: request
type: http
seq: 6
}
get {
url: https://self-signed.badssl.com
body: none
auth: inherit
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,59 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('basic ssl success', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('badssl').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/basic-ssl/collections/badssl"],
"preferences": {
"request": {
"sslVerification": true,
"customCaCertificate": {
"enabled": false,
"filePath": ""
},
"keepDefaultCaCertificates": {
"enabled": true
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/basic-ssl/collections/self-signed-badssl"],
"preferences": {
"request": {
"sslVerification": true,
"customCaCertificate": {
"enabled": false,
"filePath": ""
},
"keepDefaultCaCertificates": {
"enabled": true
}
}
}
}

View File

@@ -0,0 +1,59 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('self signed rejected', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('self-signed-badssl').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(0);
await expect(parseInt(failed)).toBe(1);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(0);
await expect(parseInt(failed)).toBe(1);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/basic-ssl/collections/self-signed-badssl"],
"preferences": {
"request": {
"sslVerification": false,
"customCaCertificate": {
"enabled": false,
"filePath": ""
},
"keepDefaultCaCertificates": {
"enabled": true
}
}
}
}

View File

@@ -0,0 +1,59 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('self signed success with validation disabled', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('self-signed-badssl').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "custom-ca-certs",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "custom-ca-certs",
"version": "1.0.0",
"description": "Bruno test collection for CA certificates and HTTPS server testing"
}

View File

@@ -0,0 +1,16 @@
meta {
name: request
type: http
seq: 6
}
get {
url: https://localhost:8090
body: none
auth: inherit
}
assert {
res.status: eq 200
res.body: eq helloworld
}

View File

@@ -0,0 +1 @@
certs

View File

@@ -0,0 +1,225 @@
const { execCommand, execCommandSilent, detectPlatform } = require('./platform');
const fs = require('node:fs');
const path = require('node:path');
function createCertsDir(certsDir) {
if (fs.existsSync(certsDir)) {
fs.rmSync(certsDir, { recursive: true, force: true });
}
fs.mkdirSync(certsDir, { recursive: true });
}
function generateCertificates(certsDir) {
execCommand('openssl version');
// Generate CA private key
execCommand('openssl genrsa -out ca-key.pem 4096', certsDir);
// Create CA configuration file with proper CA extensions and subject (LibreSSL/OpenSSL compatible)
const caConfigContent = `[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[req_distinguished_name]
C = US
ST = Dev
L = Local
O = Local Dev CA
CN = Local Dev CA
[v3_ca]
basicConstraints = critical, CA:TRUE
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always`;
fs.writeFileSync(path.join(certsDir, 'ca.conf'), caConfigContent);
// Generate CA certificate with proper CA extensions using config file (no -subj needed)
execCommand('openssl req -new -x509 -key ca-key.pem -out ca-cert.pem -days 3650 -config ca.conf', certsDir);
// Generate server private key and CSR
execCommand('openssl genrsa -out localhost-key.pem 4096', certsDir);
// Create server CSR configuration file
const serverCsrConfigContent = `[req]
distinguished_name = req_distinguished_name
prompt = no
[req_distinguished_name]
C = US
ST = Dev
L = Local
O = Local Dev
CN = localhost`;
fs.writeFileSync(path.join(certsDir, 'localhost-csr.conf'), serverCsrConfigContent);
execCommand('openssl req -new -key localhost-key.pem -out localhost.csr -config localhost-csr.conf', certsDir);
// Create server certificate configuration file (LibreSSL/OpenSSL compatible)
const serverConfigContent = `[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
C = Country Name
ST = State or Province Name
L = Locality Name
O = Organization Name
CN = Common Name
[v3_req]
keyUsage = critical, keyEncipherment, dataEncipherment, digitalSignature
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
basicConstraints = critical, CA:FALSE
authorityKeyIdentifier = keyid:always,issuer:always
[alt_names]
DNS.1 = localhost
DNS.2 = localhost.localdomain
IP.1 = 127.0.0.1
IP.2 = ::1
IP.3 = ::ffff:127.0.0.1`;
fs.writeFileSync(path.join(certsDir, 'localhost.conf'), serverConfigContent);
execCommand('openssl x509 -req -in localhost.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out localhost-cert.pem -days 730 -extensions v3_req -extfile localhost.conf', certsDir);
const platform = detectPlatform();
if (platform === 'windows') {
execCommand('openssl x509 -in ca-cert.pem -outform DER -out ca-cert.der', certsDir);
execCommand('openssl pkcs12 -export -out localhost.p12 -inkey localhost-key.pem -in localhost-cert.pem -certfile ca-cert.pem -password pass:', certsDir);
execCommand('openssl x509 -in localhost-cert.pem -outform DER -out localhost-cert.der', certsDir);
}
if (platform !== 'windows') {
execCommand('chmod 600 ca-key.pem localhost-key.pem', certsDir);
execCommand('chmod 644 ca-cert.pem localhost-cert.pem', certsDir);
}
['localhost.csr', 'localhost.conf', 'localhost-csr.conf', 'ca.conf', 'ca-cert.srl'].forEach(file => {
const filePath = path.join(certsDir, file);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
});
// Validate certificate chain
validateCertificateChain(certsDir);
}
function validateCertificateChain(certsDir) {
try {
// Verify CA certificate is valid and has proper CA extensions
const caVerifyOutput = execCommandSilent('openssl x509 -in ca-cert.pem -text -noout', certsDir).toString();
if (!caVerifyOutput.includes('CA:TRUE')) {
throw new Error('CA certificate missing basicConstraints=CA:TRUE');
}
if (!caVerifyOutput.includes('Certificate Sign')) {
throw new Error('CA certificate missing keyCertSign in keyUsage');
}
// Verify server certificate is valid and signed by CA
const serverVerifyOutput = execCommandSilent('openssl x509 -in localhost-cert.pem -text -noout', certsDir).toString();
if (!serverVerifyOutput.includes('CA:FALSE')) {
throw new Error('Server certificate should have basicConstraints=CA:FALSE');
}
if (!serverVerifyOutput.includes('TLS Web Server Authentication')) {
throw new Error('Server certificate missing serverAuth in extendedKeyUsage');
}
// Verify certificate chain
execCommandSilent('openssl verify -CAfile ca-cert.pem localhost-cert.pem', certsDir);
console.log('✅ Certificate chain validation passed');
} catch (error) {
console.error('❌ Certificate validation failed:', error.message);
throw new Error(`Certificate validation failed: ${error.message}`);
}
}
function addCAToTruststore(certsDir) {
const platform = detectPlatform();
switch (platform) {
case 'macos': {
const macCertPath = path.join(certsDir, 'ca-cert.pem');
execCommand(`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${macCertPath}"`);
break;
}
case 'linux': {
const linuxCertPath = path.join(certsDir, 'ca-cert.pem');
execCommand(`sudo cp "${linuxCertPath}" /usr/local/share/ca-certificates/bruno-ca.crt`);
execCommand('sudo update-ca-certificates');
break;
}
case 'windows': {
const winCertPath = path.join(certsDir, 'ca-cert.der');
// Escape backslashes for PowerShell
const psPath = winCertPath.replace(/\\/g, '\\\\');
// PowerShell .NET method (works reliably in CI)
const psCommand = [
`$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2('${psPath}');`,
`$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine');`,
`$store.Open('ReadWrite');`,
`$store.Add($cert);`,
`$store.Close();`,
// Verify cert was added by checking if it exists in LocalMachine\Root
`$verifyStore = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine');`,
`$verifyStore.Open('ReadOnly');`,
`$found = $verifyStore.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint };`,
`$verifyStore.Close();`,
`if (-not $found) { throw 'Certificate was not added to LocalMachine\Root' };`
].join(' ');
execCommand(`powershell -Command "${psCommand}"`);
break;
}
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
function verifyCertificates(certsDir) {
const platform = detectPlatform();
// Core PEM files required for all platforms
const requiredFiles = ['ca-cert.pem', 'ca-key.pem', 'localhost-cert.pem', 'localhost-key.pem'];
// Verify required PEM files exist
for (const file of requiredFiles) {
const filePath = path.join(certsDir, file);
if (!fs.existsSync(filePath)) {
throw new Error(`missing certificate file: ${file}`);
}
}
// Check Windows-specific files but don't require them (they're optional fallbacks)
if (platform === 'windows') {
const windowsFiles = ['ca-cert.der', 'localhost.p12', 'localhost-cert.der'];
for (const file of windowsFiles) {
const filePath = path.join(certsDir, file);
if (fs.existsSync(filePath)) {
console.log(`✅ Windows certificate file available: ${file}`);
} else {
console.log(`⚠️ Windows certificate file missing (but not required): ${file}`);
}
}
}
}
module.exports = {
createCertsDir,
generateCertificates,
addCAToTruststore,
verifyCertificates
};

View File

@@ -0,0 +1,60 @@
const { execSync } = require('node:child_process');
const os = require('node:os');
function execCommand(command, cwd = process.cwd()) {
return execSync(command, {
cwd,
stdio: 'inherit',
timeout: 30000
});
}
function execCommandSilent(command, cwd = process.cwd()) {
return execSync(command, {
cwd,
stdio: 'pipe',
timeout: 30000
});
}
function detectPlatform() {
const platform = os.platform();
switch (platform) {
case 'darwin': return 'macos';
case 'linux': return 'linux';
case 'win32': return 'windows';
default: throw new Error(`Unsupported platform: ${platform}`);
}
}
function killProcessOnPort(port) {
const platform = detectPlatform();
try {
switch (platform) {
case 'macos':
execCommand(`lsof -ti :${port} | xargs kill -9`);
break;
case 'linux':
execCommand(`lsof -ti :${port} | xargs kill -9`);
break;
case 'windows':
const result = execCommandSilent(`netstat -ano | findstr :${port}`);
const lines = result.toString().split('\n');
for (const line of lines) {
const match = line.trim().match(/\s+(\d+)$/);
if (match) {
execCommandSilent(`taskkill /F /PID ${match[1]}`);
}
}
break;
}
} catch (error) {}
}
module.exports = {
execCommand,
execCommandSilent,
detectPlatform,
killProcessOnPort
};

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
const path = require('node:path');
const fs = require('node:fs');
const https = require('node:https');
const { killProcessOnPort } = require('./helpers/platform');
function createServer(certsDir, port = 8090) {
const serverOptions = {
key: fs.readFileSync(path.join(certsDir, 'localhost-key.pem')),
cert: fs.readFileSync(path.join(certsDir, 'localhost-cert.pem')),
ca: fs.readFileSync(path.join(certsDir, 'ca-cert.pem'))
}
const server = https.createServer(serverOptions, (req, res) => {
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
res.end('helloworld');
});
return new Promise((resolve, reject) => {
server.listen(port, (error) => {
if (error) {
reject(error);
} else {
resolve(server);
}
});
});
}
function shutdownServer(server, cleanup) {
const shutdown = (signal) => {
console.log(`🛑 Received ${signal}, shutting down`);
if (cleanup) cleanup();
if (server) {
server.close(() => process.exit(0));
} else {
process.exit(0);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
async function startServer() {
const certsDir = path.join(__dirname, 'certs');
const port = 8090;
console.log('🚀 Starting HTTPS test server');
try {
killProcessOnPort(port);
console.log(`🌐 Creating server on port ${port}`);
const server = await createServer(certsDir, port);
shutdownServer(server, () => {
console.log('✨ Server cleanup completed');
});
} catch (error) {
console.error('❌ Server startup failed:', error.message);
process.exit(1);
}
}
if (require.main === module) {
startServer();
}
module.exports = { startServer };

View File

@@ -0,0 +1,106 @@
# CA Certificates Test Server
A Node.js HTTPS test server with self-signed certificate generation for testing SSL/TLS connections in Bruno.
## Overview
This server provides two main functionalities:
1. **Certificate Generation** - Creates a complete CA certificate chain for testing
2. **HTTPS Server** - Runs a secure server using the generated certificates
## Usage
### 1. Generate Certificates
Generate the required CA certificates and add them to your system's truststore:
```bash
node scripts/generate-certs.js
```
This will:
- Create a `certs/` directory
- Generate CA certificate, server certificate, and private keys
- Verify the certificate chain
- Add the CA certificate to your system's truststore (macOS/Linux/Windows)
**Generated Files:**
- `certs/ca-cert.pem` - Certificate Authority certificate
- `certs/ca-key.pem` - CA private key
- `certs/localhost-cert.pem` - Server certificate for localhost
- `certs/localhost-key.pem` - Server private key
**Windows-Specific Files (automatically generated on Windows):**
- `certs/ca-cert.der` - CA certificate in DER format (for Windows certificate store)
- `certs/localhost.p12` - PKCS#12 bundle containing server certificate and key
- `certs/localhost-cert.der` - Server certificate in DER format
### Certificate Installation Details
The certificate generation script automatically adds the CA certificate to your system's truststore:
**macOS:** Uses `security add-trusted-cert` to add the CA to the System keychain
**Linux:** Copies the CA certificate to `/usr/local/share/ca-certificates/` and runs `update-ca-certificates`
**Windows:** Uses PowerShell to add the CA certificate to the LocalMachine\Root certificate store
> **Note:** On Windows, the script requires Administrator privileges to install certificates to the machine-wide certificate store. If you encounter permission issues, run your terminal as Administrator.
### 2. Run HTTPS Server
Start the HTTPS server on port 8090:
```bash
node index.js
```
The server will:
- Load certificates from the `certs/` directory
- Start an HTTPS server on `https://localhost:8090`
- Serve a simple "helloworld" response
- Handle graceful shutdown on SIGINT/SIGTERM
## Testing
Once the server is running, you can test SSL connections:
### Unix/Linux/macOS
```bash
# Test with curl
curl https://localhost:8090
# Test certificate verification
openssl s_client -connect localhost:8090 -CAfile certs/ca-cert.pem
```
### Windows
```powershell
# Test with curl (if available)
curl https://localhost:8090
# Test with PowerShell Invoke-WebRequest
Invoke-WebRequest -Uri https://localhost:8090
# Test certificate verification with OpenSSL
openssl s_client -connect localhost:8090 -CAfile certs/ca-cert.pem
# Verify certificate is installed in Windows certificate store
Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Subject -like "*Local Dev CA*" }
# Test with .NET WebClient (alternative method)
$client = New-Object System.Net.WebClient
$client.DownloadString("https://localhost:8090")
```
## File Structure
```
server/
├── index.js # Main HTTPS server
├── scripts/
│ └── generate-certs.js # Certificate generation script
├── helpers/
│ ├── certs.js # Certificate management utilities
│ └── platform.js # Platform-specific utilities
├── certs/ # Generated certificates (created by script)
└── readme.md # This file
```

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const path = require('node:path');
const {
createCertsDir,
generateCertificates,
addCAToTruststore,
verifyCertificates
} = require('../helpers/certs');
/**
* Setup CA certificates for testing server
*/
async function setup() {
console.log('🔧 Setting up CA certificates for test server');
const certsDir = path.join(__dirname, '..', 'certs');
try {
console.log('📁 Creating certificates directory');
createCertsDir(certsDir);
console.log('🔐 Generating certificates');
generateCertificates(certsDir);
console.log('✅ Verifying certificates');
verifyCertificates(certsDir);
console.log('🛡️ Adding CA to truststore');
addCAToTruststore(certsDir);
console.log('🎉 CA certificate setup completed successfully');
return true;
} catch (error) {
console.error('❌ Generate certs failed:', error.message);
throw error;
}
}
if (require.main === module) {
setup()
.then(() => process.exit(0))
.catch(() => process.exit(1));
}
module.exports = { setup };

View File

@@ -0,0 +1,57 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('custom invalid ca cert added to the config and keep default ca certs', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('custom-ca-certs').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/custom-ca-certs/collection"],
"preferences": {
"request": {
"sslVerification": true,
"customCaCertificate": {
"enabled": true,
"filePath": "{{projectRoot}}/tests/ssl/custom-ca-certs/server/certs/ca-key.pem"
},
"keepDefaultCaCertificates": {
"enabled": true
}
}
}
}

View File

@@ -0,0 +1,57 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('custom invalid ca cert added to the config and NO default ca certs', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('custom-ca-certs').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(0);
await expect(parseInt(failed)).toBe(1);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(0);
await expect(parseInt(failed)).toBe(1);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/custom-ca-certs/collection"],
"preferences": {
"request": {
"sslVerification": true,
"customCaCertificate": {
"enabled": true,
"filePath": "{{projectRoot}}/tests/ssl/custom-ca-certs/server/certs/ca-key.pem"
},
"keepDefaultCaCertificates": {
"enabled": false
}
}
}
}

View File

@@ -0,0 +1,57 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('custom valid ca cert added to the config and keep default ca certs', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('custom-ca-certs').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/custom-ca-certs/collection"],
"preferences": {
"request": {
"sslVerification": true,
"customCaCertificate": {
"enabled": true,
"filePath": "{{projectRoot}}/tests/ssl/custom-ca-certs/server/certs/ca-cert.pem"
},
"keepDefaultCaCertificates": {
"enabled": true
}
}
}
}

View File

@@ -0,0 +1,57 @@
import { test, expect } from '../../../../../playwright';
test.describe.serial('custom valid ca cert added to the config and NO default ca certs', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
// init dev mode
await page.getByText('custom-ca-certs').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test('safe mode', async ({ pageWithUserData: page }) => {
// init safe mode
await page.getByText('Developer Mode').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
test.setTimeout(2 * 60 * 1000);
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
if (!matches) {
throw new Error('Could not parse test results');
}
const [totalRequests, passed, failed, skipped] = matches.slice(1);
await expect(parseInt(totalRequests)).toBe(1);
await expect(parseInt(passed)).toBe(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(skipped)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,16 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/ssl/custom-ca-certs/collection"],
"preferences": {
"request": {
"sslVerification": true,
"customCaCertificate": {
"enabled": true,
"filePath": "{{projectRoot}}/tests/ssl/custom-ca-certs/server/certs/ca-cert.pem"
},
"keepDefaultCaCertificates": {
"enabled": false
}
}
}
}