diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7336b86f..ad69320e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,21 @@ jobs: - run: npm ci - name: unit tests run: npm test + - name: acceptance tests (macOS/Windows) + if: runner.os != 'Linux' + env: + ACCEPTANCE_TESTS: 'true' + run: npm run test:acceptance + - 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 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/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}") diff --git a/test/credential-manager/acceptance/helpers.ts b/test/credential-manager/acceptance/helpers.ts new file mode 100644 index 00000000..10f85800 --- /dev/null +++ b/test/credential-manager/acceptance/helpers.ts @@ -0,0 +1,88 @@ +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' + +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: 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..bcf650cf --- /dev/null +++ b/test/credential-manager/acceptance/index.acceptance.test.ts @@ -0,0 +1,167 @@ +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 () { + 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.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.service, + ) + + const token = await credentialManager.getAuth( + credential.account, + '', + 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.service, + ) + + await credentialManager.removeAuth(credential.account, [], credential.service) + + await expect( + credentialManager.getAuth(credential.account, '', credential.service), + ).to.be.rejected + }) + }) +})