diff --git a/src/commands/apps/builds/unshare.test.ts b/src/commands/apps/builds/unshare.test.ts new file mode 100644 index 0000000..af3b00f --- /dev/null +++ b/src/commands/apps/builds/unshare.test.ts @@ -0,0 +1,95 @@ +import { DEFAULT_API_BASE_URL } from '@/config/consts.js'; +import authorizationService from '@/services/authorization-service.js'; +import userConfig from '@/utils/user-config.js'; +import consola from 'consola'; +import nock from 'nock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import unshareCommand from './unshare.js'; + +// Mock dependencies +vi.mock('@/utils/user-config.js'); +vi.mock('@/utils/prompt.js'); +vi.mock('@/services/authorization-service.js'); +vi.mock('consola'); + +vi.mock('@/utils/environment.js', () => ({ + isInteractive: () => false, +})); + +describe('apps-builds-unshare', () => { + const mockUserConfig = vi.mocked(userConfig); + const mockAuthorizationService = vi.mocked(authorizationService); + const mockConsola = vi.mocked(consola); + + const testToken = 'test-token'; + const appId = '00000000-0000-0000-0000-000000000001'; + const buildId = '00000000-0000-0000-0000-000000000002'; + const shareId = 'share-abc'; + + beforeEach(() => { + vi.clearAllMocks(); + + mockUserConfig.read.mockReturnValue({ token: testToken }); + mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true); + mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken); + + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`Process exited with code ${code}`); + }); + }); + + afterEach(() => { + nock.cleanAll(); + vi.restoreAllMocks(); + }); + + it('should revoke the active share', async () => { + const options = { appId, buildId, yes: true }; + + const sharesScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}/builds/${buildId}/shares`) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, [ + { + id: shareId, + appBuildId: buildId, + description: null, + expiresAt: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ]); + + const deleteScope = nock(DEFAULT_API_BASE_URL) + .delete(`/v1/apps/${appId}/builds/${buildId}/shares/${shareId}`) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(204); + + await unshareCommand.action(options, undefined); + + expect(sharesScope.isDone()).toBe(true); + expect(deleteScope.isDone()).toBe(true); + expect(mockConsola.success).toHaveBeenCalledWith('Share revoked successfully.'); + }); + + it('should error when the build is not shared', async () => { + const options = { appId, buildId, yes: true }; + + nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}/builds/${buildId}/shares`) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, []); + + await expect(unshareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + expect(mockConsola.error).toHaveBeenCalledWith('This build is not shared.'); + }); + + it('should error when no app ID is provided in non-interactive environment', async () => { + const options = {}; + + await expect(unshareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + expect(mockConsola.error).toHaveBeenCalledWith( + 'You must provide an app ID when running in non-interactive environment.', + ); + }); +}); diff --git a/src/commands/apps/builds/unshare.ts b/src/commands/apps/builds/unshare.ts new file mode 100644 index 0000000..3761c32 --- /dev/null +++ b/src/commands/apps/builds/unshare.ts @@ -0,0 +1,101 @@ +import appBuildsService from '@/services/app-builds.js'; +import { withAuth } from '@/utils/auth.js'; +import { isInteractive } from '@/utils/environment.js'; +import { prompt, promptAppSelection, promptOrganizationSelection } from '@/utils/prompt.js'; +import { defineCommand, defineOptions } from '@robingenz/zli'; +import consola from 'consola'; +import { z } from 'zod'; + +export default defineCommand({ + description: 'Revoke the public share link of an app build.', + options: defineOptions( + z.object({ + appId: z + .uuid({ + message: 'App ID must be a UUID.', + }) + .optional() + .describe('App ID the build belongs to.'), + buildId: z + .uuid({ + message: 'Build ID must be a UUID.', + }) + .optional() + .describe('Build ID to unshare.'), + buildNumber: z.string().optional().describe('Build number to unshare (e.g., "1", "42").'), + yes: z.boolean().optional().describe('Skip confirmation prompt.'), + }), + { y: 'yes' }, + ), + action: withAuth(async (options) => { + let { appId, buildId } = options; + const { buildNumber } = options; + + // Prompt for app ID if not provided + if (!appId) { + if (!isInteractive()) { + consola.error('You must provide an app ID when running in non-interactive environment.'); + process.exit(1); + } + const organizationId = await promptOrganizationSelection(); + appId = await promptAppSelection(organizationId); + } + + // Convert build number to build ID if provided + if (!buildId && buildNumber) { + const builds = await appBuildsService.findAll({ appId, numberAsString: buildNumber }); + if (builds.length === 0) { + consola.error(`Build #${buildNumber} not found.`); + process.exit(1); + } + buildId = builds[0]?.id; + } + + // Prompt for build ID if not provided + if (!buildId) { + if (!isInteractive()) { + consola.error('You must provide a build ID when running in non-interactive environment.'); + process.exit(1); + } + const builds = await appBuildsService.findAll({ appId }); + if (builds.length === 0) { + consola.error('No builds found for this app.'); + process.exit(1); + } + // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged + buildId = await prompt('Select the build you want to unshare:', { + type: 'select', + options: builds.map((build) => ({ + label: `Build #${build.numberAsString || build.id} (${build.platform} - ${build.type})`, + value: build.id, + })), + }); + if (!buildId) { + consola.error('You must select a build to unshare.'); + process.exit(1); + } + } + + // Find the active share + const shares = await appBuildsService.findAllShares({ appId, appBuildId: buildId }); + const share = shares[0]; + if (!share) { + consola.error('This build is not shared.'); + process.exit(1); + } + + // Confirm revocation + if (!options.yes && isInteractive()) { + const confirmed = await prompt('Are you sure you want to revoke the share link for this build?', { + type: 'confirm', + }); + if (!confirmed) { + return; + } + } + + // Revoke the share + await appBuildsService.deleteShare({ appId, appBuildId: buildId, id: share.id }); + consola.success('Share revoked successfully.'); + }), +}); diff --git a/src/index.ts b/src/index.ts index 96b59bd..9849ac1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ const config = defineConfig({ 'apps:builds:list': await import('@/commands/apps/builds/list.js').then((mod) => mod.default), 'apps:builds:logs': await import('@/commands/apps/builds/logs.js').then((mod) => mod.default), 'apps:builds:share': await import('@/commands/apps/builds/share.js').then((mod) => mod.default), + 'apps:builds:unshare': await import('@/commands/apps/builds/unshare.js').then((mod) => mod.default), 'apps:builds:download': await import('@/commands/apps/builds/download.js').then((mod) => mod.default), 'apps:bundles:create': await import('@/commands/apps/bundles/create.js').then((mod) => mod.default), 'apps:bundles:delete': await import('@/commands/apps/bundles/delete.js').then((mod) => mod.default), diff --git a/src/services/app-builds.ts b/src/services/app-builds.ts index ed61157..9791a68 100644 --- a/src/services/app-builds.ts +++ b/src/services/app-builds.ts @@ -4,6 +4,8 @@ import { AppBuildShareDto, CreateAppBuildDto, CreateAppBuildShareDto, + DeleteAppBuildShareDto, + FindAllAppBuildSharesDto, FindAllAppBuildsDto, FindOneAppBuildDto, UpdateAppBuildDto, @@ -19,7 +21,9 @@ export interface DownloadArtifactDto { export interface AppBuildsService { create(dto: CreateAppBuildDto): Promise; createShare(dto: CreateAppBuildShareDto): Promise; + deleteShare(dto: DeleteAppBuildShareDto): Promise; findAll(dto: FindAllAppBuildsDto): Promise; + findAllShares(dto: FindAllAppBuildSharesDto): Promise; findOne(dto: FindOneAppBuildDto): Promise; update(dto: UpdateAppBuildDto): Promise; downloadArtifact(dto: DownloadArtifactDto): Promise; @@ -56,6 +60,14 @@ class AppBuildsServiceImpl implements AppBuildsService { return response.data; } + async deleteShare(dto: DeleteAppBuildShareDto): Promise { + await this.httpClient.delete(`/v1/apps/${dto.appId}/builds/${dto.appBuildId}/shares/${dto.id}`, { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + }, + }); + } + async findAll(dto: FindAllAppBuildsDto): Promise { const params: Record = {}; if (dto.limit !== undefined) { @@ -82,6 +94,18 @@ class AppBuildsServiceImpl implements AppBuildsService { return response.data; } + async findAllShares(dto: FindAllAppBuildSharesDto): Promise { + const response = await this.httpClient.get( + `/v1/apps/${dto.appId}/builds/${dto.appBuildId}/shares`, + { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + }, + }, + ); + return response.data; + } + async findOne(dto: FindOneAppBuildDto): Promise { const { appId, appBuildId, relations } = dto; const params: Record = {}; diff --git a/src/types/app-build.ts b/src/types/app-build.ts index c15a270..d1bdd71 100644 --- a/src/types/app-build.ts +++ b/src/types/app-build.ts @@ -53,6 +53,17 @@ export interface CreateAppBuildShareDto { expiresAt?: string; } +export interface DeleteAppBuildShareDto { + appId: string; + appBuildId: string; + id: string; +} + +export interface FindAllAppBuildSharesDto { + appId: string; + appBuildId: string; +} + export interface FindOneAppBuildDto { appId: string; appBuildId: string;