Skip to content
Merged
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
9 changes: 7 additions & 2 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import {Command} from '@heroku-cli/command'
import {ux} from '@oclif/core/ux'

import AccountsModule from '../../lib/accounts/accounts.js'
import Git from '../../lib/git/git.js'

export default class Logout extends Command {
Expand All @@ -11,9 +11,10 @@ export default class Logout extends Command {
static promptFlagActive = false

async run() {
this.parse(Logout)
await this.parse(Logout)

ux.action.start('Logging out')
const cachedNetrcAccount = await AccountsModule.currentNetrc()
await this.heroku.logout()

const git = new Git()
Expand All @@ -24,6 +25,10 @@ export default class Logout extends Command {
// ignore
}

if (cachedNetrcAccount) {
AccountsModule.removeNetrc(cachedNetrcAccount)
}

await this.config.runHook('recache', {type: 'logout'})
ux.action.stop()
}
Expand Down
10 changes: 10 additions & 0 deletions src/lib/accounts/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export interface AccountEntry {
export interface IAccountsWrapper {
list(): Promise<AccountEntry[]>
current(heroku: APIClient): Promise<string | null>
currentNetrc(): Promise<string | null>
add(name: string, username: string, password: string): void
remove(name: string): void
removeNetrc(name: string): void
set(account: AccountEntry, dataDir: string): Promise<void>
getStorageConfig(): ReturnType<typeof getStorageConfig>
writeLoginState(dataDir: string, name: string): Promise<void>
Expand Down Expand Up @@ -100,6 +102,10 @@ export class AccountsWrapper implements IAccountsWrapper {
return authEntry?.account ?? null
}

return this.currentNetrc()
}

async currentNetrc(): Promise<string | null> {
const netrcInstance = await this.initNetrc()
if (netrcInstance.machines['api.heroku.com']) {
const current = this.listNetrc().find(a => a.username === netrcInstance.machines['api.heroku.com'].login)
Expand Down Expand Up @@ -132,6 +138,10 @@ export class AccountsWrapper implements IAccountsWrapper {
return
}

this.removeNetrc(name)
}

removeNetrc(name: string) {
const basedir = path.join(this.configDir(), 'accounts')
fs.unlinkSync(path.join(basedir, name))
}
Expand Down
31 changes: 29 additions & 2 deletions test/unit/commands/auth/logout.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ import {expect} from 'chai'
import sinon from 'sinon'

import Logout from '../../../../src/commands/auth/logout.js'
import AccountsModule from '../../../../src/lib/accounts/accounts.js'
import Git from '../../../../src/lib/git/git.js'
import {runCommand} from '../../../helpers/run-command.js'

describe('auth:logout', function () {
let eraseCredentialsStub: sinon.SinonStub
let removeCredentialHelperStub: sinon.SinonStub
let currentNetrcStub: sinon.SinonStub
let removeNetrcStub: sinon.SinonStub

beforeEach(function () {
eraseCredentialsStub = sinon.stub(Git.prototype, 'eraseCredentials').resolves()
removeCredentialHelperStub = sinon.stub(Git.prototype, 'removeCredentialHelper').resolves()
currentNetrcStub = sinon.stub(AccountsModule, 'currentNetrc').resolves(null)
removeNetrcStub = sinon.stub(AccountsModule, 'removeNetrc')
})

afterEach(function () {
eraseCredentialsStub.restore()
removeCredentialHelperStub.restore()
sinon.restore()
})

it('shows cli logging user out', async function () {
Expand All @@ -43,4 +47,27 @@ describe('auth:logout', function () {

expect(error).to.be.undefined
})

it('checks for cached netrc account', async function () {
await runCommand(Logout, [])

expect(currentNetrcStub.calledOnce).to.be.true
})

it('removes cached netrc account when present', async function () {
currentNetrcStub.resolves('my-account')

await runCommand(Logout, [])

expect(removeNetrcStub.calledOnce).to.be.true
expect(removeNetrcStub.firstCall.args[0]).to.equal('my-account')
})

it('does not remove account when no cached netrc account', async function () {
currentNetrcStub.resolves(null)

await runCommand(Logout, [])

expect(removeNetrcStub.called).to.be.false
})
})
75 changes: 68 additions & 7 deletions test/unit/lib/accounts/accounts.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,51 @@ describe('accounts', function () {
})
})

describe('remove', function () {
describe('currentNetrc()', function () {
let fakeNetrc: {machines: Record<string, {login: string, password: string}>, save: sinon.SinonStub}

function setNetrc(value: typeof fakeNetrc | undefined) {
(AccountsModule as unknown as {netrc: typeof fakeNetrc | undefined}).netrc = value
}

beforeEach(function () {
fakeNetrc = {machines: {}, save: sinon.stub().resolves()}
setNetrc(fakeNetrc)
fsReadFileStub.withArgs(sinon.match(/my-account$/), 'utf8')
.returns('username: user@example.com\npassword: secret\n')
fsReaddirStub.returns(['my-account', 'other-account'])
fsReadFileStub.withArgs(sinon.match(/other-account$/), 'utf8')
.returns('username: other@example.com\npassword: secret\n')
})

afterEach(function () {
setNetrc(null as unknown as typeof fakeNetrc)
})

it('returns account name when api.heroku.com machine exists and matches', async function () {
fakeNetrc.machines['api.heroku.com'] = {login: 'user@example.com', password: 'secret'}

const result = await AccountsModule.currentNetrc()

expect(result).to.equal('my-account')
})

it('returns null when api.heroku.com machine does not exist', async function () {
const result = await AccountsModule.currentNetrc()

expect(result).to.equal(null)
})

it('returns null when no account matches the login', async function () {
fakeNetrc.machines['api.heroku.com'] = {login: 'nomatch@example.com', password: 'secret'}

const result = await AccountsModule.currentNetrc()

expect(result).to.equal(null)
})
})

describe('removeNetrc()', function () {
let unlinkStub: sinon.SinonStub
let osHomeStub: sinon.SinonStub
let existsSyncStub: sinon.SinonStub
Expand All @@ -243,27 +287,44 @@ describe('accounts', function () {
existsSyncStub = sinon.stub(fs, 'existsSync')
})

it('should remove the account file with the given name', async function () {
it('should remove the account file with the given name', function () {
const accountName = 'test-account'
const basedir = '/user/home'

osHomeStub.returns(basedir)
existsSyncStub.returns(false)

await AccountsModule.remove(accountName)
AccountsModule.removeNetrc(accountName)

expect(unlinkStub.calledOnce).to.be.true
expect(unlinkStub.firstCall.args[0]).to.equal(
path.join(`${basedir}/.config/heroku/accounts`, accountName),
)
})

it('should throw an error if the file cannot be removed', async function () {
it('should throw an error if the file cannot be removed', function () {
const accountName = 'non-existent-account'
const error = new Error('File not found')
unlinkStub.throws(error)

await expect(AccountsModule.remove(accountName)).to.be.rejectedWith(Error)
expect(() => AccountsModule.removeNetrc(accountName)).to.throw(Error)
})
})

describe('remove', function () {
let removeNetrcStub: sinon.SinonStub

beforeEach(function () {
removeNetrcStub = sinon.stub(AccountsModule, 'removeNetrc')
})

it('should call removeNetrc when no credential store', async function () {
const accountName = 'test-account'

await AccountsModule.remove(accountName)

expect(removeNetrcStub.calledOnce).to.be.true
expect(removeNetrcStub.firstCall.args[0]).to.equal(accountName)
})

describe('with credentialStore', function () {
Expand Down Expand Up @@ -292,12 +353,12 @@ describe('accounts', function () {
expect(removeAuthStub.firstCall.args[1]).to.deep.equal(['api.heroku.com', 'git.heroku.com'])
})

it('should not call unlinkSync when credentialStore is set', async function () {
it('should not call removeNetrc when credentialStore is set', async function () {
const accountName = 'test-account@example.com'

await AccountsModule.remove(accountName)

expect(unlinkStub.called).to.be.false
expect(removeNetrcStub.called).to.be.false
})

it('should throw an error if removeAuth fails', async function () {
Expand Down
Loading