Skip to content

Commit

Permalink
Completed unit test conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
justinwilaby committed Mar 18, 2024
1 parent 657b20f commit daa40cf
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 104 deletions.
25 changes: 15 additions & 10 deletions packages/cli/src/commands/redis/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,14 @@ async function redisCLI(uri: ClientRequestArgs, client: Writable): Promise<void>

async function bastionConnect(uri: URL, bastions: string, config: Record<string, unknown>, preferNativeTls: boolean) {
const tunnel: Client = await new Promise(resolve => {
const tunnel = new Client()
tunnel.on('ready', () => resolve(tunnel))
const ssh2 = new Client()
resolve(ssh2)
ssh2.once('ready', () => resolve(ssh2))
ssh2.connect({
host: bastions.split(',')[0],
username: 'bastion',
privateKey: match(config, /_BASTION_KEY/) ?? '',
})
})
const localPort = await portfinder.getPortPromise({startPort: 49152, stopPort: 65535})
const stream: Duplex = await promisify(tunnel.forwardOut)('localhost', localPort, uri.hostname, Number.parseInt(uri.port, 10))
Expand All @@ -127,12 +133,6 @@ async function bastionConnect(uri: URL, bastions: string, config: Record<string,
stream.on('close', () => tunnel.end())
stream.on('end', () => client.end())

tunnel.connect({
host: bastions.split(',')[0],
username: 'bastion',
privateKey: match(config, /_BASTION_KEY/) ?? '',
})

return redisCLI(urlToHttpOptions(uri), client)
}

Expand All @@ -146,7 +146,7 @@ function match(config: Record<string, unknown>, lookup: RegExp): string | null {
return null
}

function maybeTunnel(redis: RedisFormation, config: Record<string, unknown>) {
async function maybeTunnel(redis: RedisFormation, config: Record<string, unknown>) {
const bastions = match(config, /_BASTIONS/)
const hobby = redis.plan.indexOf('hobby') === 0
const preferNativeTls = redis.prefer_native_tls
Expand Down Expand Up @@ -184,11 +184,16 @@ export default class Cli extends Command {
database: Args.string(),
}

static examples = [
'$ heroku redis:cli --app=my-app my-database',
'$ heroku redis:cli --app=my-app --confirm my-database',
]

public async run(): Promise<void> {
const {flags, args} = await this.parse(Cli)
const addon = await getRedisAddon(flags.app, args.database, this.heroku)
const configVars = await getRedisConfigVars(addon, this.heroku)
const {body: redis} = await getRedisFormation(this.heroku, addon.name)
const {body: redis} = await getRedisFormation(this.heroku, addon.id)
if (redis.plan.startsWith('shield-')) {
ux.error('\n Using redis:cli on Heroku Redis shield plans is not supported.\n Please see Heroku DevCenter for more details: https://devcenter.heroku.com/articles/shield-private-space#shield-features\n ', {exit: 1})
}
Expand Down
12 changes: 4 additions & 8 deletions packages/cli/src/lib/redis/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface RedisFormation {
}

export function makeAddonsFilter(filter = '') {
const matcher = new RegExp(`/(${filter})/ig`)
const matcher = new RegExp(`(${filter})`, 'ig')

function matches(addon: Required<AddOn>) {
for (let i = 0; i < addon.config_vars.length; i++) {
Expand All @@ -39,9 +39,7 @@ export function makeAddonsFilter(filter = '') {

function onResponse(addons: Required<AddOn>[]) {
const redisAddons = []
// eslint-disable-next-line unicorn/no-for-loop
for (let i = 0; i < addons.length; i++) {
const addon = addons[i]
for (const addon of addons) {
const service = addon.addon_service.name ?? ''

if (service.indexOf(ADDON) === 0 && (!filter || matches(addon))) {
Expand All @@ -64,9 +62,7 @@ export async function getRedisAddon(appId: string, database: string | undefined,
if (addons.length === 0) {
ux.error('No Redis instances found.', {exit: 1})
} else if (addons.length > 1) {
const names = addons.map(function (addon) {
return addon.name
})
const names = addons.map(addon => addon.name)
ux.error(`Please specify a single instance. Found: ${names.join(', ')}`, {exit: 1})
}

Expand All @@ -93,7 +89,7 @@ export async function info(heroku: APIClient, appId: string, database: string, j
return promiseSettledResult.value.body
}

const {message, statusCode} = promiseSettledResult.reason as {message: string, statusCode: number}
const {message, statusCode} = promiseSettledResult.reason as { message: string, statusCode: number }
if (statusCode !== 404) {
ux.error(message, {exit: 1})
}
Expand Down
131 changes: 75 additions & 56 deletions packages/cli/test/unit/commands/redis/cli.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,58 @@
import {stdout, stderr} from 'stdout-stderr'
import runCommand, {GenericCmd} from '../../../helpers/runCommand'
import {SinonStub} from 'sinon'
import {CLIError} from '@oclif/core/lib/errors'

import * as nock from 'nock'
import * as sinon from 'sinon'
import {noCallThru} from 'proxyquire'
import {expect} from 'chai'
import {Duplex} from 'node:stream'

const nock = require('nock')
const sinon = require('sinon')
const proxyquire = require('proxyquire')
.noCallThru()
const expect = require('chai').expect
const Duplex = require('stream').Duplex
const EventEmitter = require('events').EventEmitter

describe('heroku redis:cli', async () => {
const command = proxyquire('../../../commands/cli.js', {net: {}, tls: {}, ssh2: {}})
require('./shared.unit.test.ts')
.shouldHandleArgs(command)
})
class Client extends Duplex {
_write() {}

_read() {
this.emit('end')
}
}

class Tunnel extends EventEmitter {
forwardOut = sinon.stub().yields(null, new Client())
connect = sinon.stub().callsFake(() => this.emit('ready'))
end = sinon.stub()
}
const addonId = '1dcb269b-8be5-4132-8aeb-e3f3c7364958'
const appId = '7b0ae612-8775-4502-a5b5-2b45a4d18b2d'
describe('heroku redis:cli', async () => {
describe('heroku redis:cli', async () => {
const proxyquire = noCallThru()
const command = proxyquire('../../../commands/cli.js', {net: {}, tls: {}, ssh2: {}})
require('./shared.unit.test.ts').shouldHandleArgs(command)
})

let command: GenericCmd
let net: { connect: SinonStub }
let tls: { connect: SinonStub }
let tunnel: { forwardOut: SinonStub, connect: SinonStub, end: SinonStub }
const addonId = '1dcb269b-8be5-4132-8aeb-e3f3c7364958'
const appId = '7b0ae612-8775-4502-a5b5-2b45a4d18b2d'
let net: {connect: SinonStub}
let tls: {connect: SinonStub}
let tunnel: {forwardOut: SinonStub, connect: SinonStub, end: SinonStub}

beforeEach(function () {
class Client extends Duplex {
_write() {}
_read() {
this.emit('end')
}
}

net = {
connect: sinon.stub().returns(new Client()),
}
tls = {
connect: sinon.stub().returns(new Client()),
}

class Tunnel extends EventEmitter {
forwardOut = sinon.stub().yields(null, new Client())
connect = sinon.stub().callsFake(() => this.emit('ready'))
end = sinon.stub()
}

const ssh2 = {
Client: function () {
tunnel = new Tunnel()
return tunnel
},
}
const proxyquire = noCallThru()
const {default: Cmd} = proxyquire('../../../../src/commands/redis/cli', {net, tls, ssh2})
command = Cmd
})
Expand All @@ -73,7 +75,7 @@ describe('heroku redis:cli', async () => {
.get('/apps/example/config-vars')
.reply(200, {FOO: 'BAR'})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-haiku')
.get(`/redis/v0/databases/${addonId}`)
.reply(200, {
resource_url: 'redis://foobar:password@example.com:8649', plan: 'hobby',
})
Expand All @@ -86,8 +88,10 @@ describe('heroku redis:cli', async () => {
app.done()
configVars.done()
redis.done()
expect(stdout.output).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):\n')
expect(stderr.output).to.equal('')
const outputParts = stdout.output.split('\n')
expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):')
expect(outputParts[1]).to.equal('')
expect(outputParts[2]).to.equal('Disconnected from instance.')
expect(net.connect.called).to.equal(true)
})

Expand All @@ -109,7 +113,7 @@ describe('heroku redis:cli', async () => {
.get('/apps/example/config-vars')
.reply(200, {REDIS_TLS_URL: 'rediss://foobar:password@example.com:8649'})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-haiku')
.get(`/redis/v0/databases/${addonId}`)
.reply(200, {
resource_url: 'redis://foobar:password@example.com:8649', plan: 'hobby', prefer_native_tls: true,
})
Expand All @@ -122,8 +126,10 @@ describe('heroku redis:cli', async () => {
app.done()
configVars.done()
redis.done()
expect(stdout.output).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR, REDIS_TLS_URL):\n')
expect(stderr.output).to.equal('')
const outputParts = stdout.output.split('\n')
expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR, REDIS_TLS_URL):')
expect(outputParts[1]).to.equal('')
expect(outputParts[2]).to.equal('Disconnected from instance.')
expect(tls.connect.called).to.equal(true)
})

Expand All @@ -145,7 +151,7 @@ describe('heroku redis:cli', async () => {
.get('/apps/example/config-vars')
.reply(200, {FOO: 'BAR'})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-haiku')
.get(`/redis/v0/databases/${addonId}`)
.reply(200, {
resource_url: 'redis://foobar:password@example.com:8649', plan: 'premium-0',
})
Expand All @@ -158,8 +164,10 @@ describe('heroku redis:cli', async () => {
app.done()
configVars.done()
redis.done()
expect(stdout.output).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):\n')
expect(stderr.output).to.equal('')
const outputParts = stdout.output.split('\n')
expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):')
expect(outputParts[1]).to.equal('')
expect(outputParts[2]).to.equal('Disconnected from instance.')
expect(tls.connect.called).to.equal(true)
})

Expand All @@ -181,7 +189,7 @@ describe('heroku redis:cli', async () => {
.get('/apps/example/config-vars')
.reply(200, {FOO: 'BAR'})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-haiku')
.get(`/redis/v0/databases/${addonId}`)
.reply(200, {
resource_url: 'redis://foobar:password@example.com:8649', plan: 'shield-9',
})
Expand All @@ -194,15 +202,18 @@ describe('heroku redis:cli', async () => {
])
expect(true, 'cli command should fail!').to.equal(false)
} catch (error) {
const {code} = error as { code: number }
expect(error).to.be.an.instanceof(Error)
expect(code).to.equal(1)
expect(error).to.be.an.instanceof(CLIError)

if (error instanceof CLIError) {
const {oclif: {exit}, message} = error
expect(exit).to.equal(1)
expect(message).to.contain('Using redis:cli on Heroku Redis shield plans is not supported.')
}
}

await app.done()
await redis.done()
await configVars.done()
expect(stderr.output).to.contain('Using redis:cli on Heroku Redis shield plans is not supported.')
app.done()
redis.done()
configVars.done()
})

it('# for bastion it uses tunnel.connect', async () => {
Expand All @@ -223,21 +234,25 @@ describe('heroku redis:cli', async () => {
.get('/apps/example/config-vars')
.reply(200, {REDIS_BASTIONS: 'example.com'})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-haiku')
.get(`/redis/v0/databases/${addonId}`)
.reply(200, {
resource_url: 'redis://foobar:password@example.com:8649', plan: 'premium-0',
})

await runCommand(command, [
'--app',
'example',
'--confirm',
'example',
])

app.done()
configVars.done()
redis.done()
expect(stdout.output).to.equal('Connecting to redis-haiku (REDIS_URL):\n')
expect(stderr.output).to.equal('')
const outputParts = stdout.output.split('\n')
expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_URL):')
expect(outputParts[1]).to.equal('')
expect(outputParts[2]).to.equal('Disconnected from instance.')
})

it('# for private spaces bastion with prefer_native_tls, it uses tls.connect', async () => {
Expand All @@ -258,7 +273,7 @@ describe('heroku redis:cli', async () => {
.get('/apps/example/config-vars')
.reply(200, {REDIS_BASTIONS: 'example.com'})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-haiku')
.get(`/redis/v0/databases/${addonId}`)
.reply(200, {
resource_url: 'redis://foobar:password@example.com:8649', plan: 'private-7', prefer_native_tls: true,
})
Expand All @@ -271,8 +286,10 @@ describe('heroku redis:cli', async () => {
app.done()
configVars.done()
redis.done()
expect(stdout.output).to.equal('Connecting to redis-haiku (REDIS_URL):\n')
expect(stderr.output).to.equal('')
const outputParts = stdout.output.split('\n')
expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_URL):')
expect(outputParts[1]).to.equal('')
expect(outputParts[2]).to.equal('Disconnected from instance.')
expect(tls.connect.called).to.equal(true)
})

Expand Down Expand Up @@ -306,7 +323,7 @@ describe('heroku redis:cli', async () => {
REDIS_6_BASTION_KEY: 'key2',
})
const redis = nock('https://api.data.heroku.com:443')
.get('/redis/v0/databases/redis-sonnet')
.get('/redis/v0/databases/heroku-redis-addon-id-2')
.reply(200, {
resource_url: 'redis://foobar:password@redis-6.example.com:8649', plan: 'private-7', prefer_native_tls: true,
})
Expand All @@ -320,8 +337,10 @@ describe('heroku redis:cli', async () => {
app.done()
configVars.done()
redis.done()
expect(stdout.output).to.equal('Connecting to redis-sonnet (REDIS_6_URL):\n')
expect(stderr.output).to.equal('')
const outputParts = stdout.output.split('\n')
expect(outputParts[0]).to.equal('Connecting to redis-sonnet (REDIS_6_URL):')
expect(outputParts[1]).to.equal('')
expect(outputParts[2]).to.equal('Disconnected from instance.')
const connectArgs = tunnel.connect.args[0]
expect(connectArgs).to.have.length(1)
expect(connectArgs[0]).to.deep.equal({
Expand All @@ -332,7 +351,7 @@ describe('heroku redis:cli', async () => {
expect(localAddr).to.equal('localhost')
expect(localPort).to.be.a('number')
expect(remoteAddr).to.equal('redis-6.example.com')
expect(remotePort).to.equal('8649')
expect(remotePort).to.equal(8649)
const tlsConnectArgs = tls.connect.args[0]
expect(tlsConnectArgs).to.have.length(1)
const tlsConnectOptions = {
Expand Down
Loading

0 comments on commit daa40cf

Please sign in to comment.