Skip to content

Commit 8d62e62

Browse files
committed
feat: force user to choose org for schema app before doing anything that will auto-assign one
1 parent a8044e3 commit 8d62e62

File tree

9 files changed

+142
-20
lines changed

9 files changed

+142
-20
lines changed

.changeset/fuzzy-ants-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smartthings/cli": patch
3+
---
4+
5+
Force user to choose organization for schema app before doing anything that will automatically assign one.

packages/cli/src/__tests__/commands/schema.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { SchemaApp, SchemaEndpoint } from '@smartthings/core-sdk'
22

3-
import { outputItemOrList, TableCommonListOutputProducer } from '@smartthings/cli-lib'
3+
import { APICommand, outputItemOrList, TableCommonListOutputProducer } from '@smartthings/cli-lib'
44

55
import SchemaCommand from '../../commands/schema'
6+
import { getSchemaAppEnsuringOrganization } from '../../lib/commands/schema-util'
67

78

9+
jest.mock('../../lib/commands/schema-util')
10+
811
describe('SchemaCommand', () => {
9-
const getSpy = jest.spyOn(SchemaEndpoint.prototype, 'get').mockImplementation()
1012
const listSpy = jest.spyOn(SchemaEndpoint.prototype, 'list').mockImplementation()
1113

1214
const outputItemOrListMock = jest.mocked(outputItemOrList<SchemaApp>)
@@ -71,16 +73,22 @@ describe('SchemaCommand', () => {
7173
})
7274

7375
it('calls correct get endpoint', async () => {
76+
const getSchemaAppMock = jest.mocked(getSchemaAppEnsuringOrganization)
77+
7478
await expect(SchemaCommand.run([])).resolves.not.toThrow()
7579

7680
const getFunction = outputItemOrListMock.mock.calls[0][4]
7781

7882
const schemaApp = { endpointAppId: 'schemaAppId' } as SchemaApp
79-
getSpy.mockResolvedValueOnce(schemaApp)
83+
getSchemaAppMock.mockResolvedValueOnce({ schemaApp, organizationWasUpdated: false })
8084

8185
await expect(getFunction('schemaAppId')).resolves.toStrictEqual(schemaApp)
82-
expect(getSpy).toHaveBeenCalledTimes(1)
83-
expect(getSpy).toHaveBeenCalledWith('schemaAppId')
86+
expect(getSchemaAppMock).toHaveBeenCalledTimes(1)
87+
expect(getSchemaAppMock).toHaveBeenCalledWith(
88+
expect.any(APICommand),
89+
'schemaAppId',
90+
{ profile: 'default' },
91+
)
8492
})
8593

8694
it('calls correct list endpoint', async () => {

packages/cli/src/__tests__/commands/schema/update.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,31 @@ import { inputItem, IOFormat, selectFromList } from '@smartthings/cli-lib'
44

55
import SchemaUpdateCommand from '../../../commands/schema/update'
66
import { addSchemaPermission } from '../../../lib/aws-utils'
7-
import { SchemaAppWithOrganization } from '../../../lib/commands/schema-util'
7+
import {
8+
getSchemaAppEnsuringOrganization,
9+
SchemaAppWithOrganization,
10+
} from '../../../lib/commands/schema-util'
811

912

1013
jest.mock('../../../lib/aws-utils')
14+
jest.mock('../../../lib/commands/schema-util')
1115

1216

1317
describe('SchemaUpdateCommand', () => {
1418
const updateSpy = jest.spyOn(SchemaEndpoint.prototype, 'update').mockResolvedValue({ status: 'success' })
1519
const listSpy = jest.spyOn(SchemaEndpoint.prototype, 'list')
1620
const logSpy = jest.spyOn(SchemaUpdateCommand.prototype, 'log').mockImplementation()
1721

18-
const schemaAppRequest = { appName: 'schemaApp' } as SchemaAppWithOrganization
22+
const schemaAppRequest = { appName: 'schemaApp' } as SchemaApp
1923
const schemaAppRequestWithOrganization = {
2024
...schemaAppRequest,
2125
organizationId: 'organization-id',
2226
} as SchemaAppWithOrganization
2327
const inputItemMock = jest.mocked(inputItem).mockResolvedValue([schemaAppRequestWithOrganization, IOFormat.JSON])
2428
const addSchemaPermissionMock = jest.mocked(addSchemaPermission)
2529
const selectFromListMock = jest.mocked(selectFromList).mockResolvedValue('schemaAppId')
30+
const getSchemaAppMock = jest.mocked(getSchemaAppEnsuringOrganization)
31+
.mockResolvedValue({ schemaApp: schemaAppRequest, organizationWasUpdated: false })
2632

2733
it('prompts user to select schema app', async () => {
2834
const schemaAppList = [{ appName: 'schemaApp' } as SchemaApp]
@@ -42,6 +48,12 @@ describe('SchemaUpdateCommand', () => {
4248
listItems: expect.any(Function),
4349
}),
4450
)
51+
expect(getSchemaAppMock).toHaveBeenCalledTimes(1)
52+
expect(getSchemaAppMock).toHaveBeenCalledWith(
53+
expect.any(SchemaUpdateCommand),
54+
'schemaAppId',
55+
{ profile: 'default' },
56+
)
4557

4658
const listFunction = selectFromListMock.mock.calls[0][2].listItems
4759

packages/cli/src/commands/invites/schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ export default class InvitesSchemaCommand extends APICommand<typeof InvitesSchem
5151
type InvitationProviderFunction = () => Promise<InvitationWithAppDetails[]>
5252
const listFn = (client: SmartThingsClient, appId?: string): InvitationProviderFunction =>
5353
async (): Promise<InvitationWithAppDetails[]> => {
54+
// We have to be careful to not use the method to get a single app. For more
55+
// details see `getSchemaAppEnsuringOrganization` in schema-utils.
5456
const apps = appId
55-
? [await client.schema.get(appId)]
57+
? (await client.schema.list({ includeAllOrganizations: true }))
58+
.filter(app => app.endpointAppId === appId)
5659
: await client.schema.list()
5760
return (await Promise.all(apps.map(async app => {
5861
return app.endpointAppId

packages/cli/src/commands/invites/schema/create.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
userInputProcessor,
1515
} from '@smartthings/cli-lib'
1616

17-
import { chooseSchemaApp } from '../../../lib/commands/schema-util'
17+
import { chooseSchemaApp, getSchemaAppEnsuringOrganization } from '../../../lib/commands/schema-util'
1818
import { getSingleInvite, InvitationWithAppDetails, tableFieldDefinitions } from '../../../lib/commands/invites-utils'
1919

2020

@@ -56,7 +56,7 @@ export default class InvitesSchemaCreateCommand extends APICommand<typeof Invite
5656
const updateFromUserInput = async (): Promise<string | CancelAction> => {
5757
const schemaAppId = await chooseSchemaApp(this, this.flags['schema-app'])
5858
if (!schemaAppsById.has(schemaAppId)) {
59-
const schemaApp = await this.client.schema.get(schemaAppId)
59+
const { schemaApp } = await getSchemaAppEnsuringOrganization(this, schemaAppId, this.flags)
6060
schemaAppsById.set(schemaAppId, schemaApp)
6161
}
6262
return schemaAppId
@@ -87,9 +87,9 @@ export default class InvitesSchemaCreateCommand extends APICommand<typeof Invite
8787

8888
async run(): Promise<void> {
8989
const createInvitation = async (_: unknown, input: SchemaAppInvitationCreate): Promise<InvitationWithAppDetails> => {
90-
// We don't need the full schema app but we need to call this to force some
91-
// bookkeeping in the back end for older apps.
92-
await this.client.schema.get(input.schemaAppId)
90+
// We don't need the full schema app but using `getSchemaAppEnsuringOrganization`
91+
// ensures there is a valid organization associated with the schema app.
92+
await getSchemaAppEnsuringOrganization(this, input.schemaAppId, this.flags)
9393
const idWrapper = await this.client.invitesSchema.create(input)
9494
return getSingleInvite(this.client, input.schemaAppId, idWrapper.invitationId)
9595
}

packages/cli/src/commands/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
outputItemOrList,
99
OutputItemOrListConfig,
1010
} from '@smartthings/cli-lib'
11+
import { getSchemaAppEnsuringOrganization } from '../lib/commands/schema-util'
1112

1213

1314
export default class SchemaCommand extends APIOrganizationCommand<typeof SchemaCommand.flags> {
@@ -56,7 +57,7 @@ export default class SchemaCommand extends APIOrganizationCommand<typeof SchemaC
5657

5758
await outputItemOrList(this, config, this.args.id,
5859
() => this.client.schema.list({ includeAllOrganizations: this.flags['all-organizations'] }),
59-
id => this.client.schema.get(id),
60+
async id => (await getSchemaAppEnsuringOrganization(this, id, this.flags)).schemaApp,
6061
)
6162
}
6263
}

packages/cli/src/commands/schema/update.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import {
1212
} from '@smartthings/cli-lib'
1313

1414
import { addSchemaPermission } from '../../lib/aws-utils'
15-
import { getSchemaAppUpdateFromUser, SchemaAppWithOrganization } from '../../lib/commands/schema-util'
15+
import {
16+
getSchemaAppEnsuringOrganization,
17+
getSchemaAppUpdateFromUser,
18+
SchemaAppWithOrganization,
19+
} from '../../lib/commands/schema-util'
1620

1721

1822
export default class SchemaUpdateCommand extends APIOrganizationCommand<typeof SchemaUpdateCommand.flags> {
@@ -49,11 +53,18 @@ export default class SchemaUpdateCommand extends APIOrganizationCommand<typeof S
4953
listItems: () => this.client.schema.list(),
5054
})
5155

56+
const { schemaApp: original, organizationWasUpdated } =
57+
await getSchemaAppEnsuringOrganization(this, id, this.flags)
58+
if (original.certificationStatus === 'wwst' || original.certificationStatus === 'cst') {
59+
const cancelMsgBase =
60+
'Schema apps that have already been certified cannot be updated via the CLI'
61+
const cancelMsg = organizationWasUpdated
62+
? cancelMsgBase + ' so further updates are not possible.'
63+
: cancelMsgBase + '.'
64+
this.cancel(cancelMsg)
65+
}
66+
5267
const getInputFromUser = async (): Promise<SchemaAppRequest> => {
53-
const original = await this.client.schema.get(id)
54-
if (original.certificationStatus === 'wwst' || original.certificationStatus === 'cst') {
55-
this.cancel('Schema apps that have already been certified cannot be updated via the CLI.')
56-
}
5768
return getSchemaAppUpdateFromUser(this, original, this.flags['dry-run'])
5869
}
5970

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { OrganizationResponse } from '@smartthings/core-sdk'
2+
3+
import {
4+
APICommand,
5+
ChooseOptions,
6+
chooseOptionsWithDefaults,
7+
selectFromList,
8+
SelectFromListConfig,
9+
stringTranslateToId,
10+
} from '@smartthings/cli-lib'
11+
12+
13+
export const chooseOrganization = async (
14+
command: APICommand<typeof APICommand.flags>,
15+
appFromArg?: string,
16+
options?: Partial<ChooseOptions<OrganizationResponse>>,
17+
): Promise<string> => {
18+
const opts = chooseOptionsWithDefaults(options)
19+
const config: SelectFromListConfig<OrganizationResponse> = {
20+
itemName: 'organization',
21+
primaryKeyName: 'organizationId',
22+
sortKeyName: 'name',
23+
}
24+
const listItems = (): Promise<OrganizationResponse[]> => command.client.organizations.list()
25+
const preselectedId = opts.allowIndex
26+
? await stringTranslateToId(config, appFromArg, listItems)
27+
: appFromArg
28+
return selectFromList(command, config, { preselectedId, listItems })
29+
}

packages/cli/src/lib/commands/schema-util.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { OrganizationResponse, SchemaApp, SchemaAppRequest, SmartThingsURLProvider, ViperAppLinks } from '@smartthings/core-sdk'
1+
import {
2+
OrganizationResponse,
3+
SchemaApp,
4+
SchemaAppRequest,
5+
SmartThingsURLProvider,
6+
ViperAppLinks,
7+
} from '@smartthings/core-sdk'
28

39
import {
410
APICommand,
@@ -19,12 +25,16 @@ import {
1925
selectFromList,
2026
SelectFromListConfig,
2127
staticDef,
28+
stdinIsTTY,
29+
stdoutIsTTY,
2230
stringDef,
2331
stringTranslateToId,
2432
undefinedDef,
2533
updateFromUserInput,
2634
} from '@smartthings/cli-lib'
2735
import { awsHelpText } from '../aws-utils'
36+
import { chooseOrganization } from './organization-util'
37+
import { CLIError } from '@oclif/core/lib/errors'
2838

2939

3040
export const SCHEMA_AWS_PRINCIPAL = '148790070172'
@@ -189,3 +199,46 @@ export const chooseSchemaApp = async (command: APICommand<typeof APICommand.flag
189199
: schemaAppFromArg
190200
return selectFromList(command, config, { preselectedId, listItems, autoChoose: true })
191201
}
202+
203+
// The endpoint to get a schema app automatically assigns the users org to an app if it
204+
// doesn't have one already. This causes a problem if the app is certified because the user
205+
// organization is almost certainly the wrong one and the user can't change it after it's been
206+
// set. So, here we check to see if the app has an organization before we query it and
207+
// prompt the user for the correct organization.
208+
export const getSchemaAppEnsuringOrganization = async (
209+
command: APICommand<typeof APICommand.flags>,
210+
schemaAppId: string,
211+
flags: {
212+
json: boolean
213+
yaml: boolean
214+
input?: string
215+
output?: string
216+
},
217+
): Promise<{ schemaApp: SchemaApp; organizationWasUpdated: boolean }> => {
218+
const apps = await command.client.schema.list()
219+
const appFromList = apps.find(app => app.endpointAppId === schemaAppId)
220+
if (appFromList && !appFromList.organizationId) {
221+
if (flags.json || flags.yaml || flags.output || flags.input || !stdinIsTTY() || !stdoutIsTTY()) {
222+
throw new CLIError(
223+
'Schema app does not have an organization associated with it.\n' +
224+
`Please run "smartthings schema ${schemaAppId}" and choose an organization when prompted.`,
225+
)
226+
}
227+
// If we found an app but it didn't have an organization, ask the user to choose one.
228+
// (If we didn't find an app at all, it's safe to use the single get because that means
229+
// either it doesn't exist (bad app id) or it already has an organization.)
230+
console.log(
231+
`The schema "${appFromList.appName}" (${schemaAppId}) does not have an organization\n` +
232+
'You must choose one now.',
233+
)
234+
const organizationId = await chooseOrganization(command)
235+
const organization = await command.client.organizations.get(organizationId)
236+
// eslint-disable-next-line @typescript-eslint/naming-convention
237+
const orgClient = command.client.clone({ 'X-ST-Organization': organizationId })
238+
const schemaApp = await orgClient.schema.get(schemaAppId)
239+
console.log(`\nSchema app "${schemaApp.appName} (${schemaAppId}) is now associated with ` +
240+
`organization ${organization.name} (${organizationId}).\n`)
241+
return { schemaApp, organizationWasUpdated: true }
242+
}
243+
return { schemaApp: await command.client.schema.get(schemaAppId), organizationWasUpdated: false }
244+
}

0 commit comments

Comments
 (0)