diff --git a/packages/cli/src/commands/pg/credentials/destroy.ts b/packages/cli/src/commands/pg/credentials/destroy.ts new file mode 100644 index 0000000000..07b4e77d62 --- /dev/null +++ b/packages/cli/src/commands/pg/credentials/destroy.ts @@ -0,0 +1,51 @@ +import color from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import * as Heroku from '@heroku-cli/schema' +import {essentialPlan} from '../../../lib/pg/util' +import {getAddon} from '../../../lib/pg/fetcher' +import pgHost from '../../../lib/pg/host' +import confirmApp from '../../../lib/apps/confirm-app' + +export default class Destroy extends Command { + static topic = 'pg'; + static description = 'destroy credential within database'; + static example = '$ heroku pg:credentials:destroy postgresql-transparent-56874 --name cred-name -a woodstock-production'; + static flags = { + name: flags.string({char: 'n', required: true, description: 'unique identifier for the credential'}), + confirm: flags.string({char: 'c'}), + app: flags.app({required: true}), + }; + + static args = { + database: Args.string(), + }; + + public async run(): Promise { + const {flags, args} = await this.parse(Destroy) + const {database} = args + const {app, name, confirm} = flags + if (name === 'default') { + throw new Error('Default credential cannot be destroyed.') + } + + const db = await getAddon(this.heroku, app, database) + if (essentialPlan(db)) { + throw new Error("You can't destroy the default credential on Essential-tier databases.") + } + + const {body: attachments} = await this.heroku.get(`/addons/${db.name}/addon-attachments`) + const credAttachments = attachments.filter(a => a.namespace === `credential:${name}`) + const credAttachmentApps = Array.from(new Set(credAttachments.map(a => a.app?.name))) + if (credAttachmentApps.length > 0) + throw new Error(`Credential ${name} must be detached from the app${credAttachmentApps.length > 1 ? 's' : ''} ${credAttachmentApps.map(appName => color.app(appName || '')) + .join(', ')} before destroying.`) + + await confirmApp(app, confirm) + ux.action.start(`Destroying credential ${color.cyan.bold(name)}`) + await this.heroku.delete(`/postgres/v0/databases/${db.name}/credentials/${encodeURIComponent(name)}`, {hostname: pgHost()}) + ux.action.stop() + ux.log(`The credential has been destroyed within ${db.name}.`) + ux.log(`Database objects owned by ${name} will be assigned to the default credential.`) + } +} diff --git a/packages/cli/src/lib/pg/fetcher.ts b/packages/cli/src/lib/pg/fetcher.ts index e3fd82e270..b6c81b4989 100644 --- a/packages/cli/src/lib/pg/fetcher.ts +++ b/packages/cli/src/lib/pg/fetcher.ts @@ -104,6 +104,6 @@ async function allAttachments(heroku: APIClient, app: string) { return attachments.filter((a: AddOnAttachmentWithConfigVarsAndPlan) => a.addon.plan?.name?.startsWith('heroku-postgresql')) } -export async function getAddon(heroku: APIClient, app: string, db: string) { +export async function getAddon(heroku: APIClient, app: string, db = 'DATABASE_URL') { return ((await attachment(heroku, app, db))).addon } diff --git a/packages/cli/test/unit/commands/pg/credentials/destroy.unit.test.ts b/packages/cli/test/unit/commands/pg/credentials/destroy.unit.test.ts new file mode 100644 index 0000000000..b9e6e4eacb --- /dev/null +++ b/packages/cli/test/unit/commands/pg/credentials/destroy.unit.test.ts @@ -0,0 +1,142 @@ +import {stderr, stdout} from 'stdout-stderr' +import Cmd from '../../../../../src/commands/pg/credentials/destroy' +import runCommand from '../../../../helpers/runCommand' +import * as nock from 'nock' +import expectOutput from '../../../../helpers/utils/expectOutput' +import {expect} from 'chai' +import heredoc from 'tsheredoc' +import stripAnsi = require('strip-ansi') + +describe('pg:credentials:destroy', () => { + const addon = { + name: 'postgres-1', plan: {name: 'heroku-postgresql:standard-0'}, + } + afterEach(() => { + nock.cleanAll() + }) + + it('destroys the credential', async () => { + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon}]) + nock('https://api.data.heroku.com') + .delete('/postgres/v0/databases/postgres-1/credentials/credname') + .reply(200) + const attachments = [ + { + app: {name: 'myapp'}, addon: {id: 100, name: 'postgres-1'}, config_vars: ['HEROKU_POSTGRESQL_PINK_URL'], + }, + ] + nock('https://api.heroku.com') + .get('/addons/postgres-1/addon-attachments') + .reply(200, attachments) + + await runCommand(Cmd, [ + '--app', + 'myapp', + '--name', + 'credname', + '--confirm', + 'myapp', + ]) + expectOutput(stderr.output, heredoc(` + Destroying credential credname... + Destroying credential credname... done + `)) + expectOutput(stdout.output, heredoc(` + The credential has been destroyed within postgres-1. + Database objects owned by credname will be assigned to the default credential. + `)) + }) + + it('throws an error when the db is starter plan', async () => { + const hobbyAddon = { + name: 'postgres-1', plan: {name: 'heroku-postgresql:hobby-dev'}, + } + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon: hobbyAddon}]) + + const err = "You can't destroy the default credential on Essential-tier databases." + await runCommand(Cmd, [ + '--app', + 'myapp', + '--name', + 'jeff', + ]).catch((error: Error) => { + expect(error.message).to.equal(err) + }) + }) + + it('throws an error when the db is numbered essential plan', async () => { + const essentialAddon = { + name: 'postgres-1', plan: {name: 'heroku-postgresql:essential-0'}, + } + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon: essentialAddon}]) + const err = "You can't destroy the default credential on Essential-tier databases." + await runCommand(Cmd, [ + '--app', + 'myapp', + '--name', + 'gandalf', + ]).catch((error: Error) => { + expect(error.message).to.equal(err) + }) + }) + + it('throws an error when the credential is still used for an attachment', async () => { + const attachments = [ + { + app: {name: 'myapp'}, addon: {id: 100, name: 'postgres-1'}, config_vars: ['HEROKU_POSTGRESQL_PINK_URL'], + }, { + app: {name: 'otherapp'}, addon: {id: 100, name: 'postgres-1'}, namespace: 'credential:gandalf', config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], + }, + ] + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon}]) + nock('https://api.heroku.com') + .get('/addons/postgres-1/addon-attachments') + .reply(200, attachments) + const err = 'Credential gandalf must be detached from the app ⬢ otherapp before destroying.' + await runCommand(Cmd, [ + '--app', + 'myapp', + '--name', + 'gandalf', + ]).catch((error: Error) => { + expect(stripAnsi(error.message)).to.equal(err) + }) + }) + + it('only mentions an app with multiple attachments once', async () => { + const attachments = [ + { + app: {name: 'myapp'}, addon: {id: 100, name: 'postgres-1'}, config_vars: ['HEROKU_POSTGRESQL_PINK_URL'], + }, { + app: {name: 'otherapp'}, addon: {id: 100, name: 'postgres-1'}, namespace: 'credential:gandalf', config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], + }, { + app: {name: 'otherapp'}, addon: {id: 100, name: 'postgres-1'}, namespace: 'credential:gandalf', config_vars: ['HEROKU_POSTGRESQL_RED_URL'], + }, { + app: {name: 'yetanotherapp'}, addon: {id: 100, name: 'postgres-1'}, namespace: 'credential:gandalf', config_vars: ['HEROKU_POSTGRESQL_BLUE_URL'], + }, + ] + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon}]) + nock('https://api.heroku.com') + .get('/addons/postgres-1/addon-attachments') + .reply(200, attachments) + const err = 'Credential gandalf must be detached from the apps ⬢ otherapp, ⬢ yetanotherapp before destroying.' + await runCommand(Cmd, [ + '--app', + 'myapp', + '--name', + 'gandalf', + ]).catch((error: Error) => { + expect(stripAnsi(error.message)).to.equal(err) + }) + }) +}) diff --git a/packages/pg-v5/commands/credentials/destroy.js b/packages/pg-v5/commands/credentials/destroy.js deleted file mode 100644 index e0c1ba38fd..0000000000 --- a/packages/pg-v5/commands/credentials/destroy.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict' - -const cli = require('heroku-cli-util') - -async function run(context, heroku) { - const fetcher = require('../../lib/fetcher')(heroku) - const host = require('../../lib/host') - const util = require('../../lib/util') - - const {app, args, flags} = context - let cred = flags.name - - if (cred === 'default') { - throw new Error('Default credential cannot be destroyed.') - } - - let db = await fetcher.addon(app, args.database) - if (util.essentialPlan(db)) { - throw new Error("You can't destroy the default credential on Essential-tier databases.") - } - - let attachments = await heroku.get(`/addons/${db.name}/addon-attachments`) - let credAttachments = attachments.filter(a => a.namespace === `credential:${flags.name}`) - let credAttachmentApps = Array.from(new Set(credAttachments.map(a => a.app.name))) - if (credAttachmentApps.length > 0) throw new Error(`Credential ${flags.name} must be detached from the app${credAttachmentApps.length > 1 ? 's' : ''} ${credAttachmentApps.map(name => cli.color.app(name)).join(', ')} before destroying.`) - - await cli.confirmApp(app, flags.confirm, 'WARNING: Destructive action') - - await cli.action(`Destroying credential ${cli.color.cmd(cred)}`, (async function () { - await heroku.delete(`/postgres/v0/databases/${db.name}/credentials/${encodeURIComponent(cred)}`, {host: host(db)}) - })()) - - cli.log(`The credential has been destroyed within ${db.name}.`) - cli.log(`Database objects owned by ${cred} will be assigned to the default credential.`) -} - -module.exports = { - topic: 'pg', - command: 'credentials:destroy', - description: 'destroy credential within database', - needsApp: true, - needsAuth: true, - help: `Example: - - heroku pg:credentials:destroy postgresql-transparent-56874 --name cred-name -a woodstock-production -`, - args: [{name: 'database', optional: true}], - flags: [ - {name: 'name', char: 'n', hasValue: true, required: true, description: 'unique identifier for the credential'}, - {name: 'confirm', char: 'c', hasValue: true}, - ], - run: cli.command({preauth: true}, run), -} diff --git a/packages/pg-v5/index.js b/packages/pg-v5/index.js index 9caf92ac1c..e1ea4aa74e 100644 --- a/packages/pg-v5/index.js +++ b/packages/pg-v5/index.js @@ -15,7 +15,6 @@ exports.commands = flatten([ require('./commands/connection_pooling'), require('./commands/copy'), require('./commands/credentials'), - require('./commands/credentials/destroy'), require('./commands/credentials/repair_default'), require('./commands/credentials/rotate'), require('./commands/credentials/url'), diff --git a/packages/pg-v5/test/unit/commands/credentials/destroy.unit.test.js b/packages/pg-v5/test/unit/commands/credentials/destroy.unit.test.js deleted file mode 100644 index b9c21325b9..0000000000 --- a/packages/pg-v5/test/unit/commands/credentials/destroy.unit.test.js +++ /dev/null @@ -1,158 +0,0 @@ -'use strict' -/* global beforeEach afterEach */ - -const cli = require('heroku-cli-util') -const {expect} = require('chai') -const nock = require('nock') -const proxyquire = require('proxyquire') - -const db = { - database: 'mydb', - host: 'foo.com', - user: 'jeff', - password: 'pass', - url: {href: 'postgres://jeff:pass@foo.com/mydb'}, -} - -const addon = { - name: 'postgres-1', - plan: {name: 'heroku-postgresql:standard-0'}, -} - -const fetcher = () => { - return { - database: () => db, - addon: () => addon, - } -} - -const cmd = proxyquire('../../../../commands/credentials/destroy', { - '../../lib/fetcher': fetcher, -}) - -describe('pg:credentials:destroy', () => { - let api - let pg - - beforeEach(() => { - api = nock('https://api.heroku.com') - pg = nock('https://api.data.heroku.com') - cli.mockConsole() - }) - - afterEach(() => { - nock.cleanAll() - api.done() - }) - - it('destroys the credential', () => { - pg.delete('/postgres/v0/databases/postgres-1/credentials/credname').reply(200) - let attachments = [ - { - app: {name: 'myapp'}, - addon: {id: 100, name: 'postgres-1'}, - config_vars: ['HEROKU_POSTGRESQL_PINK_URL'], - }, - ] - api.get('/addons/postgres-1/addon-attachments').reply(200, attachments) - return cmd.run({app: 'myapp', args: {}, flags: {name: 'credname', confirm: 'myapp'}}) - .then(() => expect(cli.stderr).to.equal('Destroying credential credname... done\n')) - .then(() => expect(cli.stdout).to.equal(`The credential has been destroyed within postgres-1. -Database objects owned by credname will be assigned to the default credential. -`)) - }) - - it('throws an error when the db is starter plan', () => { - const hobbyAddon = { - name: 'postgres-1', - plan: {name: 'heroku-postgresql:hobby-dev'}, - } - - const fetcher = () => { - return { - database: () => db, - addon: () => hobbyAddon, - } - } - - const cmd = proxyquire('../../../../commands/credentials/destroy', { - '../../lib/fetcher': fetcher, - }) - - const err = "You can't destroy the default credential on Essential-tier databases." - return expect(cmd.run({app: 'myapp', args: {}, flags: {name: 'jeff'}})).to.be.rejectedWith(Error, err) - }) - - it('throws an error when the db is numbered essential plan', () => { - const essentialAddon = { - name: 'postgres-1', - plan: {name: 'heroku-postgresql:essential-0'}, - } - - const fetcher = () => { - return { - database: () => db, - addon: () => essentialAddon, - } - } - - const cmd = proxyquire('../../../../commands/credentials/destroy', { - '../../lib/fetcher': fetcher, - }) - - const err = "You can't destroy the default credential on Essential-tier databases." - return expect(cmd.run({app: 'myapp', args: {}, flags: {name: 'jeff'}})).to.be.rejectedWith(Error, err) - }) - - it('throws an error when the credential is still used for an attachment', () => { - let attachments = [ - { - app: {name: 'myapp'}, - addon: {id: 100, name: 'postgres-1'}, - config_vars: ['HEROKU_POSTGRESQL_PINK_URL'], - }, - { - app: {name: 'otherapp'}, - addon: {id: 100, name: 'postgres-1'}, - namespace: 'credential:jeff', - config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], - }, - ] - api.get('/addons/postgres-1/addon-attachments').reply(200, attachments) - - const err = 'Credential jeff must be detached from the app otherapp before destroying.' - return expect(cmd.run({app: 'myapp', args: {}, flags: {name: 'jeff'}})).to.be.rejectedWith(Error, err) - }) - - it('only mentions an app with multiple attachments once', () => { - let attachments = [ - { - app: {name: 'myapp'}, - addon: {id: 100, name: 'postgres-1'}, - config_vars: ['HEROKU_POSTGRESQL_PINK_URL'], - }, - { - app: {name: 'otherapp'}, - addon: {id: 100, name: 'postgres-1'}, - namespace: 'credential:jeff', - config_vars: ['HEROKU_POSTGRESQL_PURPLE_URL'], - }, - { - app: {name: 'otherapp'}, - addon: {id: 100, name: 'postgres-1'}, - namespace: 'credential:jeff', - config_vars: ['HEROKU_POSTGRESQL_RED_URL'], - }, - { - app: {name: 'yetanotherapp'}, - addon: {id: 100, name: 'postgres-1'}, - namespace: 'credential:jeff', - config_vars: ['HEROKU_POSTGRESQL_BLUE_URL'], - }, - ] - api.get('/addons/postgres-1/addon-attachments').reply(200, attachments) - - const err = 'Credential jeff must be detached from the apps otherapp, yetanotherapp before destroying.' - return expect(cmd.run({app: 'myapp', args: {}, flags: {name: 'jeff'}})).to.be.rejectedWith(Error, err) - }) -})