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
41 changes: 35 additions & 6 deletions script/command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,40 @@ export const confirm = (message: string = "Are you sure?"): Promise<boolean> =>
});
};

function formatKeyScope(scopes: string[] | undefined, appNames: string[] | null | undefined): string {
const access = !scopes || !scopes.length || scopes.includes("full") ? "full access" : "read only";
const apps = appNames && appNames.length ? appNames.join(", ") : "all apps";
return `${access}, ${apps}`;
}

function accessKeyAdd(command: cli.IAccessKeyAddCommand): Promise<void> {
return sdk.addAccessKey(command.name, command.ttl).then((accessKey: AccessKey) => {
log(`Successfully created the "${command.name}" access key: ${accessKey.key}`);
log("Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!");
});
// The API scopes by app id but people type names, so resolve first and fail before minting a key
// that would reach nothing.
const resolveAppIds = (): Promise<string[] | undefined> => {
if (!command.appNames || !command.appNames.length) return Q(<string[]>undefined);
return sdk.getApps().then((apps: App[]) =>
command.appNames.map((appName: string): string => {
const app = apps.find((candidate: App) => candidate.name === appName);
if (!app) {
throw new Error(`App "${appName}" was not found in your account, so the access key was not created.`);
}
if (!app.id) {
throw new Error(`The server did not return an id for app "${appName}". Limiting keys to apps needs a newer DeployPulse API.`);
}
return app.id;
})
);
};

return resolveAppIds().then((appIds: string[] | undefined) =>
sdk.addAccessKey(command.name, command.ttl, command.scopes, appIds).then((accessKey: AccessKey) => {
log(`Successfully created the "${command.name}" access key: ${accessKey.key}`);
if (command.scopes || appIds) {
log(`Scope: ${formatKeyScope(command.scopes, command.appNames)}`);
}
log("Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!");
})
);
}

