diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index f7049c7528..0222ccbc91 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -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 { @@ -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() @@ -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() } diff --git a/src/lib/accounts/accounts.ts b/src/lib/accounts/accounts.ts index 9773c41c87..580eec8941 100644 --- a/src/lib/accounts/accounts.ts +++ b/src/lib/accounts/accounts.ts @@ -14,8 +14,10 @@ export interface AccountEntry { export interface IAccountsWrapper { list(): Promise current(heroku: APIClient): Promise + currentNetrc(): Promise add(name: string, username: string, password: string): void remove(name: string): void + removeNetrc(name: string): void set(account: AccountEntry, dataDir: string): Promise getStorageConfig(): ReturnType writeLoginState(dataDir: string, name: string): Promise @@ -100,6 +102,10 @@ export class AccountsWrapper implements IAccountsWrapper { return authEntry?.account ?? null } + return this.currentNetrc() + } + + async currentNetrc(): Promise { 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) @@ -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)) } diff --git a/test/unit/commands/auth/logout.unit.test.ts b/test/unit/commands/auth/logout.unit.test.ts index 236380a7d3..746c303866 100644 --- a/test/unit/commands/auth/logout.unit.test.ts +++ b/test/unit/commands/auth/logout.unit.test.ts @@ -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 () { @@ -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 + }) }) diff --git a/test/unit/lib/accounts/accounts.unit.test.ts b/test/unit/lib/accounts/accounts.unit.test.ts index d7d98ec5b3..9dc6dd9cae 100644 --- a/test/unit/lib/accounts/accounts.unit.test.ts +++ b/test/unit/lib/accounts/accounts.unit.test.ts @@ -232,7 +232,51 @@ describe('accounts', function () { }) }) - describe('remove', function () { + describe('currentNetrc()', function () { + let fakeNetrc: {machines: Record, 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 @@ -243,14 +287,14 @@ 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( @@ -258,12 +302,29 @@ describe('accounts', function () { ) }) - 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 () { @@ -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 () {