Skip to content
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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;
});
});
});