diff --git a/README.md b/README.md index bcf9469..00b1f57 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Before upgrading a CI pipeline to 1.2.0, note two changes: an unknown flag is no - [Usage](#usage) - [Authentication](#authentication) - [Access Keys](#access-keys) + - [Organizations](#organizations) - [App Management](#app-management) - [Code Signing - Set Public Key](#code-signing---set-public-key) - [App Collaboration](#app-collaboration) @@ -114,6 +115,31 @@ dpctl access-key patch --name "new name" --ttl 10d _NOTE: When patching the TTL of an existing access key, its expiration date will be set relative to the current time, with no regard for its previous value._ +### Organizations + +Apps that belong to an organization are only reachable in that organization's context. If your account belongs to any, `dpctl login` asks which one to use once, at the end of login, and remembers the answer. Pick the personal account there and nothing changes. + +It asks nothing when there is nothing to ask: you passed `--org`, your account belongs to no organizations, or stdin is not a terminal, which is the CI case. Change it any time: + +``` +dpctl org list +dpctl org use acme +dpctl org clear +``` + +`dpctl org list` marks the organization in use with a `*`. `dpctl org use` takes a slug, a name or an id, and saves your choice to the session file, so later commands need no extra typing. `dpctl org clear` goes back to your personal account. + +Two ways to override it for a single command or a single job: + +| Override | Takes | Use for | +| -------------------- | ------------------ | --------------------------------------------- | +| `--org ` | Slug, name or id | A one-off against another organization | +| `DEPLOYPULSE_ORG_ID` | An id | CI, where there is no session file to save to | + +`--org` wins over `DEPLOYPULSE_ORG_ID`, which wins over `dpctl org use`. If you authenticate with `DEPLOYPULSE_ACCESS_KEY` rather than `dpctl login`, there is no session file, so use the environment variable. + +The environment variable takes an **id**, which `dpctl org list --format json` prints, and is sent as-is. `--org` accepts a slug or a name too, which costs one extra request to resolve, so CI is better off with the id. + ## App Management Before you can deploy any updates, you need to register an app with the DeployPulse service using the following command: diff --git a/script/command-executor.ts b/script/command-executor.ts index 1090508..a88cc73 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -30,6 +30,7 @@ import { App, CodePushError, CollaboratorMap, + Org, CollaboratorProperties, Deployment, DeploymentMetrics, @@ -61,6 +62,14 @@ interface ILegacyLoginConnectionInfo { interface ILoginConnectionInfo { accessKey: string; preserveAccessKeyOnLogout?: boolean; + /** Saved by `dpctl org use`. Always the resolved id, so later commands need no lookup. */ + orgId?: string; + orgSlug?: string; +} + +interface OrgContext { + id: string; + slug: string; } export interface UpdateMetricsWithTotalActive extends UpdateMetrics { @@ -505,7 +514,7 @@ function deploymentHistory(command: cli.IDeploymentHistoryCommand): Promise { try { const savedConnection: string = fs.readFileSync(configFilePath, { encoding: "utf8", @@ -525,13 +534,17 @@ function deserializeConnectionInfo(): ILoginConnectionInfo { } catch (ex) { return; } -} +}; export function execute(command: cli.ICommand) { connectionInfo = deserializeConnectionInfo(); return Q(null).then(() => { switch (command.type) { + // Only touches the session file on this machine. + case cli.CommandType.orgClear: + break; + // Must not be logged in case cli.CommandType.login: if (connectionInfo) { @@ -554,7 +567,9 @@ export function execute(command: cli.ICommand) { sdk = getSdk(accessKey, CLI_HEADERS); break; } - + }) + .then(() => applyOrgContext(command)) + .then(() => { switch (command.type) { case cli.CommandType.accessKeyAdd: return accessKeyAdd(command); @@ -631,6 +646,15 @@ export function execute(command: cli.ICommand) { case cli.CommandType.logout: return logout(command); + case cli.CommandType.orgList: + return orgList(command); + + case cli.CommandType.orgUse: + return orgUse(command); + + case cli.CommandType.orgClear: + return orgClear(command); + case cli.CommandType.patch: return patch(command); @@ -694,19 +718,20 @@ function login(command: cli.ILoginCommand): Promise { // Check if one of the flags were provided. if (command.accessKey) { sdk = getSdk(command.accessKey, CLI_HEADERS); - return sdk.isAuthenticated().then((isAuthenticated: boolean): void => { - if (isAuthenticated) { - serializeConnectionInfo(command.accessKey, /*preserveAccessKeyOnLogout*/ true); - } else { + return sdk.isAuthenticated().then((isAuthenticated: boolean): Promise => { + if (!isAuthenticated) { throw new Error("Invalid access key."); } + return chooseOrgContext(command).then((org: OrgContext): void => { + serializeConnectionInfo(command.accessKey, /*preserveAccessKeyOnLogout*/ true, org); + }); }); } else { - return loginWithExternalAuthentication("login"); + return loginWithExternalAuthentication("login", command); } } -function loginWithExternalAuthentication(action: string): Promise { +function loginWithExternalAuthentication(action: string, command?: cli.ICommand): Promise { initiateExternalAuthenticationAsync(action); log(""); // Insert newline @@ -718,12 +743,13 @@ function loginWithExternalAuthentication(action: string): Promise { sdk = getSdk(accessKey, CLI_HEADERS); - return sdk.isAuthenticated().then((isAuthenticated: boolean): void => { - if (isAuthenticated) { - serializeConnectionInfo(accessKey, /*preserveAccessKeyOnLogout*/ false); - } else { + return sdk.isAuthenticated().then((isAuthenticated: boolean): Promise => { + if (!isAuthenticated) { throw new Error("Invalid access key."); } + return chooseOrgContext(command).then((org: OrgContext): void => { + serializeConnectionInfo(accessKey, /*preserveAccessKeyOnLogout*/ false, org); + }); }); }); } @@ -1547,14 +1573,181 @@ export const runReactNativeBundleCommand = ( }); }; -function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean): void { + +/** + * Ask, once at login, which organization this machine's commands run against. + * + * Single-select: x-org-id carries one organization, so a command runs against the personal account or + * exactly one org. It asks nothing when --org already answered, when the account has no organizations, + * or when stdin is not a terminal, which is the CI case. A failure here never fails the login: the + * access key has already been minted by this point. + */ +// `export const`, like writeConnectionInfo: TypeScript routes calls to an exported const through the +// exports object, which is what lets a test stub promptForLine without a terminal. +export const chooseOrgContext = (command?: cli.ICommand): Promise => { + if (command?.org) { + return resolveOrg(command.org).then((org: Org): OrgContext => ({ id: org.id, slug: org.slug })); + } + + if (!process.stdin.isTTY) return Q(null); + + return sdk + .getOrgs() + .then((orgs: Org[]): Promise => { + if (!orgs.length) return Q(null); + + log(""); + log("You belong to the following organizations:"); + orgs.forEach((org: Org, index: number) => log(` ${index + 1}) ${org.name} (${org.slug})`)); + log(" 0) Personal account"); + + return exports.promptForLine(`Which should commands run against? [0-${orgs.length}, default 0]:`).then( + (answer: string): OrgContext => { + const choice: number = parseInt(answer, 10); + // Anything else means personal, which is the safe answer: it touches only your own apps. + if (!(choice >= 1 && choice <= orgs.length)) return null; + return { id: orgs[choice - 1].id, slug: orgs[choice - 1].slug }; + } + ); + }) + .catch((): OrgContext => null); +}; + +export const promptForLine = (message: string): Promise => { + return Promise((resolve): void => { + prompt.message = ""; + prompt.delimiter = ""; + prompt.start(); + prompt.get({ properties: { response: { description: chalk.cyan(message) } } }, (err: any, result: any): void => { + resolve(err || !result ? "" : String(result.response ?? "").trim()); + }); + }); +}; + +// --org, then DEPLOYPULSE_ORG_ID, then `dpctl org use`. The variable beats the session file so CI can +// switch context without rewriting it. `resolve` marks the one that costs a lookup: --org takes a slug +// or a name, the other two are already ids. +function requestedOrg(command: cli.ICommand): { value: string; resolve: boolean } | null { + if (command.org) return { value: command.org, resolve: true }; + if (process.env.DEPLOYPULSE_ORG_ID) return { value: process.env.DEPLOYPULSE_ORG_ID, resolve: false }; + if (connectionInfo?.orgId) return { value: connectionInfo.orgId, resolve: false }; + return null; +} + +function resolveOrg(requested: string): Promise { + return sdk.getOrgs().then((orgs: Org[]): Org => { + const needle = requested.toLowerCase(); + // Tiered, most specific first, because slugs are unique server-side but NAMES ARE NOT: two orgs + // called "Acme" get slugs "acme" and "acme-2", and one find() over id|slug|name could match the + // wrong one by name and persist it. + const byId = orgs.filter((org: Org) => org.id === requested); + const bySlug = orgs.filter((org: Org) => (org.slug ?? "").toLowerCase() === needle); + const byName = orgs.filter((org: Org) => (org.name ?? "").toLowerCase() === needle); + const tier = byId.length ? byId : bySlug.length ? bySlug : byName; + if (tier.length > 1) { + throw new Error( + `"${requested}" matches ${tier.length} organizations (${tier.map((org: Org) => org.slug).join(", ")}). Use the slug or the id.` + ); + } + const match = tier[0]; + if (!match) { + const known = orgs.length ? orgs.map((org: Org) => org.slug).join(", ") : "none"; + throw new Error(`No organization "${requested}". Organizations you belong to: ${known}.`); + } + return match; + }); +} + +// Runs after authentication and before the command, so every request it makes carries the header. +function applyOrgContext(command: cli.ICommand): Promise { + const requested = requestedOrg(command); + if (!requested || !sdk || command.type === cli.CommandType.orgList || command.type === cli.CommandType.orgUse) { + return Q(null); + } + if (!requested.resolve) { + sdk.setOrgId(requested.value); + return Q(null); + } + return resolveOrg(requested.value).then((org: Org): void => { + sdk.setOrgId(org.id); + }); +} + +function orgList(command: cli.IOrgListCommand): Promise { + throwForInvalidOutputFormat(command.format); + + return sdk.getOrgs().then((orgs: Org[]): void => { + const activeId = requestedOrg(command)?.value ?? null; + if (command.format === "json") { + printJson(orgs.map((org: Org) => ({ ...org, active: org.id === activeId || org.slug === activeId }))); + return; + } + printTable(["", "Slug", "Name", "Role"], (dataSource: any[]): void => { + orgs.forEach((org: Org) => { + const active = org.id === activeId || org.slug === activeId; + dataSource.push([active ? chalk.green("*") : "", org.slug, org.name, org.role]); + }); + }); + if (!orgs.length) { + log("You do not belong to any organizations."); + } + }); +} + +/** The env var beats the session file, so a change to the file is a no-op while it is set. */ +function warnIfOrgEnvOverrides(): void { + if (process.env.DEPLOYPULSE_ORG_ID) { + log( + chalk.yellow( + `[Warning] DEPLOYPULSE_ORG_ID is set (${process.env.DEPLOYPULSE_ORG_ID}) and takes precedence, so commands still run against that organization. Unset it for this change to take effect.` + ) + ); + } +} + +function orgUse(command: cli.IOrgUseCommand): Promise { + if (!connectionInfo) { + throw new Error( + "There is no session file to save the organization to. Run 'dpctl login' first, or set DEPLOYPULSE_ORG_ID if you authenticate with an access key." + ); + } + return resolveOrg(command.organization).then((org: Org): void => { + writeConnectionInfo({ ...connectionInfo, orgId: org.id, orgSlug: org.slug }); + log(`Commands now run against ${chalk.cyan(org.name)} (${org.slug}). Run ${chalk.cyan("dpctl org clear")} to go back to your personal account.`); + warnIfOrgEnvOverrides(); + }); +} + +function orgClear(command: cli.ICommand): Promise { + if (!connectionInfo?.orgId && !connectionInfo?.orgSlug) { + log("Commands already run against your personal account."); + return Q(null); + } + const { orgId, orgSlug, ...rest } = connectionInfo; + writeConnectionInfo(rest); + log( + orgSlug + ? `Commands now run against your personal account. ${chalk.cyan("dpctl org use " + orgSlug)} switches back.` + : "Commands now run against your personal account." + ); + warnIfOrgEnvOverrides(); + return Q(null); +} + +// `export const`, not `export function`: TypeScript compiles calls to an exported const through the +// exports object, which is what lets the tests stub this and keep the real session file untouched. +export const writeConnectionInfo = (connectionInfo: ILoginConnectionInfo): void => { + fs.writeFileSync(configFilePath, JSON.stringify(connectionInfo), { encoding: "utf8" }); +}; + +function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean, org?: OrgContext): void { const connectionInfo: ILoginConnectionInfo = { accessKey: accessKey, preserveAccessKeyOnLogout: preserveAccessKeyOnLogout, + ...(org?.id ? { orgId: org.id, orgSlug: org.slug } : {}), }; - const json: string = JSON.stringify(connectionInfo); - fs.writeFileSync(configFilePath, json, { encoding: "utf8" }); + writeConnectionInfo(connectionInfo); log( `\r\nSuccessfully logged-in. Your session file was written to ${chalk.cyan(configFilePath)}. You can run the ${chalk.cyan( diff --git a/script/command-parser.ts b/script/command-parser.ts index ce76fcc..6170df7 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -145,6 +145,12 @@ function addCommonConfiguration(yargs: yargs.Argv): void { yargs .wrap(/*columnLimit*/ null) .string("_") // Interpret non-hyphenated arguments as strings (e.g. an app version of '1.10'). + // Declared here so every command accepts it; strictOptions would reject it otherwise. + .option("org", { + demand: false, + description: "Organization to run this command against (slug, name or id). Overrides 'dpctl org use'", + type: "string", + }) // strictOptions, NOT strict: unknown flags become errors instead of being silently dropped, which is // what let `--deployment Production` ship releases to Staging. Full .strict() also validates // positionals, and this parser declares only positional counts, so every command would fail with @@ -163,6 +169,22 @@ function addCommonConfiguration(yargs: yargs.Argv): void { }); } +function orgList(commandName: string, yargs: yargs.Argv): void { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " org " + commandName + " [options]") + .demand(/*count*/ 0, /*max*/ 0) + .example("org " + commandName, "List your organizations in tabular format") + .example("org " + commandName + " --format json", "List your organizations in JSON format") + .option("format", { + default: "table", + demand: false, + description: 'Output format to display your organizations in ("json" or "table")', + type: "string", + }); + addCommonConfiguration(yargs); +} + function appList(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs @@ -557,6 +579,31 @@ yargs .example("logout", "Logs out and ends your current session"); addCommonConfiguration(yargs); }) + .command("org", "View and switch the organization your commands run against", (yargs: yargs.Argv) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " org ") + .demand(/*count*/ 2, /*max*/ 3) + .command("list", "List the organizations you belong to", (yargs: yargs.Argv) => orgList("list", yargs)) + .command("ls", "List the organizations you belong to", (yargs: yargs.Argv) => orgList("ls", yargs)) + .command("use", "Run later commands against an organization", (yargs: yargs.Argv): void => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " org use ") + .demand(/*count*/ 1, /*max*/ 1) // The organization; the category counts the words before it. + .example("org use acme", "Run later commands against the acme organization") + .example("org use " + chalk.cyan(""), "The same, by organization id"); + addCommonConfiguration(yargs); + }) + .command("clear", "Go back to running commands against your personal account", (yargs: yargs.Argv): void => { + isValidCommand = true; + yargs.usage(USAGE_PREFIX + " org clear").demand(/*count*/ 0, /*max*/ 0); + addCommonConfiguration(yargs); + }) + .check((argv: any, aliases: { [aliases: string]: string }): any => isValidCommand); + + addCommonConfiguration(yargs); + }) .command("patch", "Update the metadata for an existing release", (yargs: yargs.Argv) => { yargs .usage(USAGE_PREFIX + " patch [options]") @@ -1292,6 +1339,27 @@ export function createCommand(): cli.ICommand { cmd = { type: cli.CommandType.logout }; break; + case "org": + switch (arg1) { + case "list": + case "ls": + cmd = { type: cli.CommandType.orgList }; + (cmd).format = argv["format"] as any; + break; + + case "use": + if (arg2) { + cmd = { type: cli.CommandType.orgUse }; + (cmd).organization = arg2; + } + break; + + case "clear": + cmd = { type: cli.CommandType.orgClear }; + break; + } + break; + case "patch": if (arg1 && arg2) { cmd = { type: cli.CommandType.patch }; @@ -1413,6 +1481,11 @@ export function createCommand(): cli.ICommand { break; } + // --org applies to every command, so it is read once here rather than in each case above. + if (cmd && argv["org"]) { + cmd.org = String(argv["org"]); + } + return cmd; } } diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 0b8014a..a00c6a3 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -22,6 +22,7 @@ import { Deployment, DeploymentMetrics, Headers, + Org, Package, PackageInfo, ServerAccessKey, @@ -72,6 +73,7 @@ class AccountManager { public static ERROR_UNAUTHORIZED = 401; private _accessKey: string; + private _orgId: string | null = null; private _serverUrl: string; private _customHeaders: Headers; @@ -153,6 +155,15 @@ class AccountManager { }); } + // Everything the CLI sends is scoped to this organization while it is set. + public setOrgId(orgId: string | null): void { + this._orgId = orgId; + } + + public getOrgs(): Promise { + return this.get(urlEncode(["/orgs"])).then((res: JsonResponse) => res.body.orgs as Org[]); + } + public getAccessKeys(): Promise { return this.get(urlEncode(["/accessKeys"])).then((res: JsonResponse) => { const accessKeys: AccessKey[] = []; @@ -646,6 +657,9 @@ class AccountManager { request.set("Accept", `application/vnd.code-push.v${AccountManager.API_VERSION}+json`); request.set("Authorization", `Bearer ${this._accessKey}`); request.set("X-CodePush-SDK-Version", packageJson.version); + if (this._orgId) { + request.set("x-org-id", this._orgId); + } } } diff --git a/script/types.ts b/script/types.ts index 81a6cb0..2cdf152 100644 --- a/script/types.ts +++ b/script/types.ts @@ -20,6 +20,14 @@ export interface CodePushError { statusCode: number; } +export interface Org { + id: string; + slug: string; + name: string; + role: string; + isOwner: boolean; +} + export interface AccessKey { createdTime: number; expires: number; diff --git a/script/types/cli.ts b/script/types/cli.ts index 0c59561..2f37e1a 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -30,6 +30,9 @@ export enum CommandType { deploymentRename, login, logout, + orgClear, + orgList, + orgUse, patch, promote, release, @@ -42,6 +45,7 @@ export enum CommandType { export interface ICommand { type: CommandType; + org?: string; } export interface IAccessKeyAddCommand extends ICommand { @@ -169,6 +173,14 @@ export interface ILinkCommand extends ICommand { serverUrl?: string; } +export interface IOrgListCommand extends ICommand { + format: string; +} + +export interface IOrgUseCommand extends ICommand { + organization: string; +} + export interface ILoginCommand extends ICommand { accessKey: string; } diff --git a/test/cli.ts b/test/cli.ts index d06aa5d..bafeef7 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -121,6 +121,19 @@ export class SdkStub { ]); } + public setOrgId(orgId: string | null): void { + this.orgId = orgId; + } + + public orgId: string | null = null; + + public getOrgs(): Q.Promise { + return Q([ + { id: "org-id-acme", slug: "acme", name: "Acme Inc", role: "admin", isOwner: true }, + { id: "org-id-other", slug: "other-co", name: "Other Co", role: "viewer", isOwner: false }, + ]); + } + public getApp(appName: string): Q.Promise { // No platform: apps created before platforms existed are the common case, and the release guards // deliberately do not fire for them. @@ -301,6 +314,7 @@ describe("CLI", () => { var sandbox: sinon.SinonSandbox; var spawn: sinon.SinonStub; var wasConfirmed = true; + var writtenConnectionInfo: any = null; const INVALID_RELEASE_FILE_ERROR_MESSAGE: string = "It is unnecessary to package releases in a .zip or binary file. Please specify the direct path to the update content's directory (e.g. /platforms/ios/www) or file (e.g. main.jsbundle)."; @@ -317,6 +331,13 @@ describe("CLI", () => { (cmdexec as any).sdk = new SdkStub(); + // Nothing in this suite may touch the real session file. Stubbed for every test, not just the ones + // that mean to write, because a test that writes it clobbers the developer's own session. + writtenConnectionInfo = null; + sandbox.stub(cmdexec, "writeConnectionInfo").callsFake((info: any) => { + writtenConnectionInfo = info; + }); + sandbox.stub(cmdexec, "createEmptyTempReleaseFolder").callsFake(() => Q.Promise((resolve) => resolve())); log = sandbox.stub(cmdexec, "log").callsFake(() => {}); spawn = sandbox.stub(cmdexec, "spawn").callsFake(() => { @@ -1944,6 +1965,217 @@ describe("CLI", () => { .done(); }); + it("orgList marks the organization commands currently run against", (done: Mocha.Done): void => { + var command: cli.IOrgListCommand = { + type: cli.CommandType.orgList, + format: "json", + org: "acme", + }; + + cmdexec.execute(command).done((): void => { + var actual: any[] = JSON.parse(log.args[0][0]); + assert.equal(actual.length, 2); + assert.strictEqual(actual[0].active, true, "acme is the active organization"); + assert.strictEqual(actual[1].active, false); + done(); + }); + }); + + it("orgList rejects an output format it cannot produce", (done: Mocha.Done): void => { + var command: cli.IOrgListCommand = { type: cli.CommandType.orgList, format: "xml" }; + + cmdexec.execute(command).done( + () => done(new Error("an unsupported format should be rejected")), + () => done() + ); + }); + + it("orgUse resolves a slug to the organization's id", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => { accessKey: "key" }); + + var command: cli.IOrgUseCommand = { type: cli.CommandType.orgUse, organization: "acme" }; + + cmdexec.execute(command).done((): void => { + // The id is saved, not the slug, so later commands send the header with no extra lookup. + assert.strictEqual(writtenConnectionInfo.orgId, "org-id-acme"); + assert.strictEqual(writtenConnectionInfo.orgSlug, "acme"); + assert.strictEqual(writtenConnectionInfo.accessKey, "key", "the session is preserved, not replaced"); + done(); + }); + }); + + it("orgUse refuses an organization the account does not belong to, and names the ones it does", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => { accessKey: "key" }); + var command: cli.IOrgUseCommand = { type: cli.CommandType.orgUse, organization: "not-mine" }; + + cmdexec.execute(command).done( + () => done(new Error("an unknown organization should be rejected")), + (error: any) => { + assert.ok(error.message.indexOf("acme") >= 0, error.message); + done(); + } + ); + }); + + it("--org puts the command in that organization's context", (done: Mocha.Done): void => { + var command: cli.IAppListCommand = { type: cli.CommandType.appList, format: "json", org: "acme" }; + + cmdexec.execute(command).done((): void => { + assert.strictEqual((cmdexec.sdk as any).orgId, "org-id-acme"); + done(); + }); + }); + + it("runs against the personal account when nothing asks for an organization", (done: Mocha.Done): void => { + var command: cli.IAppListCommand = { type: cli.CommandType.appList, format: "json" }; + + cmdexec.execute(command).done((): void => { + assert.strictEqual((cmdexec.sdk as any).orgId, null); + done(); + }); + }); + + // --------------------------------------------------------------------------- + // The organization picker shown once, at login. Single-select on purpose: x-org-id carries ONE + // organization, so a command runs against the personal account or against exactly one org. + // --------------------------------------------------------------------------- + + function withTty(isTty: boolean, body: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { value: isTty, configurable: true }); + try { + return body(); + } finally { + if (descriptor) Object.defineProperty(process.stdin, "isTTY", descriptor); + else delete (process.stdin as any).isTTY; + } + } + + it("login asks nothing when stdin is not a terminal", (done: Mocha.Done): void => { + // A CI job piping `dpctl login --accessKey` must never block on a prompt. + const promptForLine = sandbox.stub(cmdexec, "promptForLine").callsFake(() => Q("1")); + + withTty(false, () => { + cmdexec.chooseOrgContext().done((org: any): void => { + sinon.assert.notCalled(promptForLine); + assert.strictEqual(org, null, "no prompt means the personal account"); + done(); + }, done); + }); + }); + + it("login puts you in the organization you pick", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "promptForLine").callsFake(() => Q("1")); + + withTty(true, () => { + cmdexec.chooseOrgContext().done((org: any): void => { + assert.strictEqual(org.id, "org-id-acme"); + assert.strictEqual(org.slug, "acme"); + done(); + }, done); + }); + }); + + it("login reads 0 as the personal account", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "promptForLine").callsFake(() => Q("0")); + + withTty(true, () => { + cmdexec.chooseOrgContext().done((org: any): void => { + assert.strictEqual(org, null); + done(); + }, done); + }); + }); + + it("login treats an answer that is not on the list as the personal account", (done: Mocha.Done): void => { + // Anything unrecognised means personal, which is the safe answer: it touches only your own apps. + sandbox.stub(cmdexec, "promptForLine").callsFake(() => Q("99")); + + withTty(true, () => { + cmdexec.chooseOrgContext().done((org: any): void => { + assert.strictEqual(org, null); + done(); + }, done); + }); + }); + + it("login skips the picker when --org already answered it", (done: Mocha.Done): void => { + const promptForLine = sandbox.stub(cmdexec, "promptForLine").callsFake(() => Q("1")); + + withTty(true, () => { + cmdexec.chooseOrgContext({ type: cli.CommandType.login, org: "other-co" }).done((org: any): void => { + sinon.assert.notCalled(promptForLine); + assert.strictEqual(org.id, "org-id-other"); + done(); + }, done); + }); + }); + + it("a failure listing organizations does not fail the login", (done: Mocha.Done): void => { + // The access key has already been minted by this point, so erroring out would leave the user + // authenticated with nothing written to disk. + sandbox.stub(cmdexec.sdk, "getOrgs").callsFake(() => Q.reject(new Error("boom"))); + + withTty(true, () => { + cmdexec.chooseOrgContext().done((org: any): void => { + assert.strictEqual(org, null); + done(); + }, done); + }); + }); + + it("DEPLOYPULSE_ORG_ID beats the organization saved in the session file", (done: Mocha.Done): void => { + // CI must be able to switch context without rewriting the file the developer logged in with. + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => { accessKey: "key", orgId: "org-id-other" }); + process.env.DEPLOYPULSE_ORG_ID = "org-id-acme"; + + var command: cli.IAppListCommand = { type: cli.CommandType.appList, format: "json" }; + + cmdexec.execute(command).done((): void => { + delete process.env.DEPLOYPULSE_ORG_ID; + assert.strictEqual((cmdexec.sdk as any).orgId, "org-id-acme"); + done(); + }, (error: any) => { + delete process.env.DEPLOYPULSE_ORG_ID; + done(error); + }); + }); + + it("orgUse prefers a slug over another organization's name", (done: Mocha.Done): void => { + // Slugs are unique server-side but names are not, so a single find() over id|slug|name could match + // the wrong org by name. "acme" is one org's slug and another's name; the slug has to win. + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => { accessKey: "key" }); + sandbox.stub(cmdexec.sdk, "getOrgs").callsFake(() => + Q([ + { id: "id-slug-acme", slug: "acme", name: "Beta Corp", role: "admin", isOwner: true }, + { id: "id-named-acme", slug: "beta-co", name: "acme", role: "admin", isOwner: true }, + ]) + ); + + cmdexec.execute({ type: cli.CommandType.orgUse, organization: "acme" }).done((): void => { + assert.strictEqual(writtenConnectionInfo.orgId, "id-slug-acme"); + done(); + }, done); + }); + + it("orgUse refuses a name two organizations share", (done: Mocha.Done): void => { + sandbox.stub(cmdexec, "deserializeConnectionInfo").callsFake(() => { accessKey: "key" }); + sandbox.stub(cmdexec.sdk, "getOrgs").callsFake(() => + Q([ + { id: "id-1", slug: "acme", name: "Acme Inc", role: "admin", isOwner: true }, + { id: "id-2", slug: "acme-2", name: "Acme Inc", role: "admin", isOwner: true }, + ]) + ); + + cmdexec.execute({ type: cli.CommandType.orgUse, organization: "Acme Inc" }).done( + () => done(new Error("an ambiguous name should be refused")), + (error: any) => { + assert.ok(/matches 2 organizations/.test(error.message), error.message); + done(); + } + ); + }); + it("sessionList lists session name and expires fields", (done: Mocha.Done): void => { var command: cli.IAccessKeyListCommand = { type: cli.CommandType.sessionList, diff --git a/test/management-sdk.ts b/test/management-sdk.ts index f5ad4e8..8607cda 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -407,6 +407,19 @@ describe("Management SDK", () => { ); }); + it("sends x-org-id once an organization is set, and not before", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ apps: [] }), 200); + manager.getApps().done(() => { + assert.ok(!lastRequestHeaders["x-org-id"], "personal account requests must not carry the header"); + + manager.setOrgId("org-id-acme"); + manager.getApps().done(() => { + assert.strictEqual(lastRequestHeaders["x-org-id"], "org-id-acme"); + done(); + }, rejectHandler); + }, rejectHandler); + }); + 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(() => { @@ -445,14 +458,16 @@ function rejectHandler(val: any): void { // Wrapper for superagent-mock that abstracts away information not needed for SDK tests let lastRequestBody: any; +let lastRequestHeaders: 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 { + fixtures: function (match: any, params: any, headers: any): any { lastRequestBody = params; + lastRequestHeaders = headers || {}; var isOk = statusCode >= 200 && statusCode < 300; if (!isOk) { var err: any = new Error(bodyText);