Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/commands/apps/builds/unshare.test.ts
Original file line number Diff line number Diff line change
@@ -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 };

Comment thread
robingenz marked this conversation as resolved.
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.',
);
});
});
101 changes: 101 additions & 0 deletions src/commands/apps/builds/unshare.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
robingenz marked this conversation as resolved.

// 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.');
}),
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions src/services/app-builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AppBuildShareDto,
CreateAppBuildDto,
CreateAppBuildShareDto,
DeleteAppBuildShareDto,
FindAllAppBuildSharesDto,
FindAllAppBuildsDto,
FindOneAppBuildDto,
UpdateAppBuildDto,
Expand All @@ -19,7 +21,9 @@ export interface DownloadArtifactDto {
export interface AppBuildsService {
create(dto: CreateAppBuildDto): Promise<AppBuildDto>;
createShare(dto: CreateAppBuildShareDto): Promise<AppBuildShareDto>;
deleteShare(dto: DeleteAppBuildShareDto): Promise<void>;
findAll(dto: FindAllAppBuildsDto): Promise<AppBuildDto[]>;
findAllShares(dto: FindAllAppBuildSharesDto): Promise<AppBuildShareDto[]>;
findOne(dto: FindOneAppBuildDto): Promise<AppBuildDto>;
update(dto: UpdateAppBuildDto): Promise<AppBuildDto>;
downloadArtifact(dto: DownloadArtifactDto): Promise<ArrayBuffer>;
Expand Down Expand Up @@ -56,6 +60,14 @@ class AppBuildsServiceImpl implements AppBuildsService {
return response.data;
}

async deleteShare(dto: DeleteAppBuildShareDto): Promise<void> {
await this.httpClient.delete(`/v1/apps/${dto.appId}/builds/${dto.appBuildId}/shares/${dto.id}`, {
headers: {
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
},
});
}

async findAll(dto: FindAllAppBuildsDto): Promise<AppBuildDto[]> {
const params: Record<string, string> = {};
if (dto.limit !== undefined) {
Expand All @@ -82,6 +94,18 @@ class AppBuildsServiceImpl implements AppBuildsService {
return response.data;
}

async findAllShares(dto: FindAllAppBuildSharesDto): Promise<AppBuildShareDto[]> {
const response = await this.httpClient.get<AppBuildShareDto[]>(
`/v1/apps/${dto.appId}/builds/${dto.appBuildId}/shares`,
{
headers: {
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
},
},
);
return response.data;
}

async findOne(dto: FindOneAppBuildDto): Promise<AppBuildDto> {
const { appId, appBuildId, relations } = dto;
const params: Record<string, string> = {};
Expand Down
11 changes: 11 additions & 0 deletions src/types/app-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading