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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
- Added `appdistribution:group:create` and
`appdistribution:group:delete`.
- Added `--group-alias` option to `appdistribution:testers:add` and
`appdistribution:testers:remove`.
- Fixed an issue where Storage rules could not be deployed to projects without a billing plan. (#5955)
66 changes: 65 additions & 1 deletion src/appdistribution/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export interface BatchRemoveTestersResponse {
emails: string[];
}

export interface Group {
name: string;
displayName: string;
testerCount?: number;
releaseCount?: number;
inviteLinkCount?: number;
}

/**
* Makes RPCs to the App Distribution server backend.
*/
Expand Down Expand Up @@ -166,7 +174,7 @@ export class AppDistributionClient {
utils.logSuccess("distributed to testers/groups successfully");
}

async addTesters(projectName: string, emails: string[]) {
async addTesters(projectName: string, emails: string[]): Promise<void> {
try {
await this.appDistroV2Client.request({
method: "POST",
Expand Down Expand Up @@ -196,4 +204,60 @@ export class AppDistributionClient {
}
return apiResponse.body;
}

async createGroup(projectName: string, displayName: string, alias?: string): Promise<Group> {
let apiResponse;
try {
apiResponse = await this.appDistroV2Client.request<{ displayName: string }, Group>({
method: "POST",
path:
alias === undefined ? `${projectName}/groups` : `${projectName}/groups?groupId=${alias}`,
body: { displayName: displayName },
});
} catch (err: any) {
throw new FirebaseError(`Failed to create group ${err}`);
}
return apiResponse.body;
}

async deleteGroup(groupName: string): Promise<void> {
try {
await this.appDistroV2Client.request({
method: "DELETE",
path: groupName,
});
} catch (err: any) {
throw new FirebaseError(`Failed to delete group ${err}`);
}

utils.logSuccess(`Group deleted successfully`);
}

async addTestersToGroup(groupName: string, emails: string[]): Promise<void> {
try {
await this.appDistroV2Client.request({
method: "POST",
path: `${groupName}:batchJoin`,
body: { emails: emails },
});
} catch (err: any) {
throw new FirebaseError(`Failed to add testers to group ${err}`);
}

utils.logSuccess(`Testers added to group successfully`);
}

async removeTestersFromGroup(groupName: string, emails: string[]): Promise<void> {
try {
await this.appDistroV2Client.request({
method: "POST",
path: `${groupName}:batchLeave`,
body: { emails: emails },
});
} catch (err: any) {
throw new FirebaseError(`Failed to remove testers from group ${err}`);
}

utils.logSuccess(`Testers removed from group successfully`);
}
}
17 changes: 17 additions & 0 deletions src/commands/appdistribution-group-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Command } from "../command";
import * as utils from "../utils";
import { requireAuth } from "../requireAuth";
import { AppDistributionClient } from "../appdistribution/client";
import { getProjectName } from "../appdistribution/options-parser-util";

export const command = new Command("appdistribution:group:create <displayName> [alias]")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should alias be a named option for clarity?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had it as an option before, but then changed my mind and decided against it. Mainly because it's an argument in group:delete - and I didn't want to change that.

.description("create group in project")
.before(requireAuth)
.action(async (displayName: string, alias?: string, options?: any) => {
const projectName = await getProjectName(options);
const appDistroClient = new AppDistributionClient();
utils.logBullet(`Creating group in project`);
const group = await appDistroClient.createGroup(projectName, displayName, alias);
alias = group.name.split("/").pop();
utils.logSuccess(`Group '${group.displayName}' (alias: ${alias}) created successfully`);
});
21 changes: 21 additions & 0 deletions src/commands/appdistribution-group-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Command } from "../command";
import * as utils from "../utils";
import { requireAuth } from "../requireAuth";
import { FirebaseError } from "../error";
import { AppDistributionClient } from "../appdistribution/client";
import { getProjectName } from "../appdistribution/options-parser-util";

export const command = new Command("appdistribution:group:delete <alias>")
.description("delete group from a project")
.before(requireAuth)
.action(async (alias: string, options: any) => {
const projectName = await getProjectName(options);
const appDistroClient = new AppDistributionClient();
try {
utils.logBullet(`Deleting group from project`);
await appDistroClient.deleteGroup(`${projectName}/groups/${alias}`);
} catch (err: any) {
throw new FirebaseError(`Failed to delete group ${err}`);
}
utils.logSuccess(`Group ${alias} has successfully been deleted`);
});
13 changes: 12 additions & 1 deletion src/commands/appdistribution-testers-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ import { AppDistributionClient } from "../appdistribution/client";
import { getEmails, getProjectName } from "../appdistribution/options-parser-util";

export const command = new Command("appdistribution:testers:add [emails...]")
.description("add testers to project")
.description("add testers to project (and possibly group)")
.option("--file <file>", "a path to a file containing a list of tester emails to be added")
.option(
"--group-alias <group-alias>",
"if specified, the testers are also added to the group identified by this alias"
)
.before(requireAuth)
.action(async (emails: string[], options?: any) => {
const projectName = await getProjectName(options);
const appDistroClient = new AppDistributionClient();
const emailsToAdd = getEmails(emails, options.file);
utils.logBullet(`Adding ${emailsToAdd.length} testers to project`);
await appDistroClient.addTesters(projectName, emailsToAdd);
if (options.groupAlias) {
utils.logBullet(`Adding ${emailsToAdd.length} testers to group`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider changing the plurality of testers depending on the length of the emails, like

marked(`The following param${plural ? "s are" : " is"} immutable and won't be changed:`)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm too lazy - there are too many places to change

await appDistroClient.addTestersToGroup(
`${projectName}/groups/${options.groupAlias}`,
emailsToAdd
);
}
});
38 changes: 25 additions & 13 deletions src/commands/appdistribution-testers-remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,37 @@ import { getEmails, getProjectName } from "../appdistribution/options-parser-uti
import { logger } from "../logger";

export const command = new Command("appdistribution:testers:remove [emails...]")
.description("remove testers from a project")
.description("remove testers from a project (or group)")
.option("--file <file>", "a path to a file containing a list of tester emails to be removed")
.option(
"--group-alias <group-alias>",
"if specified, the testers are only removed from the group identified by this alias, but not the project"
)
.before(requireAuth)
.action(async (emails: string[], options?: any) => {
const projectName = await getProjectName(options);
const appDistroClient = new AppDistributionClient();
const emailsArr = getEmails(emails, options.file);
let deleteResponse;
try {
utils.logBullet(`Deleting ${emailsArr.length} testers from project`);
deleteResponse = await appDistroClient.removeTesters(projectName, emailsArr);
} catch (err: any) {
throw new FirebaseError(`Failed to remove testers ${err}`);
}
if (options.groupAlias) {
utils.logBullet(`Removing ${emailsArr.length} testers from group`);
await appDistroClient.removeTestersFromGroup(
`${projectName}/groups/${options.groupAlias}`,
emailsArr
);
} else {
let deleteResponse;
try {
utils.logBullet(`Deleting ${emailsArr.length} testers from project`);
deleteResponse = await appDistroClient.removeTesters(projectName, emailsArr);
} catch (err: any) {
throw new FirebaseError(`Failed to remove testers ${err}`);
}

if (!deleteResponse.emails) {
utils.logSuccess(`Testers did not exist`);
return;
if (!deleteResponse.emails) {
utils.logSuccess(`Testers did not exist`);
return;
}
logger.debug(`Testers: ${deleteResponse.emails}, have been successfully deleted`);
utils.logSuccess(`${deleteResponse.emails.length} testers have successfully been deleted`);
}
logger.debug(`Testers: ${deleteResponse.emails}, have been successfully deleted`);
utils.logSuccess(`${deleteResponse.emails.length} testers have successfully been deleted`);
});
3 changes: 3 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export function load(client: any): any {
client.appdistribution.testers = {};
client.appdistribution.testers.add = loadCommand("appdistribution-testers-add");
client.appdistribution.testers.delete = loadCommand("appdistribution-testers-remove");
client.appdistribution.group = {};
client.appdistribution.group.create = loadCommand("appdistribution-group-create");
client.appdistribution.group.delete = loadCommand("appdistribution-group-delete");
client.apps = {};
client.apps.create = loadCommand("apps-create");
client.apps.list = loadCommand("apps-list");
Expand Down
104 changes: 102 additions & 2 deletions src/test/appdistro/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import * as rimraf from "rimraf";
import * as sinon from "sinon";
import * as tmp from "tmp";

import { AppDistributionClient, BatchRemoveTestersResponse } from "../../appdistribution/client";
import {
AppDistributionClient,
BatchRemoveTestersResponse,
Group,
} from "../../appdistribution/client";
import { appDistributionOrigin } from "../../api";
import { Distribution } from "../../appdistribution/distribution";
import { FirebaseError } from "../../error";
Expand All @@ -17,6 +21,7 @@ describe("distribution", () => {
const tempdir = tmp.dirSync();
const projectName = "projects/123456789";
const appName = `${projectName}/apps/1:123456789:ios:abc123def456`;
const groupName = `${projectName}/groups/my-group`;
const binaryFile = join(tempdir.name, "app.ipa");
fs.ensureFileSync(binaryFile);
const mockDistribution = new Distribution(binaryFile);
Expand Down Expand Up @@ -61,6 +66,7 @@ describe("distribution", () => {

describe("deleteTesters", () => {
const emails = ["a@foo.com", "b@foo.com"];
const mockResponse: BatchRemoveTestersResponse = { emails: emails };

it("should throw error if delete fails", async () => {
nock(appDistributionOrigin)
Expand All @@ -73,7 +79,6 @@ describe("distribution", () => {
expect(nock.isDone()).to.be.true;
});

const mockResponse: BatchRemoveTestersResponse = { emails: emails };
it("should resolve when request succeeds", async () => {
nock(appDistributionOrigin)
.post(`/v1/${projectName}/testers:batchRemove`)
Expand Down Expand Up @@ -200,4 +205,99 @@ describe("distribution", () => {
});
});
});

describe("createGroup", () => {
const mockResponse: Group = { name: groupName, displayName: "My Group" };

it("should throw error if request fails", async () => {
nock(appDistributionOrigin)
.post(`/v1/${projectName}/groups`)
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
await expect(appDistributionClient.createGroup(projectName, "My Group")).to.be.rejectedWith(
FirebaseError,
"Failed to create group"
);
expect(nock.isDone()).to.be.true;
});

it("should resolve when request succeeds", async () => {
nock(appDistributionOrigin).post(`/v1/${projectName}/groups`).reply(200, mockResponse);
await expect(
appDistributionClient.createGroup(projectName, "My Group")
).to.eventually.deep.eq(mockResponse);
expect(nock.isDone()).to.be.true;
});

it("should resolve when request with alias succeeds", async () => {
nock(appDistributionOrigin)
.post(`/v1/${projectName}/groups?groupId=my-group`)
.reply(200, mockResponse);
await expect(
appDistributionClient.createGroup(projectName, "My Group", "my-group")
).to.eventually.deep.eq(mockResponse);
expect(nock.isDone()).to.be.true;
});
});

describe("deleteGroup", () => {
it("should throw error if delete fails", async () => {
nock(appDistributionOrigin)
.delete(`/v1/${groupName}`)
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
await expect(appDistributionClient.deleteGroup(groupName)).to.be.rejectedWith(
FirebaseError,
"Failed to delete group"
);
expect(nock.isDone()).to.be.true;
});

it("should resolve when request succeeds", async () => {
nock(appDistributionOrigin).delete(`/v1/${groupName}`).reply(200, {});
await expect(appDistributionClient.deleteGroup(groupName)).to.be.eventually.fulfilled;
expect(nock.isDone()).to.be.true;
});
});

describe("addTestersToGroup", () => {
const emails = ["a@foo.com", "b@foo.com"];

it("should throw error if request fails", async () => {
nock(appDistributionOrigin)
.post(`/v1/${groupName}:batchJoin`)
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
await expect(appDistributionClient.addTestersToGroup(groupName, emails)).to.be.rejectedWith(
FirebaseError,
"Failed to add testers to group"
);
expect(nock.isDone()).to.be.true;
});

it("should resolve when request succeeds", async () => {
nock(appDistributionOrigin).post(`/v1/${groupName}:batchJoin`).reply(200, {});
await expect(appDistributionClient.addTestersToGroup(groupName, emails)).to.be.eventually
.fulfilled;
expect(nock.isDone()).to.be.true;
});
});

describe("removeTestersFromGroup", () => {
const emails = ["a@foo.com", "b@foo.com"];

it("should throw error if request fails", async () => {
nock(appDistributionOrigin)
.post(`/v1/${groupName}:batchLeave`)
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
await expect(
appDistributionClient.removeTestersFromGroup(groupName, emails)
).to.be.rejectedWith(FirebaseError, "Failed to remove testers from group");
expect(nock.isDone()).to.be.true;
});

it("should resolve when request succeeds", async () => {
nock(appDistributionOrigin).post(`/v1/${groupName}:batchLeave`).reply(200, {});
await expect(appDistributionClient.removeTestersFromGroup(groupName, emails)).to.be.eventually
.fulfilled;
expect(nock.isDone()).to.be.true;
});
});
});