Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
88 changes: 88 additions & 0 deletions test/credential-manager/acceptance/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, Fixture> = {
'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<string, Fixture>

/**
* 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
}
},
}
}
167 changes: 167 additions & 0 deletions test/credential-manager/acceptance/index.acceptance.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
})
Loading