diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index ca3937e5a8..f2c1804b62 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -1,17 +1,45 @@ -import {AppManagementClient, GatedExtensionTemplate, allowedTemplates, diffAppModules} from './app-management-client.js' +import { + AppManagementClient, + GatedExtensionTemplate, + allowedTemplates, + diffAppModules, + encodedGidFromId, +} from './app-management-client.js' import {AppModule} from './app-management-client/graphql/app-version-by-id.js' +import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js' import {testUIExtension, testRemoteExtensionTemplates, testOrganizationApp} from '../../models/app/app.test-data.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {describe, expect, test, vi} from 'vitest' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {fetch} from '@shopify/cli-kit/node/http' +import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform' vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/business-platform') const extensionA = await testUIExtension({uid: 'extension-a-uuid'}) const extensionB = await testUIExtension({uid: 'extension-b-uuid'}) const extensionC = await testUIExtension({uid: 'extension-c-uuid'}) +const templateWithoutRules: GatedExtensionTemplate = testRemoteExtensionTemplates[0]! +const allowedTemplate: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['allowedFlag'], + minimumCliVersion: '1.0.0', +} +const templateDisallowedByCliVersion: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationBetaFlags: ['allowedFlag'], + // minimum CLI version is higher than the current CLI version + minimumCliVersion: `1${CLI_KIT_VERSION}`, +} +const templateDisallowedByBetaFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[3]!, + // organization beta flag is not allowed + organizationBetaFlags: ['notAllowedFlag'], + minimumCliVersion: '1.0.0', +} + function moduleFromExtension(extension: ExtensionInstance): AppModule { return { uuid: extension.uid, @@ -49,21 +77,70 @@ describe('diffAppModules', () => { describe('templateSpecifications', () => { test('returns the templates with sortPriority to enforce order', async () => { // Given - const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(testRemoteExtensionTemplates)) + const orgApp = testOrganizationApp() + const templates: GatedExtensionTemplate[] = [templateWithoutRules, allowedTemplate] + const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(templates)) vi.mocked(fetch).mockImplementation(mockedFetch) + const mockedFetchFlagsResponse: OrganizationBetaFlagsQuerySchema = { + organization: { + id: encodedGidFromId(orgApp.organizationId), + flag_allowedFlag: true, + }, + } + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedFetchFlagsResponse) // When const client = new AppManagementClient() - const got = await client.templateSpecifications(testOrganizationApp()) + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + const got = await client.templateSpecifications(orgApp) const gotLabels = got.map((template) => template.name) const gotSortPriorities = got.map((template) => template.sortPriority) // Then - expect(got.length).toEqual(testRemoteExtensionTemplates.length) - expect(gotLabels).toEqual(testRemoteExtensionTemplates.map((template) => template.name)) + expect(got.length).toEqual(templates.length) + expect(gotLabels).toEqual(templates.map((template) => template.name)) expect(gotSortPriorities).toEqual(gotSortPriorities.sort()) }) + test('returns only allowed templates', async () => { + // Given + const orgApp = testOrganizationApp() + const templates: GatedExtensionTemplate[] = [templateWithoutRules, allowedTemplate, templateDisallowedByBetaFlag] + const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(templates)) + vi.mocked(fetch).mockImplementation(mockedFetch) + const mockedFetchFlagsResponse: OrganizationBetaFlagsQuerySchema = { + organization: { + id: encodedGidFromId(orgApp.organizationId), + flag_allowedFlag: true, + flag_notAllowedFlag: false, + }, + } + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedFetchFlagsResponse) + + // When + const client = new AppManagementClient() + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + const got = await client.templateSpecifications(orgApp) + const gotLabels = got.map((template) => template.name) + + // Then + expect(vi.mocked(businessPlatformOrganizationsRequest)).toHaveBeenCalledWith( + ` + query OrganizationBetaFlags($organizationId: OrganizationID!) { + organization(organizationId: $organizationId) { + id + flag_allowedFlag: hasFeatureFlag(handle: "allowedFlag") + flag_notAllowedFlag: hasFeatureFlag(handle: "notAllowedFlag") + } + }`, + 'business-platform-token', + orgApp.organizationId, + {organizationId: encodedGidFromId(orgApp.organizationId)}, + ) + const expectedAllowedTemplates = [templateWithoutRules, allowedTemplate] + expect(gotLabels).toEqual(expectedAllowedTemplates.map((template) => template.name)) + }) + test('fails with an error message when fetching the specifications list fails', async () => { // Given vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch')) @@ -80,24 +157,6 @@ describe('templateSpecifications', () => { describe('allowedTemplates', () => { test('filters templates by betas', async () => { // Given - const templateWithoutRules: GatedExtensionTemplate = testRemoteExtensionTemplates[0]! - const allowedTemplate: GatedExtensionTemplate = { - ...testRemoteExtensionTemplates[1]!, - organizationBetaFlags: ['allowedFlag'], - minimumCliVersion: '1.0.0', - } - const templateDisallowedByCliVersion: GatedExtensionTemplate = { - ...testRemoteExtensionTemplates[2]!, - organizationBetaFlags: ['allowedFlag'], - // minimum CLI version is higher than the current CLI version - minimumCliVersion: `1${CLI_KIT_VERSION}`, - } - const templateDisallowedByBetaFlag: GatedExtensionTemplate = { - ...testRemoteExtensionTemplates[3]!, - // organization beta flag is not allowed - organizationBetaFlags: ['notAllowedFlag'], - minimumCliVersion: '1.0.0', - } const templates: GatedExtensionTemplate[] = [ templateWithoutRules, allowedTemplate, diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 3053d3c86a..c41ca59c2c 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -38,6 +38,11 @@ import { AppVersionByIdQueryVariables, AppModule as AppModuleReturnType, } from './app-management-client/graphql/app-version-by-id.js' +import { + OrganizationBetaFlagsQuerySchema, + OrganizationBetaFlagsQueryVariables, + organizationBetaFlagsQuery, +} from './app-management-client/graphql/organization_beta_flags.js' import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' import { DeveloperPlatformClient, @@ -127,6 +132,7 @@ import {appManagementRequest} from '@shopify/cli-kit/node/api/app-management' import {appDevRequest} from '@shopify/cli-kit/node/api/app-dev' import { businessPlatformOrganizationsRequest, + businessPlatformOrganizationsRequestDoc, businessPlatformRequest, businessPlatformRequestDoc, } from '@shopify/cli-kit/node/api/business-platform' @@ -381,7 +387,7 @@ export class AppManagementClient implements DeveloperPlatformClient { // partners-client and app-management-client. Since we need transferDisabled and convertableToPartnerTest values // from the Partners OrganizationStore schema, we will return this type for now async devStoresForOrg(orgId: string): Promise { - const storesResult = await businessPlatformOrganizationsRequest( + const storesResult = await businessPlatformOrganizationsRequestDoc( ListAppDevStores, await this.businessPlatformToken(), orgId, @@ -731,7 +737,7 @@ export class AppManagementClient implements DeveloperPlatformClient { // from the Partners FindByStoreDomainSchema, we will return this type for now async storeByDomain(orgId: string, shopDomain: string): Promise { const queryVariables: FetchDevStoreByDomainQueryVariables = {domain: shopDomain} - const storesResult = await businessPlatformOrganizationsRequest( + const storesResult = await businessPlatformOrganizationsRequestDoc( FetchDevStoreByDomain, await this.businessPlatformToken(), orgId, @@ -865,15 +871,21 @@ export class AppManagementClient implements DeveloperPlatformClient { } private async organizationBetaFlags( - _organizationId: string, + organizationId: string, allBetaFlags: string[], - ): Promise<{[key: string]: boolean}> { - // For now, stub everything as false - const stub: {[flag: string]: boolean} = {} + ): Promise<{[flag: (typeof allBetaFlags)[number]]: boolean}> { + const variables: OrganizationBetaFlagsQueryVariables = {organizationId: encodedGidFromId(organizationId)} + const flagsResult = await businessPlatformOrganizationsRequest( + organizationBetaFlagsQuery(allBetaFlags), + await this.businessPlatformToken(), + organizationId, + variables, + ) + const result: {[flag: (typeof allBetaFlags)[number]]: boolean} = {} allBetaFlags.forEach((flag) => { - stub[flag] = false + result[flag] = Boolean(flagsResult.organization[`flag_${flag}`]) }) - return stub + return result } } @@ -926,7 +938,7 @@ function createAppVars(name: string, isLaunchable = true, scopesArray?: string[] // just the integer portion of that ID. These functions convert between the two. // 1234 => gid://organization/Organization/1234 => base64 -function encodedGidFromId(id: string): string { +export function encodedGidFromId(id: string): string { const gid = `gid://organization/Organization/${id}` return Buffer.from(gid).toString('base64') } diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client/graphql/organization_beta_flags.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client/graphql/organization_beta_flags.ts new file mode 100644 index 0000000000..9890234d91 --- /dev/null +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client/graphql/organization_beta_flags.ts @@ -0,0 +1,22 @@ +import {gql} from 'graphql-request' + +export function organizationBetaFlagsQuery(flags: string[]): string { + return gql` + query OrganizationBetaFlags($organizationId: OrganizationID!) { + organization(organizationId: $organizationId) { + id + ${flags.map((flag) => `flag_${flag}: hasFeatureFlag(handle: "${flag}")`).join('\n ')} + } + }` +} + +export interface OrganizationBetaFlagsQueryVariables { + organizationId: string +} + +export interface OrganizationBetaFlagsQuerySchema { + organization: { + id: string + [flag: `flag_${string}`]: boolean + } +} diff --git a/packages/cli-kit/src/public/node/api/business-platform.ts b/packages/cli-kit/src/public/node/api/business-platform.ts index 62b3353e82..c47e5de6b1 100644 --- a/packages/cli-kit/src/public/node/api/business-platform.ts +++ b/packages/cli-kit/src/public/node/api/business-platform.ts @@ -61,30 +61,64 @@ export async function businessPlatformRequestDoc. + */ +export async function businessPlatformOrganizationsRequest( + query: string, + token: string, + organizationId: string, + variables?: GraphQLVariables, +): Promise { + return graphqlRequest({ + query, + ...(await setupOrganizationsRequest(token, organizationId)), + variables, + }) +} + +/** + * Executes a GraphQL query against the Business Platform Organizations API. Uses typed documents. + * + * @param query - GraphQL query to execute. + * @param token - Business Platform token. * @param organizationId - Organization ID as a numeric value. * @param variables - GraphQL variables to pass to the query. * @returns The response of the query of generic type . */ -export async function businessPlatformOrganizationsRequest( +export async function businessPlatformOrganizationsRequestDoc( query: TypedDocumentNode | TypedDocumentNode>, token: string, organizationId: string, variables?: GraphQLVariables, ): Promise { - const api = 'BusinessPlatform' - const fqdn = await businessPlatformFqdn() - const url = `https://${fqdn}/organizations/api/unstable/organization/${organizationId}/graphql` return graphqlRequestDoc({ query, - api, - url, - token, + ...(await setupOrganizationsRequest(token, organizationId)), variables, - responseOptions: {onResponse: handleDeprecations}, }) }