Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: migrate access:index to oclif/core #2658

Merged
merged 10 commits into from
Feb 29, 2024
108 changes: 108 additions & 0 deletions packages/cli/src/commands/access/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import * as _ from 'lodash'
import {isTeamApp, getOwner} from '../../lib/access/utils'

type MemberData = {
email: string,
role: string,
permissions?: string
}

type AdminWithPermissions = Heroku.TeamMember & {
permissions?: Heroku.TeamAppPermission[],
}

function printJSON(collaborators: Heroku.TeamAppCollaborator[]) {
ux.log(JSON.stringify(collaborators, null, 2))
}

function buildTableColumns(showPermissions: boolean) {
const baseColumns = {
email: {
get: ({email}: any): string => color.cyan(email),
},
role: {
get: ({role}: any) => color.green(role),
},
}

if (showPermissions) {
return {
...baseColumns,
permissions: {},
}
}

return baseColumns
}

function printAccess(app: Heroku.App, collaborators: any[]) {
const showPermissions = isTeamApp(app.owner?.email)
collaborators = _.chain(collaborators)
.sortBy(c => c.email || c.user.email)
.reject(c => /herokumanager\.com$/.test(c.user.email))
.map(collab => {
const email = collab.user.email
const role = collab.role
const data: MemberData = {email: email, role: role || 'collaborator'}
if (showPermissions) {
data.permissions = _.map(_.sortBy(collab.permissions, 'name'), 'name').join(', ')
}

return data
})
.value()

const tableColumns = buildTableColumns(showPermissions)
ux.table(
collaborators,
tableColumns,
)
}

function buildCollaboratorsArray(collaboratorsRaw: Heroku.TeamAppCollaborator[], admins: Heroku.TeamMember[]) {
const collaboratorsNoAdmins = _.reject(collaboratorsRaw, {role: 'admin'})
return _.union(collaboratorsNoAdmins, admins)
}

export default class AccessIndex extends Command {
static description = 'list who has access to an app'
static topic = 'access'
static flags = {
app: flags.app({required: true}),
remote: flags.remote({char: 'r'}),
json: flags.boolean({description: 'output in json format'}),
}

public async run(): Promise<void> {
const {flags, argv, args} = await this.parse(AccessIndex)
const {app: appName, json} = flags
const {body: app} = await this.heroku.get<Heroku.App>(`/apps/${appName}`)
let {body: collaborators} = await this.heroku.get<Heroku.TeamAppCollaborator[]>(`/apps/${appName}/collaborators`)
if (isTeamApp(app.owner?.email)) {
const teamName = getOwner(app.owner?.email)
try {
const {body: members} = await this.heroku.get<Heroku.TeamMember[]>(`/teams/${teamName}/members`)
let admins: AdminWithPermissions[] = members.filter(member => member.role === 'admin')
const {body: adminPermissions} = await this.heroku.get<Heroku.TeamAppPermission[]>('/teams/permissions')
admins = _.forEach(admins, function (admin) {
k80bowman marked this conversation as resolved.
Show resolved Hide resolved
admin.user = {email: admin.email}
admin.permissions = adminPermissions
return admin
})
collaborators = buildCollaboratorsArray(collaborators, admins)
} catch (error: any) {
if (error.statusCode !== 403)
throw error
}
}

if (json)
printJSON(collaborators)
else
printAccess(app, collaborators)
}
}
11 changes: 11 additions & 0 deletions packages/cli/src/lib/access/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const isTeamApp = function (owner: string | undefined) {
return owner ? (/@herokumanager\.com$/.test(owner)) : false
}

export const getOwner = function (owner: string | undefined) {
if (owner && isTeamApp(owner)) {
return owner.split('@herokumanager.com')[0]
}

return owner
}
178 changes: 178 additions & 0 deletions packages/cli/test/helpers/stubs/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
'use strict'
eablack marked this conversation as resolved.
Show resolved Hide resolved
import * as nock from 'nock'

