From dd8cc93168d652e43004879960075fdfc828224e Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Mon, 23 Mar 2026 15:23:10 -0400 Subject: [PATCH 01/22] add acceptance tests for save, get, and remove --- .github/workflows/ci.yml | 4 + package.json | 3 +- test/credential-manager/acceptance/helpers.ts | 88 +++++++++ .../acceptance/index.acceptance.test.ts | 178 ++++++++++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 test/credential-manager/acceptance/helpers.ts create mode 100644 test/credential-manager/acceptance/index.acceptance.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7336b86f..aa4bd794 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,9 @@ jobs: - run: npm ci - name: unit tests run: npm test + - name: acceptance tests + env: + ACCEPTANCE_TESTS: 'true' + run: npm run test:acceptance - name: linting run: npm run lint diff --git a/package.json b/package.json index 2cadcf00..1609bc8a 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "lint": "tsc -p test --noEmit && eslint .", "prepublishOnly": "npm run build", "prepare": "npm run build", - "test": "c8 --reporter=text-summary --check-coverage mocha --forbid-only \"test/**/*.test.ts\"", + "test": "c8 --reporter=text-summary --check-coverage mocha --forbid-only --ignore \"test/credential-manager/acceptance/**\" \"test/**/*.test.ts\"", + "test:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/**/*.test.ts\"", "test:file": "c8 mocha", "test:local": "c8 mocha \"test/**/*.test.ts\"", "changelog": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -r 0", diff --git a/test/credential-manager/acceptance/helpers.ts b/test/credential-manager/acceptance/helpers.ts new file mode 100644 index 00000000..9f8b1365 --- /dev/null +++ b/test/credential-manager/acceptance/helpers.ts @@ -0,0 +1,88 @@ +import childProcess from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export const HOST_NAME = 'acceptance.test.heroku.com' +export const ALTERNATE_HOST_NAME = 'acceptance-2.test.heroku.com' + +export const SERVICE_NAME = 'heroku-cli-acceptance-test' +export const ALTERNATE_SERVICE_NAME = 'heroku-cli-acceptance-test-2' + +export type Fixture = { + account: string, + hosts: string[], + service: string, + token: string, +} + +export const CREDENTIAL_FIXTURES: Record = { + 'account-default': { + account: 'acceptance-test@example.com', + hosts: [HOST_NAME], + service: SERVICE_NAME, + token: 'test-acceptance-token-12345', + }, + 'account-different-service': { + account: 'acceptance-test-different-service@example.com', + hosts: [HOST_NAME], + service: ALTERNATE_SERVICE_NAME, + token: 'test-acceptance-token-12348', + }, + 'account-multiple-hosts': { + account: 'acceptance-test-multiple-hosts@example.com', + hosts: [HOST_NAME, ALTERNATE_HOST_NAME], + service: SERVICE_NAME, + token: 'test-acceptance-token-12347', + }, +} as const satisfies Record + +/** + * Skip the current suite or test unless ACCEPTANCE_TESTS=true. + */ +export function skipUnlessAcceptanceEnv(context: Mocha.Context): void { + const value = process.env.ACCEPTANCE_TESTS?.toLowerCase() + if (value !== 'true') { + context.skip() + } +} + +/** + * Result of setting up a temp directory for netrc-only acceptance tests. + * Call restore() in afterEach/after to reset env and remove the directory. + */ +export type TempNetrcDir = { + dir: string + restore: () => void +} + +/** + * Creates a temp directory and sets HOME (and on Windows, USERPROFILE) so that + * .netrc reads/writes go to the temp dir. + */ +export function setupTempNetrcDir(): TempNetrcDir { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'heroku-credential-manager-acceptance-')) + const originalHome = process.env.HOME + const originalUserProfile = process.env.USERPROFILE + + process.env.HOME = dir + if (process.platform === 'win32') { + process.env.USERPROFILE = dir + } + + return { + dir, + restore() { + process.env.HOME = originalHome + if (process.platform === 'win32') { + process.env.USERPROFILE = originalUserProfile + } + + try { + fs.rmSync(dir, {force: true, recursive: true}) + } catch { + // ignore cleanup errors + } + }, + } +} diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts new file mode 100644 index 00000000..7b7d915c --- /dev/null +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -0,0 +1,178 @@ +import {expect, use,} from 'chai' +import chaiAsPromised from 'chai-as-promised' + +import * as credentialManager from '../../../src/credential-manager-core/index.js' +import { + CREDENTIAL_FIXTURES, setupTempNetrcDir, skipUnlessAcceptanceEnv, +} from './helpers.js' + +use(chaiAsPromised) + +describe('credential-manager', function () { + before(function () { + skipUnlessAcceptanceEnv(this) + }) + + describe('netrc-only', function () { + let restoreNetrc: (() => void) | undefined + let originalNetrcWrite: string | undefined + + before(function () { + originalNetrcWrite = process.env.HEROKU_NETRC_WRITE + process.env.HEROKU_NETRC_WRITE = 'TRUE' + + const temp = setupTempNetrcDir() + restoreNetrc = temp.restore + }) + + after(function () { + if (originalNetrcWrite === undefined) { + delete process.env.HEROKU_NETRC_WRITE + } else { + process.env.HEROKU_NETRC_WRITE = originalNetrcWrite + } + + if (restoreNetrc) { + restoreNetrc() + } + }) + + afterEach(async function () { + for (const credential of Object.values(CREDENTIAL_FIXTURES)) { + try { + // eslint-disable-next-line no-await-in-loop + await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + } catch { + // ignore cleanup errors + } + } + }) + + it('saves and retrieves a credential (one host)', async function () { + const credential = CREDENTIAL_FIXTURES['account-default'] + await credentialManager.saveAuth( + credential.account, + credential.token, + credential.hosts, + credential.service, + ) + const token = await credentialManager.getAuth( + credential.account, + credential.hosts[0], + credential.service, + ) + expect(token).to.equal(credential.token) + }) + + it('saves and retrieves a credential (multiple hosts)', async function () { + const credential = CREDENTIAL_FIXTURES['account-multiple-hosts'] + await credentialManager.saveAuth( + credential.account, + credential.token, + credential.hosts, + credential.service, + ) + const token = await credentialManager.getAuth( + credential.account, + credential.hosts[0], + credential.service, + ) + expect(token).to.equal(credential.token) + const token2 = await credentialManager.getAuth( + credential.account, + credential.hosts[1], + credential.service, + ) + expect(token2).to.equal(credential.token) + }) + + it('removes a credential (one host)', async function () { + const credential = CREDENTIAL_FIXTURES['account-default'] + await credentialManager.saveAuth( + credential.account, + credential.token, + credential.hosts, + credential.service, + ) + await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + await expect( + credentialManager.getAuth(credential.account, credential.hosts[0], credential.service), + ).to.be.rejectedWith(Error, `No auth found for ${credential.hosts[0]}`) + }) + + it('removes a credential (multiple hosts)', async function () { + const credential = CREDENTIAL_FIXTURES['account-multiple-hosts'] + await credentialManager.saveAuth( + credential.account, + credential.token, + credential.hosts, + credential.service, + ) + await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + await expect( + credentialManager.getAuth(credential.account, credential.hosts[0], credential.service), + ).to.be.rejectedWith(Error, `No auth found for ${credential.hosts[0]}`) + await expect( + credentialManager.getAuth(credential.account, credential.hosts[1], credential.service), + ).to.be.rejectedWith(Error, `No auth found for ${credential.hosts[1]}`) + }) + }) + + describe('native credential store with netrc', function () { + let restoreNetrc: (() => void) | undefined + + before(function () { + const temp = setupTempNetrcDir() + restoreNetrc = temp.restore + }) + + after(function () { + if (restoreNetrc) { + restoreNetrc() + } + }) + + afterEach(async function () { + for (const credential of Object.values(CREDENTIAL_FIXTURES)) { + try { + // eslint-disable-next-line no-await-in-loop + await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + } catch { + // ignore cleanup errors + } + } + }) + + it('saves and retrieves a credential', async function () { + const credential = CREDENTIAL_FIXTURES['account-default'] + await credentialManager.saveAuth( + credential.account, + credential.token, + credential.hosts, + credential.service, + ) + + const token = await credentialManager.getAuth( + credential.account, + credential.hosts[0], + credential.service, + ) + expect(token).to.equal(credential.token) + }) + + it('removes a credential', async function () { + const credential = CREDENTIAL_FIXTURES['account-default'] + await credentialManager.saveAuth( + credential.account, + credential.token, + credential.hosts, + credential.service, + ) + + await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + await expect( + credentialManager.getAuth(credential.account, credential.hosts[0], credential.service), + ).to.be.rejectedWith(Error, `No auth found for ${credential.hosts[0]}`) + }) + }) +}) From 1318f279b4ca51c49b873a69d3fa92fb2beeb024 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Mon, 23 Mar 2026 15:26:55 -0400 Subject: [PATCH 02/22] fix linting errors --- test/credential-manager/acceptance/helpers.ts | 1 - test/credential-manager/acceptance/index.acceptance.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/credential-manager/acceptance/helpers.ts b/test/credential-manager/acceptance/helpers.ts index 9f8b1365..5aa0847f 100644 --- a/test/credential-manager/acceptance/helpers.ts +++ b/test/credential-manager/acceptance/helpers.ts @@ -1,4 +1,3 @@ -import childProcess from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts index 7b7d915c..e9a3c107 100644 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -1,4 +1,4 @@ -import {expect, use,} from 'chai' +import {expect, use} from 'chai' import chaiAsPromised from 'chai-as-promised' import * as credentialManager from '../../../src/credential-manager-core/index.js' From a84eccc826cfc09833e72662cfcfafb3bf3a8be0 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Mon, 23 Mar 2026 15:32:45 -0400 Subject: [PATCH 03/22] add mocha import --- test/credential-manager/acceptance/helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/credential-manager/acceptance/helpers.ts b/test/credential-manager/acceptance/helpers.ts index 5aa0847f..10f85800 100644 --- a/test/credential-manager/acceptance/helpers.ts +++ b/test/credential-manager/acceptance/helpers.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { Context } from 'mocha' export const HOST_NAME = 'acceptance.test.heroku.com' export const ALTERNATE_HOST_NAME = 'acceptance-2.test.heroku.com' @@ -39,7 +40,7 @@ export const CREDENTIAL_FIXTURES: Record = { /** * Skip the current suite or test unless ACCEPTANCE_TESTS=true. */ -export function skipUnlessAcceptanceEnv(context: Mocha.Context): void { +export function skipUnlessAcceptanceEnv(context: Context): void { const value = process.env.ACCEPTANCE_TESTS?.toLowerCase() if (value !== 'true') { context.skip() From 5c96de77c9fd0ddb9cb3193feb44feea71e924ef Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Mon, 23 Mar 2026 15:36:00 -0400 Subject: [PATCH 04/22] fix windows void-cast --- .../credential-handlers/windows-handler.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/credential-manager-core/credential-handlers/windows-handler.ts b/src/credential-manager-core/credential-handlers/windows-handler.ts index 8b406439..b51f5b3b 100644 --- a/src/credential-manager-core/credential-handlers/windows-handler.ts +++ b/src/credential-manager-core/credential-handlers/windows-handler.ts @@ -25,8 +25,7 @@ export class WindowsHandler { public getAuth(account: string, service: string): string { try { const psCommand = ` - [void] - [Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] $vault = New-Object Windows.Security.Credentials.PasswordVault $credential = $vault.Retrieve("${service}", "${account}") $credential.Password @@ -55,8 +54,7 @@ export class WindowsHandler { public listAccounts(service: string): string[] { try { const psCommand = ` - [void] - [Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] $vault = New-Object Windows.Security.Credentials.PasswordVault try { $creds = $vault.FindAllByResource("${service}") From 02b1b80027fd6cc7cd99a0a9f2b25f7f4542e307 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Mon, 23 Mar 2026 16:27:59 -0400 Subject: [PATCH 05/22] probe macOS for security command --- .github/workflows/ci.yml | 2 ++ package.json | 1 + scripts/ci/probe-macos.sh | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 scripts/ci/probe-macos.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa4bd794..d8b85d25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,5 +36,7 @@ jobs: env: ACCEPTANCE_TESTS: 'true' run: npm run test:acceptance + - name: probe macOS for 'security' command + run: npm run probe:macos - name: linting run: npm run lint diff --git a/package.json b/package.json index 1609bc8a..6b10f4ad 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "lint": "tsc -p test --noEmit && eslint .", "prepublishOnly": "npm run build", "prepare": "npm run build", + "probe:macos": "bash scripts/ci/probe-macos.sh", "test": "c8 --reporter=text-summary --check-coverage mocha --forbid-only --ignore \"test/credential-manager/acceptance/**\" \"test/**/*.test.ts\"", "test:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/**/*.test.ts\"", "test:file": "c8 mocha", diff --git a/scripts/ci/probe-macos.sh b/scripts/ci/probe-macos.sh new file mode 100644 index 00000000..4607013a --- /dev/null +++ b/scripts/ci/probe-macos.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ "$(uname -s)" != "Darwin" ]; then + echo "This script is for macOS only (Darwin). Skipping." + exit 0 +fi + +echo "=== which security ===" +command -v security + +echo "=== list-keychains ===" +security list-keychains + +echo "=== default-keychain ===" +security default-keychain + +echo "=== dump-keychain (first 4 lines) ===" +security dump-keychain 2>&1 | head -4 \ No newline at end of file From 5de3b61a205e6a084aed34ea7b3b6eee120b5fd6 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Mon, 23 Mar 2026 16:32:09 -0400 Subject: [PATCH 06/22] temporarily remove acceptance tests from CI --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8b85d25..e7d6d617 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,6 @@ jobs: - run: npm ci - name: unit tests run: npm test - - name: acceptance tests - env: - ACCEPTANCE_TESTS: 'true' - run: npm run test:acceptance - name: probe macOS for 'security' command run: npm run probe:macos - name: linting From 820d39099341716ddb801668c14ab517b30e310c Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 09:39:55 -0400 Subject: [PATCH 07/22] try save auth for macos --- scripts/ci/probe-macos.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/probe-macos.sh b/scripts/ci/probe-macos.sh index 4607013a..fbf8ab4a 100644 --- a/scripts/ci/probe-macos.sh +++ b/scripts/ci/probe-macos.sh @@ -14,5 +14,7 @@ security list-keychains echo "=== default-keychain ===" security default-keychain -echo "=== dump-keychain (first 4 lines) ===" -security dump-keychain 2>&1 | head -4 \ No newline at end of file +echo "=== save credential ===" +security add-generic-password -U -a "test@example.com" -s "heroku-cli-test" -w "fake-token" + +exit 0 \ No newline at end of file From 541b6e2127af9c8a702fa97f4952caca1bd4cef5 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 09:42:46 -0400 Subject: [PATCH 08/22] try generic password again for macos --- scripts/ci/probe-macos.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/ci/probe-macos.sh b/scripts/ci/probe-macos.sh index fbf8ab4a..e32e0894 100644 --- a/scripts/ci/probe-macos.sh +++ b/scripts/ci/probe-macos.sh @@ -15,6 +15,4 @@ echo "=== default-keychain ===" security default-keychain echo "=== save credential ===" -security add-generic-password -U -a "test@example.com" -s "heroku-cli-test" -w "fake-token" - -exit 0 \ No newline at end of file +security add-generic-password -a "test@example.com" -s "heroku-cli-test" -w "fake-token" \ No newline at end of file From 6dd737f8dea18787cbaf41fa2c7f076e261ec5d8 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 09:46:53 -0400 Subject: [PATCH 09/22] save, get, remove for macOS --- scripts/ci/probe-macos.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ci/probe-macos.sh b/scripts/ci/probe-macos.sh index e32e0894..254fdc75 100644 --- a/scripts/ci/probe-macos.sh +++ b/scripts/ci/probe-macos.sh @@ -15,4 +15,10 @@ echo "=== default-keychain ===" security default-keychain echo "=== save credential ===" -security add-generic-password -a "test@example.com" -s "heroku-cli-test" -w "fake-token" \ No newline at end of file +security add-generic-password -a "test@example.com" -s "heroku-cli-test" -w "fake-token" + +echo "=== get credential ===" +security find-generic-password -a "test@example.com" -s "heroku-cli-test" -w + +echo "=== delete credential ===" +security delete-generic-password -a "test@example.com" -s "heroku-cli-test" \ No newline at end of file From 22d48ec10e9bc3c8ed42a4af52b9ced425ae2688 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 10:18:48 -0400 Subject: [PATCH 10/22] add acceptance tests back in --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7d6d617..aa4bd794 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,9 @@ jobs: - run: npm ci - name: unit tests run: npm test - - name: probe macOS for 'security' command - run: npm run probe:macos + - name: acceptance tests + env: + ACCEPTANCE_TESTS: 'true' + run: npm run test:acceptance - name: linting run: npm run lint From 69cb5dd78dd7578d004cbe030223e5ef5edb44c4 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 10:32:48 -0400 Subject: [PATCH 11/22] add logs to find block in MacOS tests --- .../acceptance/index.acceptance.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts index e9a3c107..0bab2588 100644 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -144,6 +144,7 @@ describe('credential-manager', function () { }) it('saves and retrieves a credential', async function () { + console.log('=== made it to "saves and retrieves a credential" ===') const credential = CREDENTIAL_FIXTURES['account-default'] await credentialManager.saveAuth( credential.account, @@ -152,15 +153,21 @@ describe('credential-manager', function () { credential.service, ) + console.log('=== made it to "saveAuth" ===') + const token = await credentialManager.getAuth( credential.account, credential.hosts[0], credential.service, ) + + console.log('=== made it to "getAuth" ===') + expect(token).to.equal(credential.token) }) it('removes a credential', async function () { + console.log('=== made it to "removes a credential" ===') const credential = CREDENTIAL_FIXTURES['account-default'] await credentialManager.saveAuth( credential.account, @@ -169,7 +176,12 @@ describe('credential-manager', function () { credential.service, ) + console.log('=== made it to "saveAuth" ===') + await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + + console.log('=== made it to "removeAuth" ===') + await expect( credentialManager.getAuth(credential.account, credential.hosts[0], credential.service), ).to.be.rejectedWith(Error, `No auth found for ${credential.hosts[0]}`) From ef5133fbc963e5ae20ebf6a58f013063323f0e23 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 10:42:40 -0400 Subject: [PATCH 12/22] add timeout for MacOS --- .../credential-handlers/macos-handler.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/credential-manager-core/credential-handlers/macos-handler.ts b/src/credential-manager-core/credential-handlers/macos-handler.ts index c02db2a7..371e206e 100644 --- a/src/credential-manager-core/credential-handlers/macos-handler.ts +++ b/src/credential-manager-core/credential-handlers/macos-handler.ts @@ -8,6 +8,8 @@ import {KeychainAuthEntry} from '../lib/types.js' * Uses the macOS security command-line tool to interact with the Keychain. */ export class MacOSHandler { + private static readonly SECURITY_COMMAND_TIMEOUT_MS = 10_000 + private readonly scrubber = new Scrubber({ patterns: [ /-a\s+"[^"]*"/g, // Scrub account (-a flag) @@ -26,7 +28,11 @@ export class MacOSHandler { try { const output = childProcess.execSync( `security find-generic-password -a "${account}" -s "${service}" -w`, - {encoding: 'utf8'}, + { + encoding: 'utf8', + killSignal: 'SIGKILL', + timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, + }, ) const token = output.trim() @@ -49,7 +55,11 @@ export class MacOSHandler { */ public listAccounts(service: string): string[] { try { - const output = childProcess.execSync('security dump-keychain', {encoding: 'utf8'}) + const output = childProcess.execSync('security dump-keychain', { + encoding: 'utf8', + killSignal: 'SIGKILL', + timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, + }) // Expected output format: // keychain: "/path/to/keychain" @@ -99,7 +109,11 @@ export class MacOSHandler { try { childProcess.execSync( `security delete-generic-password -a "${account}" -s "${service}"`, - {encoding: 'utf8'}, + { + encoding: 'utf8', + killSignal: 'SIGKILL', + timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, + }, ) } catch (error) { const {message} = error as Error @@ -118,7 +132,11 @@ export class MacOSHandler { try { childProcess.execSync( `security add-generic-password -U -a "${auth.account}" -s "${auth.service}" -w "${auth.token}"`, - {encoding: 'utf8'}, + { + encoding: 'utf8', + killSignal: 'SIGKILL', + timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, + }, ) } catch (error) { const {message} = error as Error From 86b181e394b7bc8396f27c30b8f36df44b31d275 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 10:49:43 -0400 Subject: [PATCH 13/22] check -U flag for macOS --- .github/workflows/ci.yml | 2 ++ scripts/ci/probe-macos.sh | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa4bd794..d8b85d25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,5 +36,7 @@ jobs: env: ACCEPTANCE_TESTS: 'true' run: npm run test:acceptance + - name: probe macOS for 'security' command + run: npm run probe:macos - name: linting run: npm run lint diff --git a/scripts/ci/probe-macos.sh b/scripts/ci/probe-macos.sh index 254fdc75..2d4611d2 100644 --- a/scripts/ci/probe-macos.sh +++ b/scripts/ci/probe-macos.sh @@ -15,7 +15,8 @@ echo "=== default-keychain ===" security default-keychain echo "=== save credential ===" -security add-generic-password -a "test@example.com" -s "heroku-cli-test" -w "fake-token" +security add-generic-password -U -a "test@example.com" -s "heroku-cli-test" -w "fake-token" +echo $? echo "=== get credential ===" security find-generic-password -a "test@example.com" -s "heroku-cli-test" -w From e154689cfcf2038f1e55569e1e82d7808a933f73 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 11:12:31 -0400 Subject: [PATCH 14/22] try removing temp netrc file --- .../credential-handlers/macos-handler.ts | 28 ++++--------------- .../acceptance/index.acceptance.test.ts | 13 --------- 2 files changed, 6 insertions(+), 35 deletions(-) diff --git a/src/credential-manager-core/credential-handlers/macos-handler.ts b/src/credential-manager-core/credential-handlers/macos-handler.ts index 371e206e..039fa82d 100644 --- a/src/credential-manager-core/credential-handlers/macos-handler.ts +++ b/src/credential-manager-core/credential-handlers/macos-handler.ts @@ -8,8 +8,6 @@ import {KeychainAuthEntry} from '../lib/types.js' * Uses the macOS security command-line tool to interact with the Keychain. */ export class MacOSHandler { - private static readonly SECURITY_COMMAND_TIMEOUT_MS = 10_000 - private readonly scrubber = new Scrubber({ patterns: [ /-a\s+"[^"]*"/g, // Scrub account (-a flag) @@ -28,11 +26,7 @@ export class MacOSHandler { try { const output = childProcess.execSync( `security find-generic-password -a "${account}" -s "${service}" -w`, - { - encoding: 'utf8', - killSignal: 'SIGKILL', - timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, - }, + {encoding: 'utf8'}, ) const token = output.trim() @@ -55,11 +49,9 @@ export class MacOSHandler { */ public listAccounts(service: string): string[] { try { - const output = childProcess.execSync('security dump-keychain', { - encoding: 'utf8', - killSignal: 'SIGKILL', - timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, - }) + const output = childProcess.execSync('security dump-keychain', + {encoding: 'utf8'}, + ) // Expected output format: // keychain: "/path/to/keychain" @@ -109,11 +101,7 @@ export class MacOSHandler { try { childProcess.execSync( `security delete-generic-password -a "${account}" -s "${service}"`, - { - encoding: 'utf8', - killSignal: 'SIGKILL', - timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, - }, + {encoding: 'utf8'}, ) } catch (error) { const {message} = error as Error @@ -132,11 +120,7 @@ export class MacOSHandler { try { childProcess.execSync( `security add-generic-password -U -a "${auth.account}" -s "${auth.service}" -w "${auth.token}"`, - { - encoding: 'utf8', - killSignal: 'SIGKILL', - timeout: MacOSHandler.SECURITY_COMMAND_TIMEOUT_MS, - }, + {encoding: 'utf8'}, ) } catch (error) { const {message} = error as Error diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts index 0bab2588..0712d7d3 100644 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -119,19 +119,6 @@ describe('credential-manager', function () { }) describe('native credential store with netrc', function () { - let restoreNetrc: (() => void) | undefined - - before(function () { - const temp = setupTempNetrcDir() - restoreNetrc = temp.restore - }) - - after(function () { - if (restoreNetrc) { - restoreNetrc() - } - }) - afterEach(async function () { for (const credential of Object.values(CREDENTIAL_FIXTURES)) { try { From 4826cc12b34b9f5003881f8840fa4d3d4e8d6c37 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 12:28:00 -0400 Subject: [PATCH 15/22] probe windows --- .github/workflows/ci.yml | 3 ++ package.json | 1 + scripts/ci/probe-windows.ps1 | 63 ++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 scripts/ci/probe-windows.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8b85d25..fba525d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,8 @@ jobs: run: npm run test:acceptance - name: probe macOS for 'security' command run: npm run probe:macos + - name: probe Windows for Windows Credential Manager + if: runner.os == 'Windows' + run: npm run probe:windows - name: linting run: npm run lint diff --git a/package.json b/package.json index 6b10f4ad..22cc4580 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "prepublishOnly": "npm run build", "prepare": "npm run build", "probe:macos": "bash scripts/ci/probe-macos.sh", + "probe:windows": "powershell -NoProfile -File scripts/ci/probe-windows.ps1", "test": "c8 --reporter=text-summary --check-coverage mocha --forbid-only --ignore \"test/credential-manager/acceptance/**\" \"test/**/*.test.ts\"", "test:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/**/*.test.ts\"", "test:file": "c8 mocha", diff --git a/scripts/ci/probe-windows.ps1 b/scripts/ci/probe-windows.ps1 new file mode 100644 index 00000000..15c563cd --- /dev/null +++ b/scripts/ci/probe-windows.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = 'Continue' + +$vaultTypeLoad = '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]' + +Write-Host "=== powershell -> $((Get-Command powershell).Source) ===" + +Write-Host '=== PasswordVault in this PowerShell session ===' +try { + Invoke-Expression $vaultTypeLoad + Write-Host 'OK: type loaded' +} catch { + Write-Host "FAILED: $($_.Exception.Message)" +} + +Write-Host '=== Child powershell -Command (same pattern as each execSync) ===' +& powershell -NoProfile -NonInteractive -Command $vaultTypeLoad +if ($LASTEXITCODE -ne 0) { + Write-Host "FAILED: child exited with code $LASTEXITCODE" +} else { + Write-Host 'OK: child loaded PasswordVault type' +} + +$saveCommand = + '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = New-Object Windows.Security.Credentials.PasswordCredential("heroku-cli-test", "test@example.com", "fake-token") + $vault.Add($credential)' + +Write-Host '=== Save credential ===' +& powershell -NoProfile -NonInteractive -Command $saveCommand +if ($LASTEXITCODE -ne 0) { + Write-Host "FAILED: child exited with code $LASTEXITCODE" +} else { + Write-Host 'OK: credential saved' +} + +$getCommand = + '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") + $credential.Password' + +Write-Host '=== Get credential ===' +& powershell -NoProfile -NonInteractive -Command $getCommand +if ($LASTEXITCODE -ne 0) { + Write-Host "FAILED: child exited with code $LASTEXITCODE" +} else { + Write-Host 'OK: credential retrieved' +} + +$removeCommand = + '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") + $vault.Remove($credential)' + +Write-Host '=== Remove credential ===' +& powershell -NoProfile -NonInteractive -Command $removeCommand +if ($LASTEXITCODE -ne 0) { + Write-Host "FAILED: child exited with code $LASTEXITCODE" +} else { + Write-Host 'OK: credential removed' +} \ No newline at end of file From 80f43fb0a4b44069ff5e0dd2aec95dd38bd89069 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 12:40:38 -0400 Subject: [PATCH 16/22] update windows probe --- scripts/ci/probe-windows.ps1 | 57 +++++++++++++++--------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/scripts/ci/probe-windows.ps1 b/scripts/ci/probe-windows.ps1 index 15c563cd..2eadc548 100644 --- a/scripts/ci/probe-windows.ps1 +++ b/scripts/ci/probe-windows.ps1 @@ -20,44 +20,35 @@ if ($LASTEXITCODE -ne 0) { Write-Host 'OK: child loaded PasswordVault type' } -$saveCommand = - '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = New-Object Windows.Security.Credentials.PasswordCredential("heroku-cli-test", "test@example.com", "fake-token") - $vault.Add($credential)' - Write-Host '=== Save credential ===' -& powershell -NoProfile -NonInteractive -Command $saveCommand -if ($LASTEXITCODE -ne 0) { - Write-Host "FAILED: child exited with code $LASTEXITCODE" -} else { - Write-Host 'OK: credential saved' +try{ + Invoke-Expression $vaultTypeLoad + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = New-Object Windows.Security.Credentials.PasswordCredential("heroku-cli-test", "test@example.com", "fake-token") + $vault.Add($credential) + Write-Host 'OK: credential saved' +} catch { + Write-Host "FAILED: $($_.Exception.Message)" } -$getCommand = - '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") - $credential.Password' - Write-Host '=== Get credential ===' -& powershell -NoProfile -NonInteractive -Command $getCommand -if ($LASTEXITCODE -ne 0) { - Write-Host "FAILED: child exited with code $LASTEXITCODE" -} else { - Write-Host 'OK: credential retrieved' +try{ + Invoke-Expression $vaultTypeLoad + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") + $credential.Password + Write-Host 'OK: credential retrieved' +} catch { + Write-Host "FAILED: $($_.Exception.Message)" } -$removeCommand = - '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") - $vault.Remove($credential)' - Write-Host '=== Remove credential ===' -& powershell -NoProfile -NonInteractive -Command $removeCommand -if ($LASTEXITCODE -ne 0) { - Write-Host "FAILED: child exited with code $LASTEXITCODE" -} else { - Write-Host 'OK: credential removed' +try{ + Invoke-Expression $vaultTypeLoad + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") + $vault.Remove($credential) + Write-Host 'OK: credential removed' +} catch { + Write-Host "FAILED: $($_.Exception.Message)" } \ No newline at end of file From da0869fa5f1de80240f09f709dd88182e0076b4d Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 13:52:11 -0400 Subject: [PATCH 17/22] probe linux --- .github/workflows/ci.yml | 2 ++ package.json | 1 + scripts/ci/probe-linux.sh | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 scripts/ci/probe-linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fba525d7..49a024e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,5 +41,7 @@ jobs: - name: probe Windows for Windows Credential Manager if: runner.os == 'Windows' run: npm run probe:windows + - name: probe Linux for 'secret-tool' command + run: npm run probe:linux - name: linting run: npm run lint diff --git a/package.json b/package.json index 22cc4580..ec6e83ac 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "lint": "tsc -p test --noEmit && eslint .", "prepublishOnly": "npm run build", "prepare": "npm run build", + "probe:linux": "bash scripts/ci/probe-linux.sh", "probe:macos": "bash scripts/ci/probe-macos.sh", "probe:windows": "powershell -NoProfile -File scripts/ci/probe-windows.ps1", "test": "c8 --reporter=text-summary --check-coverage mocha --forbid-only --ignore \"test/credential-manager/acceptance/**\" \"test/**/*.test.ts\"", diff --git a/scripts/ci/probe-linux.sh b/scripts/ci/probe-linux.sh new file mode 100644 index 00000000..240b5747 --- /dev/null +++ b/scripts/ci/probe-linux.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != Linux ]]; then + echo "probe-linux-secret: skipping (Linux only)" + exit 0 +fi + +secret_tool_path="$(command -v secret-tool || true)" +if [[ -n "$secret_tool_path" ]]; then + echo "=== secret-tool -> ${secret_tool_path} ===" + secret-tool --version 2>/dev/null || true + echo "OK: secret-tool on PATH" +else + echo "=== secret-tool ===" + echo "MISSING: secret-tool not on PATH" +fi + +if command -v which >/dev/null 2>&1; then + echo "which is available at $(command -v which)" +else + echo "which not on PATH" +fi + +echo "=== related binaries (gnome-keyring / dbus session) ===" +for cmd in gnome-keyring-daemon dbus-run-session; do + if command -v "$cmd" >/dev/null 2>&1; then + echo "OK: ${cmd} -> $(command -v "$cmd")" + else + echo "MISSING: ${cmd}" + fi +done + +echo "=== apt: can libsecret-tools be installed? ===" +if ! command -v apt-get >/dev/null 2>&1; then + echo "No apt-get" + exit 0 +fi From edad61e142300bc59d8532f4278d4450dc61747e Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 13:57:00 -0400 Subject: [PATCH 18/22] update linux probe --- scripts/ci/probe-linux.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/ci/probe-linux.sh b/scripts/ci/probe-linux.sh index 240b5747..09e078c8 100644 --- a/scripts/ci/probe-linux.sh +++ b/scripts/ci/probe-linux.sh @@ -17,6 +17,7 @@ else fi if command -v which >/dev/null 2>&1; then + echo "=== which ===" echo "which is available at $(command -v which)" else echo "which not on PATH" @@ -31,8 +32,9 @@ for cmd in gnome-keyring-daemon dbus-run-session; do fi done -echo "=== apt: can libsecret-tools be installed? ===" -if ! command -v apt-get >/dev/null 2>&1; then - echo "No apt-get" - exit 0 +echo "=== apt ===" +if command -v apt-get >/dev/null 2>&1; then + echo "apt-get is available at $(command -v apt-get)" +else + echo "apt-get not on PATH" fi From 0c25ab9bfdf7b182e975c3eaa1466bca8169abe1 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 15:25:59 -0400 Subject: [PATCH 19/22] add temp netr directory --- .../credential-handlers/macos-handler.ts | 4 +--- .../acceptance/index.acceptance.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/credential-manager-core/credential-handlers/macos-handler.ts b/src/credential-manager-core/credential-handlers/macos-handler.ts index 039fa82d..c02db2a7 100644 --- a/src/credential-manager-core/credential-handlers/macos-handler.ts +++ b/src/credential-manager-core/credential-handlers/macos-handler.ts @@ -49,9 +49,7 @@ export class MacOSHandler { */ public listAccounts(service: string): string[] { try { - const output = childProcess.execSync('security dump-keychain', - {encoding: 'utf8'}, - ) + const output = childProcess.execSync('security dump-keychain', {encoding: 'utf8'}) // Expected output format: // keychain: "/path/to/keychain" diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts index 0712d7d3..fdfb2673 100644 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -119,6 +119,19 @@ describe('credential-manager', function () { }) describe('native credential store with netrc', function () { + let restoreNetrc: (() => void) | undefined + + before(function () { + const temp = setupTempNetrcDir() + restoreNetrc = temp.restore + }) + + after(function () { + if (restoreNetrc) { + restoreNetrc() + } + }) + afterEach(async function () { for (const credential of Object.values(CREDENTIAL_FIXTURES)) { try { From 5a2e9934389cc76580d6306ada72208a96d02756 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 15:27:52 -0400 Subject: [PATCH 20/22] fix linting --- test/credential-manager/acceptance/index.acceptance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts index fdfb2673..0bab2588 100644 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -131,7 +131,7 @@ describe('credential-manager', function () { restoreNetrc() } }) - + afterEach(async function () { for (const credential of Object.values(CREDENTIAL_FIXTURES)) { try { From 68f5331b9809398403f999561c26bc92d000c04a Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 16:05:49 -0400 Subject: [PATCH 21/22] linux probe with install --- scripts/ci/probe-linux.sh | 85 +++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/scripts/ci/probe-linux.sh b/scripts/ci/probe-linux.sh index 09e078c8..d35d349b 100644 --- a/scripts/ci/probe-linux.sh +++ b/scripts/ci/probe-linux.sh @@ -6,35 +6,70 @@ if [[ "$(uname -s)" != Linux ]]; then exit 0 fi -secret_tool_path="$(command -v secret-tool || true)" -if [[ -n "$secret_tool_path" ]]; then - echo "=== secret-tool -> ${secret_tool_path} ===" - secret-tool --version 2>/dev/null || true - echo "OK: secret-tool on PATH" +echo "=== apt ===" +if command -v apt-get >/dev/null 2>&1; then + echo "apt-get is available at $(command -v apt-get)" + + missing_tools=() + command -v secret-tool >/dev/null 2>&1 || missing_tools+=("secret-tool") + command -v gnome-keyring-daemon >/dev/null 2>&1 || missing_tools+=("gnome-keyring-daemon") + command -v dbus-run-session >/dev/null 2>&1 || missing_tools+=("dbus-run-session") + + if [[ "${#missing_tools[@]}" -gt 0 ]]; then + echo "Installing missing Linux keyring dependencies for: ${missing_tools[*]}" + apt_cmd="apt-get" + if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then + apt_cmd="sudo apt-get" + fi + $apt_cmd update + $apt_cmd install -y libsecret-tools gnome-keyring dbus-user-session + fi else - echo "=== secret-tool ===" - echo "MISSING: secret-tool not on PATH" + echo "apt-get not on PATH" fi -if command -v which >/dev/null 2>&1; then - echo "=== which ===" - echo "which is available at $(command -v which)" -else - echo "which not on PATH" +if ! command -v dbus-run-session >/dev/null 2>&1; then + echo "MISSING: dbus-run-session (cannot run secret-tool probe session)" + exit 1 fi -echo "=== related binaries (gnome-keyring / dbus session) ===" -for cmd in gnome-keyring-daemon dbus-run-session; do - if command -v "$cmd" >/dev/null 2>&1; then - echo "OK: ${cmd} -> $(command -v "$cmd")" - else - echo "MISSING: ${cmd}" - fi -done +if ! command -v gnome-keyring-daemon >/dev/null 2>&1; then + echo "MISSING: gnome-keyring-daemon (cannot run secret-tool probe session)" + exit 1 +fi -echo "=== apt ===" -if command -v apt-get >/dev/null 2>&1; then - echo "apt-get is available at $(command -v apt-get)" -else - echo "apt-get not on PATH" +if ! command -v secret-tool >/dev/null 2>&1; then + echo "MISSING: secret-tool (cannot run secret-tool probe session)" + exit 1 fi + +echo "=== secret-tool round-trip in dbus session ===" +dbus-run-session -- bash -c ' + set -euo pipefail + eval "$(echo -n "heroku-credential-manager-ci" | gnome-keyring-daemon --unlock --components=secrets)" + + service="heroku-cli-probe-linux" + account="probe-linux@example.com" + token="probe-linux-token" + + echo "store credential" + printf "%s" "$token" | secret-tool store --label="Heroku CLI Probe" service "$service" account "$account" + + echo "lookup credential" + looked_up="$(secret-tool lookup service "$service" account "$account")" + if [[ "$looked_up" != "$token" ]]; then + echo "ERROR: lookup mismatch (got: ${looked_up})" + exit 1 + fi + echo "OK: lookup matched expected token" + + echo "remove credential" + secret-tool clear service "$service" account "$account" + + echo "verify removal" + if secret-tool lookup service "$service" account "$account" >/dev/null 2>&1; then + echo "ERROR: credential still present after clear" + exit 1 + fi + echo "OK: credential removed" +' From 4a5dad77ba58933691dcda3c3acf3ad6adcd5c00 Mon Sep 17 00:00:00 2001 From: Erika Wallace Date: Tue, 24 Mar 2026 16:33:24 -0400 Subject: [PATCH 22/22] remove probes, update acceptance tests --- .github/workflows/ci.yml | 21 ++++-- package.json | 3 - scripts/ci/probe-linux.sh | 75 ------------------- scripts/ci/probe-macos.sh | 25 ------- scripts/ci/probe-windows.ps1 | 54 ------------- .../acceptance/index.acceptance.test.ts | 37 ++------- 6 files changed, 20 insertions(+), 195 deletions(-) delete mode 100644 scripts/ci/probe-linux.sh delete mode 100644 scripts/ci/probe-macos.sh delete mode 100644 scripts/ci/probe-windows.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49a024e7..ad69320e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,16 +32,21 @@ jobs: - run: npm ci - name: unit tests run: npm test - - name: acceptance tests + - name: acceptance tests (macOS/Windows) + if: runner.os != 'Linux' env: ACCEPTANCE_TESTS: 'true' run: npm run test:acceptance - - name: probe macOS for 'security' command - run: npm run probe:macos - - name: probe Windows for Windows Credential Manager - if: runner.os == 'Windows' - run: npm run probe:windows - - name: probe Linux for 'secret-tool' command - run: npm run probe:linux + - name: acceptance tests (Linux) + if: runner.os == 'Linux' + env: + ACCEPTANCE_TESTS: 'true' + run: | + sudo apt-get update + sudo apt-get install -y libsecret-tools gnome-keyring + dbus-run-session -- bash -c ' + eval "$(echo -n "heroku-credential-manager-ci" | gnome-keyring-daemon --unlock --components=secrets 2>/dev/null)" + npm run test:acceptance + ' - name: linting run: npm run lint diff --git a/package.json b/package.json index ec6e83ac..1609bc8a 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,6 @@ "lint": "tsc -p test --noEmit && eslint .", "prepublishOnly": "npm run build", "prepare": "npm run build", - "probe:linux": "bash scripts/ci/probe-linux.sh", - "probe:macos": "bash scripts/ci/probe-macos.sh", - "probe:windows": "powershell -NoProfile -File scripts/ci/probe-windows.ps1", "test": "c8 --reporter=text-summary --check-coverage mocha --forbid-only --ignore \"test/credential-manager/acceptance/**\" \"test/**/*.test.ts\"", "test:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/**/*.test.ts\"", "test:file": "c8 mocha", diff --git a/scripts/ci/probe-linux.sh b/scripts/ci/probe-linux.sh deleted file mode 100644 index d35d349b..00000000 --- a/scripts/ci/probe-linux.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$(uname -s)" != Linux ]]; then - echo "probe-linux-secret: skipping (Linux only)" - exit 0 -fi - -echo "=== apt ===" -if command -v apt-get >/dev/null 2>&1; then - echo "apt-get is available at $(command -v apt-get)" - - missing_tools=() - command -v secret-tool >/dev/null 2>&1 || missing_tools+=("secret-tool") - command -v gnome-keyring-daemon >/dev/null 2>&1 || missing_tools+=("gnome-keyring-daemon") - command -v dbus-run-session >/dev/null 2>&1 || missing_tools+=("dbus-run-session") - - if [[ "${#missing_tools[@]}" -gt 0 ]]; then - echo "Installing missing Linux keyring dependencies for: ${missing_tools[*]}" - apt_cmd="apt-get" - if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then - apt_cmd="sudo apt-get" - fi - $apt_cmd update - $apt_cmd install -y libsecret-tools gnome-keyring dbus-user-session - fi -else - echo "apt-get not on PATH" -fi - -if ! command -v dbus-run-session >/dev/null 2>&1; then - echo "MISSING: dbus-run-session (cannot run secret-tool probe session)" - exit 1 -fi - -if ! command -v gnome-keyring-daemon >/dev/null 2>&1; then - echo "MISSING: gnome-keyring-daemon (cannot run secret-tool probe session)" - exit 1 -fi - -if ! command -v secret-tool >/dev/null 2>&1; then - echo "MISSING: secret-tool (cannot run secret-tool probe session)" - exit 1 -fi - -echo "=== secret-tool round-trip in dbus session ===" -dbus-run-session -- bash -c ' - set -euo pipefail - eval "$(echo -n "heroku-credential-manager-ci" | gnome-keyring-daemon --unlock --components=secrets)" - - service="heroku-cli-probe-linux" - account="probe-linux@example.com" - token="probe-linux-token" - - echo "store credential" - printf "%s" "$token" | secret-tool store --label="Heroku CLI Probe" service "$service" account "$account" - - echo "lookup credential" - looked_up="$(secret-tool lookup service "$service" account "$account")" - if [[ "$looked_up" != "$token" ]]; then - echo "ERROR: lookup mismatch (got: ${looked_up})" - exit 1 - fi - echo "OK: lookup matched expected token" - - echo "remove credential" - secret-tool clear service "$service" account "$account" - - echo "verify removal" - if secret-tool lookup service "$service" account "$account" >/dev/null 2>&1; then - echo "ERROR: credential still present after clear" - exit 1 - fi - echo "OK: credential removed" -' diff --git a/scripts/ci/probe-macos.sh b/scripts/ci/probe-macos.sh deleted file mode 100644 index 2d4611d2..00000000 --- a/scripts/ci/probe-macos.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -if [ "$(uname -s)" != "Darwin" ]; then - echo "This script is for macOS only (Darwin). Skipping." - exit 0 -fi - -echo "=== which security ===" -command -v security - -echo "=== list-keychains ===" -security list-keychains - -echo "=== default-keychain ===" -security default-keychain - -echo "=== save credential ===" -security add-generic-password -U -a "test@example.com" -s "heroku-cli-test" -w "fake-token" -echo $? - -echo "=== get credential ===" -security find-generic-password -a "test@example.com" -s "heroku-cli-test" -w - -echo "=== delete credential ===" -security delete-generic-password -a "test@example.com" -s "heroku-cli-test" \ No newline at end of file diff --git a/scripts/ci/probe-windows.ps1 b/scripts/ci/probe-windows.ps1 deleted file mode 100644 index 2eadc548..00000000 --- a/scripts/ci/probe-windows.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -$ErrorActionPreference = 'Continue' - -$vaultTypeLoad = '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]' - -Write-Host "=== powershell -> $((Get-Command powershell).Source) ===" - -Write-Host '=== PasswordVault in this PowerShell session ===' -try { - Invoke-Expression $vaultTypeLoad - Write-Host 'OK: type loaded' -} catch { - Write-Host "FAILED: $($_.Exception.Message)" -} - -Write-Host '=== Child powershell -Command (same pattern as each execSync) ===' -& powershell -NoProfile -NonInteractive -Command $vaultTypeLoad -if ($LASTEXITCODE -ne 0) { - Write-Host "FAILED: child exited with code $LASTEXITCODE" -} else { - Write-Host 'OK: child loaded PasswordVault type' -} - -Write-Host '=== Save credential ===' -try{ - Invoke-Expression $vaultTypeLoad - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = New-Object Windows.Security.Credentials.PasswordCredential("heroku-cli-test", "test@example.com", "fake-token") - $vault.Add($credential) - Write-Host 'OK: credential saved' -} catch { - Write-Host "FAILED: $($_.Exception.Message)" -} - -Write-Host '=== Get credential ===' -try{ - Invoke-Expression $vaultTypeLoad - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") - $credential.Password - Write-Host 'OK: credential retrieved' -} catch { - Write-Host "FAILED: $($_.Exception.Message)" -} - -Write-Host '=== Remove credential ===' -try{ - Invoke-Expression $vaultTypeLoad - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("heroku-cli-test", "test@example.com") - $vault.Remove($credential) - Write-Host 'OK: credential removed' -} catch { - Write-Host "FAILED: $($_.Exception.Message)" -} \ No newline at end of file diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts index 0bab2588..bcf650cf 100644 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -119,24 +119,11 @@ describe('credential-manager', function () { }) describe('native credential store with netrc', function () { - let restoreNetrc: (() => void) | undefined - - before(function () { - const temp = setupTempNetrcDir() - restoreNetrc = temp.restore - }) - - after(function () { - if (restoreNetrc) { - restoreNetrc() - } - }) - afterEach(async function () { for (const credential of Object.values(CREDENTIAL_FIXTURES)) { try { // eslint-disable-next-line no-await-in-loop - await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) + await credentialManager.removeAuth(credential.account, [], credential.service) } catch { // ignore cleanup errors } @@ -144,47 +131,37 @@ describe('credential-manager', function () { }) it('saves and retrieves a credential', async function () { - console.log('=== made it to "saves and retrieves a credential" ===') const credential = CREDENTIAL_FIXTURES['account-default'] await credentialManager.saveAuth( credential.account, credential.token, - credential.hosts, + [], credential.service, ) - console.log('=== made it to "saveAuth" ===') - const token = await credentialManager.getAuth( credential.account, - credential.hosts[0], + '', credential.service, ) - console.log('=== made it to "getAuth" ===') - expect(token).to.equal(credential.token) }) it('removes a credential', async function () { - console.log('=== made it to "removes a credential" ===') const credential = CREDENTIAL_FIXTURES['account-default'] await credentialManager.saveAuth( credential.account, credential.token, - credential.hosts, + [], credential.service, ) - console.log('=== made it to "saveAuth" ===') - - await credentialManager.removeAuth(credential.account, credential.hosts, credential.service) - - console.log('=== made it to "removeAuth" ===') + await credentialManager.removeAuth(credential.account, [], credential.service) await expect( - credentialManager.getAuth(credential.account, credential.hosts[0], credential.service), - ).to.be.rejectedWith(Error, `No auth found for ${credential.hosts[0]}`) + credentialManager.getAuth(credential.account, '', credential.service), + ).to.be.rejected }) }) })