function accessKeyPatch(command: cli.IAccessKeyPatchCommand): Promise<void> {
Expand Down Expand Up @@ -1127,15 +1156,15 @@ function printAccessKeys(format: string, keys: AccessKey[]): void {
if (format === "json") {
printJson(keys);
} else if (format === "table") {
printTable(["Name", "Created", "Expires"], (dataSource: any[]): void => {
printTable(["Name", "Created", "Expires", "Scope"], (dataSource: any[]): void => {
const now = new Date().getTime();

function isExpired(key: AccessKey): boolean {
return now >= key.expires;
}

function keyToTableRow(key: AccessKey, dim: boolean): string[] {
const row: string[] = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires)];
const row: string[] = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires), formatKeyScope(key.scopes, key.appNames)];

if (dim) {
row.forEach((col: string, index: number) => {
Expand Down
26 changes: 26 additions & 0 deletions script/command-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ function accessKeyAdd(commandName: string, yargs: yargs.Argv): void {
demand: false,
description: "Duration string which specifies the amount of time that the access key should remain valid for (e.g 5m, 60d, 1y)",
type: "string",
})
.example(
"access-key " + commandName + ' "MyApp CI" --app MyApp-iOS --app MyApp-Android',
"Creates a key that can only reach those two apps"
)
.example("access-key " + commandName + ' "Metrics bot" --scope read', "Creates a read-only key")
.option("scope", {
// No default: yargs validates `choices` against the default too, and omitting the field lets the
// server apply its own.
choices: ["full", "read"],
demand: false,
description: 'What the key may do: "full" (default) or "read" (GET requests only)',
type: "string",
})
.option("app", {
demand: false,
description: "Limit the key to this app. Repeat for several apps. Omit for all apps.",
type: "array",
});

addCommonConfiguration(yargs);
Expand Down Expand Up @@ -979,6 +997,14 @@ export function createCommand(): cli.ICommand {
if (isDefined(ttlOption)) {
accessKeyAddCmd.ttl = parseDurationMilliseconds(ttlOption);
}
const scopeOption: string = argv["scope"] as any;
if (isDefined(scopeOption)) {
accessKeyAddCmd.scopes = [scopeOption];
}
const appOption: any = argv["app"];
if (isDefined(appOption)) {
accessKeyAddCmd.appNames = (Array.isArray(appOption) ? appOption : [appOption]).map(String);
}
}
break;

Expand Down
7 changes: 6 additions & 1 deletion script/management-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class AccountManager {
});
}

public addAccessKey(friendlyName: string, ttl?: number): Promise<AccessKey> {
public addAccessKey(friendlyName: string, ttl?: number, scopes?: string[], appIds?: string[]): Promise<AccessKey> {
if (!friendlyName) {
throw new Error("A name must be specified when adding an access key.");
}
Expand All @@ -127,6 +127,9 @@ class AccountManager {
friendlyName,
ttl,
};
// Absent means full access to all apps.
if (scopes && scopes.length) accessKeyRequest.scopes = scopes;
if (appIds && appIds.length) accessKeyRequest.appIds = appIds;

return this.post(urlEncode(["/accessKeys/"]), JSON.stringify(accessKeyRequest), /*expectResponseBody=*/ true).then(
(response: JsonResponse) => {
Expand Down Expand Up @@ -160,6 +163,8 @@ class AccountManager {
createdTime: serverAccessKey.createdTime,
expires: serverAccessKey.expires,
name: serverAccessKey.friendlyName,
scopes: serverAccessKey.scopes,
appNames: serverAccessKey.appNames,
});
});

Expand Down
2 changes: 2 additions & 0 deletions script/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface AccessKey {
expires: number;
name: string;
key?: string;
scopes?: string[];
appNames?: string[] | null;
}

export interface Session {
Expand Down
2 changes: 2 additions & 0 deletions script/types/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface ICommand {
export interface IAccessKeyAddCommand extends ICommand {
name: string;
ttl?: number;
scopes?: string[];
appNames?: string[];
}

export interface IAccessKeyPatchCommand extends ICommand {
Expand Down
6 changes: 6 additions & 0 deletions script/types/rest-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ export interface AccessKey extends AccessKeyBase {
/*generated*/ createdTime?: number;
expires: number;
/*generated*/ isSession?: boolean;
/*generated*/ scopes?: string[];
/*generated*/ appIds?: string[] | null;
/*generated*/ appNames?: string[] | null;
}

/*in*/
export interface AccessKeyRequest extends AccessKeyBase {
ttl?: number;
scopes?: string[];
appIds?: string[];
}

/*out*/
Expand Down Expand Up @@ -109,6 +114,7 @@ export interface CollaboratorMap {

/*inout*/
export interface App {
/*generated*/ id?: string;
/*generated*/ collaborators?: CollaboratorMap;
/*key*/ name: string;
/*generated*/ deployments?: string[];
Expand Down
58 changes: 57 additions & 1 deletion test/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class SdkStub {
});
}

public addAccessKey(name: string, ttl: number): Q.Promise<codePush.AccessKey> {
public addAccessKey(name: string, ttl?: number, scopes?: string[], appIds?: string[]): Q.Promise<codePush.AccessKey> {
return Q(<codePush.AccessKey>{
key: "key123",
createdTime: new Date().getTime(),
Expand Down Expand Up @@ -123,13 +123,15 @@ export class SdkStub {
public getApps(): Q.Promise<codePush.App[]> {
return Q([
<codePush.App>{
id: "app-a-id",
name: "a",
collaborators: {
"a@a.com": { permission: "Owner", isCurrentAccount: true },
},
deployments: ["Production", "Staging"],
},
<codePush.App>{
id: "app-b-id",
name: "b",
collaborators: {
"a@a.com": { permission: "Owner", isCurrentAccount: true },
Expand Down Expand Up @@ -370,6 +372,44 @@ describe("CLI", () => {
});
});

it("accessKeyAdd resolves app names to ids and sends the requested scope", (done: Mocha.Done): void => {
var command: cli.IAccessKeyAddCommand = {
type: cli.CommandType.accessKeyAdd,
name: "CI key",
scopes: ["read"],
appNames: ["a"],
};

var addAccessKey: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addAccessKey");

cmdexec.execute(command).done((): void => {
sinon.assert.calledOnce(addAccessKey);
sinon.assert.calledWithExactly(addAccessKey, "CI key", undefined, ["read"], ["app-a-id"]);
sinon.assert.calledThrice(log);
assert.equal(log.args[1][0], "Scope: read only, a");
done();
});
});

it("accessKeyAdd does not create a key when an app name is not found", (done: Mocha.Done): void => {
var command: cli.IAccessKeyAddCommand = {
type: cli.CommandType.accessKeyAdd,
name: "CI key",
appNames: ["does-not-exist"],
};

var addAccessKey: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addAccessKey");

cmdexec.execute(command).then(
(): void => done(new Error("Should have rejected")),
(error: any): void => {
assert.ok(/was not found/.test(error.message));
sinon.assert.notCalled(addAccessKey);
done();
}
);
});

it("accessKeyPatch updates access key with new name", (done: Mocha.Done): void => {
var command: cli.IAccessKeyPatchCommand = {
type: cli.CommandType.accessKeyPatch,
Expand Down Expand Up @@ -454,6 +494,20 @@ describe("CLI", () => {
});
});

it("accessKeyList shows what each key can reach", (done: Mocha.Done): void => {
var command: cli.IAccessKeyListCommand = {
type: cli.CommandType.accessKeyList,
format: "table",
};

cmdexec.execute(command).done((): void => {
var table: string = log.args[0][0];
assert.ok(/Scope/.test(table), table);
assert.ok(/full access, all apps/.test(table), table);
done();
});
});

it("accessKeyRemove removes access key", (done: Mocha.Done): void => {
var command: cli.IAccessKeyRemoveCommand = {
type: cli.CommandType.accessKeyRemove,
Expand Down Expand Up @@ -524,6 +578,7 @@ describe("CLI", () => {
var actual: string = log.args[0][0];
var expected = [
{
id: "app-a-id",
name: "a",
collaborators: {
"a@a.com": {
Expand All @@ -534,6 +589,7 @@ describe("CLI", () => {
deployments: ["Production", "Staging"],
},
{
id: "app-b-id",
name: "b",
collaborators: {
"a@a.com": {
Expand Down
24 changes: 24 additions & 0 deletions test/management-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,26 @@ describe("Management SDK", () => {
);
});

it("addAccessKey sends scopes and appIds when they are set", (done: Mocha.Done) => {
mockReturn(JSON.stringify({ accessKey: { name: "k", friendlyName: "CI key", createdTime: 0, expires: 1 } }), 201);
manager.addAccessKey("CI key", undefined, ["read"], ["app-a-id"]).done(() => {
const body = typeof lastRequestBody === "string" ? JSON.parse(lastRequestBody) : lastRequestBody;
assert.deepStrictEqual(body.scopes, ["read"]);
assert.deepStrictEqual(body.appIds, ["app-a-id"]);
done();
}, rejectHandler);
});

it("addAccessKey leaves scopes and appIds out when they are not set", (done: Mocha.Done) => {
mockReturn(JSON.stringify({ accessKey: { name: "k", friendlyName: "CI key", createdTime: 0, expires: 1 } }), 201);
manager.addAccessKey("CI key").done(() => {
const body = typeof lastRequestBody === "string" ? JSON.parse(lastRequestBody) : lastRequestBody;
assert.ok(!("scopes" in body), "an unscoped key must not pin itself to full");
assert.ok(!("appIds" in body), "an unscoped key must not pin itself to a list of apps");
done();
}, rejectHandler);
});

it("getAutoRollbackConfig reads autoRollbackConfig, the key the API actually sends", (done: Mocha.Done) => {
mockReturn(JSON.stringify({ autoRollbackConfig: { enabled: true, threshold: 25 } }), 200);
manager.getAutoRollbackConfig("appName", "Staging").done((config: any) => {
Expand All @@ -421,11 +441,15 @@ function rejectHandler(val: any): void {
}

// Wrapper for superagent-mock that abstracts away information not needed for SDK tests
let lastRequestBody: any;

function mockReturn(bodyText: string, statusCode: number, header = {}): void {
lastRequestBody = undefined;
require("superagent-mock")(request, [
{
pattern: "https://api.deploypulse.io/(.*)",
fixtures: function (match: any, params: any): any {
lastRequestBody = params;
var isOk = statusCode >= 200 && statusCode < 300;
if (!isOk) {
var err: any = new Error(bodyText);
Expand Down
Loading