export function apps() {
return nock('https://api.heroku.com:443')
.get('/apps')
.reply(200, [
{name: 'my-team-app', owner: {email: 'team@herokumanager.com'}},
{name: 'myapp', owner: {email: 'foo@foo.com'}},
])
}

export function appCollaborators(collaborators =
[{user: {email: 'raulb@heroku.com'}, role: 'owner'},
{user: {email: 'jeff@heroku.com'}, role: 'collaborator'}]) {
return nock('https://api.heroku.com:443')
.get('/apps/myapp/collaborators')
.reply(200, collaborators)
}

export function appPermissions() {
return nock('https://api.heroku.com:443', {
reqheaders: {Accept: 'application/vnd.heroku+json; version=3'},
})
.get('/teams/permissions')
.reply(200, [
{name: 'deploy'},
{name: 'manage'},
{name: 'operate'},
{name: 'view'},
])
}

export function teams(teams = [
{name: 'enterprise a', role: 'collaborator', type: 'enterprise'},
{name: 'team a', role: 'collaborator', type: 'team'},
{name: 'enterprise b', role: 'admin', type: 'enterprise'},
{name: 'team b', role: 'admin', type: 'team'},
]) {
return nock('https://api.heroku.com:443')
.get('/teams')
.reply(200, teams)
}

export function teamApp(locked = false) {
return nock('https://api.heroku.com:443')
.get('/apps/myapp')
.reply(200, {
name: 'myapp',
owner: {email: 'myteam@herokumanager.com'},
locked: locked,
})
}

export function teamAppCollaboratorsWithPermissions() {
return nock('https://api.heroku.com:443', {
reqheaders: {Accept: 'application/vnd.heroku+json; version=3'},
})
.get('/apps/myapp/collaborators')
.reply(200, [
{permissions: [],
role: 'owner',
user: {email: 'myteam@herokumanager.com'},
},
{
permissions: [{name: 'deploy'}, {name: 'view'}],
role: 'member',
user: {email: 'bob@heroku.com'},
},
])
}

export function teamFeatures(features: any) {
return nock('https://api.heroku.com:443', {
reqheaders: {Accept: 'application/vnd.heroku+json; version=3'},
})
.get('/teams/myteam/features')
.reply(200, features)
}

export function teamInfo(type = 'enterprise') {
return nock('https://api.heroku.com:443', {
reqheaders: {Accept: 'application/vnd.heroku+json; version=3'},
})
.get('/teams/myteam')
.reply(200, {
name: 'myteam',
role: 'admin',
type: type,
})
}

export function teamInvites(invites = [
{
invited_by: {email: 'raulb@heroku.com'},
role: 'admin',
user: {email: 'invited-user@mail.com'},
},
]) {
return nock('https://api.heroku.com:443', {
reqheaders: {Accept: 'application/vnd.heroku+json; version=3.team-invitations'},
})
.get('/teams/myteam/invitations')
.reply(200, invites)
}

export function teamMembers(members = [
{
email: 'raulb@heroku.com',
role: 'admin',
user: {email: 'raulb@heroku.com'},
},
{
email: 'bob@heroku.com',
role: 'viewer',
user: {email: 'bob@heroku.com'},
},
{
email: 'peter@heroku.com',
role: 'collaborator',
user: {email: 'peter@heroku.com'},
},
]) {
return nock('https://api.heroku.com:443')
.get('/teams/myteam/members')
.reply(200, members)
}

export function personalApp() {
return nock('https://api.heroku.com:443')
.get('/apps/myapp')
.reply(200, {
name: 'myapp',
owner: {email: 'raulb@heroku.com'},
})
}

export function userAccount(email = 'raulb@heroku.com') {
return nock('https://api.heroku.com:443')
.get('/account')
.reply(200, {email})
}

export function userFeatureFlags(features: any) {
return nock('https://api.heroku.com:443')
.get('/account/features')
.reply(200, features)
}

export function variableSizeTeamInvites(teamSize: number) {
teamSize = (typeof (teamSize) === 'undefined') ? 1 : teamSize
const invites = []
for (let i = 0; i < teamSize; i++) {
invites.push({
role: 'member', user: {email: `invited-user-${i}@mail.com`},
})
}

return nock('https://api.heroku.com:443', {
reqheaders: {Accept: 'application/vnd.heroku+json; version=3.team-invitations'},
})
.get('/teams/myteam/invitations')
.reply(200, invites)
}

export function variableSizeTeamMembers(teamSize: number) {
teamSize = (typeof (teamSize) === 'undefined') ? 1 : teamSize
const teamMembers = []
for (let i = 0; i < teamSize; i++) {
teamMembers.push({email: `test${i}@heroku.com`,
role: 'admin',
user: {email: `test${i}@heroku.com`}})
}

return nock('https://api.heroku.com:443')
.get('/teams/myteam/members')
.reply(200, teamMembers)
}
13 changes: 13 additions & 0 deletions packages/cli/test/integration/access.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {expect, test} from '@oclif/test'

describe('access', () => {
test
.stdout()
.command(['access', '--app=heroku-cli-ci-smoke-test-app'])
.it('shows a table with access status', ctx => {
// This is asserting that logs are returned by checking for the presence of the first two
// digits of the year in the timetstamp
expect(ctx.stdout.includes('admin')).to.be.true
expect(ctx.stdout.includes('deploy, manage, operate, view')).to.be.true
})
})
50 changes: 50 additions & 0 deletions packages/cli/test/unit/commands/access/index.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {stdout, stderr} from 'stdout-stderr'
import * as nock from 'nock'
import {expect} from 'chai'
import Cmd from '../../../../src/commands/access/index'
import runCommand from '../../../helpers/runCommand'
import {
personalApp,
appCollaborators,
teamApp,
teamMembers,
appPermissions,
teamAppCollaboratorsWithPermissions,
} from '../../../helpers/stubs/get'

describe('heroku access', () => {
context('with personal app', () => {
afterEach(() => nock.cleanAll())
it('shows the app collaborators', () => {
const apiGetPersonalApp = personalApp()
const apiGetAppCollaborators = appCollaborators()
return runCommand(Cmd, [
'--app',
'myapp',
])
.then(() => expect(stdout.output).to.contain('jeff@heroku.com collaborator \n raulb@heroku.com owner'))
.then(() => expect('').to.eq(stderr.output))
.then(() => apiGetPersonalApp.done())
.then(() => apiGetAppCollaborators.done())
})
})
context('with team', () => {
afterEach(() => nock.cleanAll())
it('shows the app collaborators and hides the team collaborator record', () => {
const apiGetTeamApp = teamApp()
const apiGetOrgMembers = teamMembers()
const apiGetAppPermissions = appPermissions()
const apiGetTeamAppCollaboratorsWithPermissions = teamAppCollaboratorsWithPermissions()
return runCommand(Cmd, [
'--app',
'myapp',
])
.then(() => expect(stdout.output).to.contain('bob@heroku.com member deploy, view \n raulb@heroku.com admin deploy, manage, operate, view \n'))
.then(() => expect('').to.eq(stderr.output))
.then(() => apiGetTeamApp.done())
.then(() => apiGetOrgMembers.done())
.then(() => apiGetAppPermissions.done())
.then(() => apiGetTeamAppCollaboratorsWithPermissions.done())
})
})
})
1 change: 0 additions & 1 deletion packages/orgs-v5/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ exports.topics = [
]

exports.commands = flatten([
require('./commands/access'),
require('./commands/access/add'),
require('./commands/access/remove'),
require('./commands/access/update'),
Expand Down
1 change: 0 additions & 1 deletion packages/orgs-v5/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
"postpublish": "rm oclif.manifest.json",
"prepack": "oclif manifest",
"test": "nyc mocha ./test/**/*.unit.test.js && yarn lint",
"test:integration": "mocha './test/**/*.integration.test.js'",
"version": "oclif readme && git add README.md"
}
}

This file was deleted.

Loading
Loading