diff --git a/src/base-command.ts b/src/base-command.ts index ea97bfad0..a2d9a3b31 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -474,7 +474,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // Make sure we have authentication after potentially modifying options if (!clientOptions.key && !clientOptions.token) { this.fail( - "Authentication required. Please provide either an API key, a token, or log in first.", + 'Authentication required. Please provide either an API key, a token, or log in first. Run "ably accounts login" to configure authentication.', flags, "auth", ); diff --git a/src/commands/accounts/logout.ts b/src/commands/accounts/logout.ts index 53fb0cb8b..f98ec4666 100644 --- a/src/commands/accounts/logout.ts +++ b/src/commands/accounts/logout.ts @@ -7,9 +7,9 @@ import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class AccountsLogout extends ControlBaseCommand { static override args = { - accountAlias: Args.string({ + accountAliasOrId: Args.string({ description: - "Alias of the account to log out from (defaults to current account)", + "Alias or ID of the account to log out from (defaults to current account)", required: false, }), }; @@ -19,6 +19,7 @@ export default class AccountsLogout extends ControlBaseCommand { static override examples = [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> mycompany", + "<%= config.bin %> <%= command.id %> VgQpOZ", "<%= config.bin %> <%= command.id %> --json", "<%= config.bin %> <%= command.id %> --pretty-json", ]; @@ -31,29 +32,24 @@ export default class AccountsLogout extends ControlBaseCommand { public async run(): Promise { const { args, flags } = await this.parse(AccountsLogout); - // Determine which account to log out from - const targetAlias = - args.accountAlias || this.configManager.getCurrentAccountAlias(); - - if (!targetAlias) { - this.fail( - 'No account is currently selected and no alias provided. Use "ably accounts list" to see available accounts.', - flags, - "accountLogout", - ); - } - - const accounts = this.configManager.listAccounts(); - const accountExists = accounts.some( - (account) => account.alias === targetAlias, - ); - - if (!accountExists) { - this.fail( - `Account with alias "${targetAlias}" not found. Use "ably accounts list" to see available accounts.`, - flags, - "accountLogout", - ); + // The accountAliasOrId arg accepts two formats: + // 1. Account alias — e.g. "mycompany" (the label set during login) + // 2. Account ID — e.g. "VgQpOZ" (the Ably-assigned account ID) + let targetAlias: string; + if (args.accountAliasOrId) { + targetAlias = this.resolveAccountAlias(args.accountAliasOrId, flags); + } else { + const currentAlias = this.configManager.getCurrentAccountAlias(); + if (!currentAlias) { + this.fail( + 'No account is currently selected and no alias or ID provided. Run "ably accounts list" to see available accounts.', + flags, + "accountLogout", + ); + } + // Validate that the current alias still exists in the accounts list. + // This catches stale config where current.account points to a removed alias. + targetAlias = this.resolveAccountAlias(currentAlias, flags); } // In JSON mode, require --force to prevent accidental destructive actions diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index 90aac9d14..a6c995221 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -10,8 +10,8 @@ import { pickUniqueAlias, slugifyAccountName } from "../../utils/slugify.js"; export default class AccountsSwitch extends ControlBaseCommand { static override args = { - accountAlias: Args.string({ - description: "Alias of the account to switch to", + accountAliasOrId: Args.string({ + description: "Alias or ID of the account to switch to", required: false, }), }; @@ -21,6 +21,7 @@ export default class AccountsSwitch extends ControlBaseCommand { static override examples = [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> mycompany", + "<%= config.bin %> <%= command.id %> VgQpOZ", "<%= config.bin %> <%= command.id %> --json", "<%= config.bin %> <%= command.id %> --pretty-json", ]; @@ -38,7 +39,7 @@ export default class AccountsSwitch extends ControlBaseCommand { if (localAccounts.length === 0) { if (this.shouldOutputJson(flags)) { this.fail( - 'No accounts configured. Use "ably accounts login" to add an account.', + 'No accounts configured. Run "ably accounts login" to add an account.', flags, "accountSwitch", ); @@ -50,16 +51,23 @@ export default class AccountsSwitch extends ControlBaseCommand { return; } - // If alias is provided, switch directly - if (args.accountAlias) { - await this.switchToLocalAccount(args.accountAlias, localAccounts, flags); + // If alias or ID is provided, resolve and switch directly. + // The accountAliasOrId arg accepts two formats: + // 1. Account alias — e.g. "mycompany" (the label set during login) + // 2. Account ID — e.g. "VgQpOZ" (the Ably-assigned account ID) + if (args.accountAliasOrId) { + const resolvedAlias = this.resolveAccountAlias( + args.accountAliasOrId, + flags, + ); + await this.switchToLocalAccount(resolvedAlias, flags); return; } // JSON mode requires an explicit alias if (this.shouldOutputJson(flags)) { this.fail( - "No account alias provided. Please specify an account alias to switch to.", + 'No account alias or ID provided. Run "ably accounts list" to see available accounts.', flags, "accountSwitch", { @@ -162,16 +170,7 @@ export default class AccountsSwitch extends ControlBaseCommand { }; if (selected.type === "local") { - const validAccounts = localAccounts - .filter((a) => a.account.accountId && a.account.accountName) - .map((a) => ({ - account: { - accountId: a.account.accountId!, - accountName: a.account.accountName!, - }, - alias: a.alias, - })); - await this.switchToLocalAccount(selected.alias, validAccounts, flags); + await this.switchToLocalAccount(selected.alias, flags); return true; } @@ -253,29 +252,10 @@ export default class AccountsSwitch extends ControlBaseCommand { private async switchToLocalAccount( alias: string, - accounts: Array<{ - account: { accountId: string; accountName: string }; - alias: string; - }>, flags: Record, ): Promise { - const accountExists = accounts.some((account) => account.alias === alias); - - if (!accountExists) { - this.fail( - `Account with alias "${alias}" not found. Use "ably accounts list" to see available accounts.`, - flags, - "accountSwitch", - { - availableAccounts: accounts.map(({ account, alias }) => ({ - alias, - id: account.accountId, - name: account.accountName, - })), - }, - ); - } - + // Alias is already validated by resolveAccountAlias() or interactive + // selection before reaching this method. this.configManager.switchAccount(alias); // Store custom endpoint if provided diff --git a/src/commands/apps/rules/delete.ts b/src/commands/apps/rules/delete.ts index 2a0ca93ce..5293d1f6e 100644 --- a/src/commands/apps/rules/delete.ts +++ b/src/commands/apps/rules/delete.ts @@ -8,7 +8,7 @@ import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class RulesDeleteCommand extends ControlBaseCommand { static args = { - nameOrId: Args.string({ + ruleNameOrId: Args.string({ description: "Name or ID of the rule to delete", required: true, }), @@ -40,14 +40,19 @@ export default class RulesDeleteCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - // Find the namespace by name or ID + // Resolve the rule. The ruleNameOrId arg accepts the rule identifier which serves as both the name and ID + // (e.g. "chat", "events"). For channel rules, the namespace ID is the rule name — they are the + // same value — so a single match on n.id covers both cases. const namespaces = await controlApi.listNamespaces(appId); - const namespace = namespaces.find((n) => n.id === args.nameOrId); + const namespace = namespaces.find((n) => n.id === args.ruleNameOrId); if (!namespace) { - this.fail(`Rule "${args.nameOrId}" not found`, flags, "ruleDelete", { - appId, - }); + this.fail( + `Rule "${args.ruleNameOrId}" not found. Run "ably apps rules list" to see available rules.`, + flags, + "ruleDelete", + { appId }, + ); } // In JSON mode, require --force to prevent accidental destructive actions diff --git a/src/commands/apps/rules/update.ts b/src/commands/apps/rules/update.ts index 726b7b5a3..e1e0484d1 100644 --- a/src/commands/apps/rules/update.ts +++ b/src/commands/apps/rules/update.ts @@ -6,7 +6,7 @@ import { formatLabel, formatResource } from "../../../utils/output.js"; export default class RulesUpdateCommand extends ControlBaseCommand { static args = { - nameOrId: Args.string({ + ruleNameOrId: Args.string({ description: "Name or ID of the rule to update", required: true, }), @@ -120,14 +120,19 @@ export default class RulesUpdateCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - // Find the namespace by name or ID + // Resolve the rule. The ruleNameOrId arg accepts the rule identifier which serves as both the name and ID + // (e.g. "chat", "events"). For channel rules, the namespace ID is the rule name — they are the + // same value — so a single match on n.id covers both cases. const namespaces = await controlApi.listNamespaces(appId); - const namespace = namespaces.find((n) => n.id === args.nameOrId); + const namespace = namespaces.find((n) => n.id === args.ruleNameOrId); if (!namespace) { - this.fail(`Rule "${args.nameOrId}" not found`, flags, "ruleUpdate", { - appId, - }); + this.fail( + `Rule "${args.ruleNameOrId}" not found. Run "ably apps rules list" to see available rules.`, + flags, + "ruleUpdate", + { appId }, + ); } // Prepare update data diff --git a/src/commands/apps/switch.ts b/src/commands/apps/switch.ts index a87c1ae05..b62b58487 100644 --- a/src/commands/apps/switch.ts +++ b/src/commands/apps/switch.ts @@ -1,13 +1,12 @@ import { Args } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { ControlApi } from "../../services/control-api.js"; import { formatResource } from "../../utils/output.js"; export default class AppsSwitch extends ControlBaseCommand { static override args = { - appId: Args.string({ - description: "ID of the app to switch to", + appNameOrId: Args.string({ + description: "App name or ID to switch to", required: false, }), }; @@ -15,9 +14,9 @@ export default class AppsSwitch extends ControlBaseCommand { static override description = "Switch to a different Ably app"; static override examples = [ - "<%= config.bin %> <%= command.id %> APP_ID", + '<%= config.bin %> <%= command.id %> "My App"', + "<%= config.bin %> <%= command.id %> app-id", "<%= config.bin %> <%= command.id %>", - "<%= config.bin %> <%= command.id %> APP_ID --json", ]; static override flags = { @@ -28,11 +27,27 @@ export default class AppsSwitch extends ControlBaseCommand { const { args, flags } = await this.parse(AppsSwitch); try { - const controlApi = this.createControlApi({}); + const controlApi = this.createControlApi(flags); - // If app ID is provided, switch directly - if (args.appId) { - await this.switchToApp(args.appId, controlApi, flags); + // If app name or ID is provided, resolve and switch directly. + // The appNameOrId arg accepts two formats: + // 1. App name — e.g. "My App" (human-readable, may contain spaces) + // 2. App ID — e.g. "s57drg" (the Ably-assigned app ID) + if (args.appNameOrId) { + const apps = await controlApi.listApps(); + const matchedApp = apps.find( + (a) => a.name === args.appNameOrId || a.id === args.appNameOrId, + ); + + if (!matchedApp) { + this.fail( + `App "${args.appNameOrId}" not found. Run "ably apps list" to see available apps.`, + flags, + "appSwitch", + ); + } + + this.saveAndReportSwitch(matchedApp, flags); return; } @@ -43,22 +58,7 @@ export default class AppsSwitch extends ControlBaseCommand { const selectedApp = await this.interactiveHelper.selectApp(controlApi); if (selectedApp) { - // Save the app info and set as current - this.configManager.setCurrentApp(selectedApp.id); - this.configManager.storeAppInfo(selectedApp.id, { - appName: selectedApp.name, - }); - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { app: { id: selectedApp.id, name: selectedApp.name } }, - flags, - ); - } else { - this.logSuccessMessage( - `Switched to app ${formatResource(selectedApp.name)} (${selectedApp.id}).`, - flags, - ); - } + this.saveAndReportSwitch(selectedApp, flags); } else { this.logWarning("App switch cancelled.", flags); } @@ -67,31 +67,20 @@ export default class AppsSwitch extends ControlBaseCommand { } } - private async switchToApp( - appId: string, - controlApi: ControlApi, + private saveAndReportSwitch( + app: { id: string; name: string }, flags: Record, - ): Promise { - try { - // Verify the app exists - const app = await controlApi.getApp(appId); - - // Save app info and set as current - this.configManager.setCurrentApp(appId); - this.configManager.storeAppInfo(appId, { appName: app.name }); + ): void { + this.configManager.setCurrentApp(app.id); + this.configManager.storeAppInfo(app.id, { appName: app.name }); - if (this.shouldOutputJson(flags)) { - this.logJsonResult({ app: { id: app.id, name: app.name } }, flags); - } else { - this.logSuccessMessage( - `Switched to app ${formatResource(app.name)} (${app.id}).`, - flags, - ); - } - } catch (error) { - this.fail(error, flags, "appSwitch", { - context: `switching to app "${appId}"`, - }); + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ app: { id: app.id, name: app.name } }, flags); + } else { + this.logSuccessMessage( + `Switched to app ${formatResource(app.name)} (${app.id}).`, + flags, + ); } } } diff --git a/src/commands/apps/update.ts b/src/commands/apps/update.ts index c661e180b..eb654a340 100644 --- a/src/commands/apps/update.ts +++ b/src/commands/apps/update.ts @@ -5,19 +5,19 @@ import { formatLabel, formatResource } from "../../utils/output.js"; export default class AppsUpdateCommand extends ControlBaseCommand { static args = { - appId: Args.string({ - description: "App ID to update", + appNameOrId: Args.string({ + description: "App name or ID to update", required: true, }), }; - static description = "Update an app"; + static description = "Update the name or TLS setting of an app"; static examples = [ - '$ ably apps update app-id --name "Updated App Name"', - "$ ably apps update app-id --tls-only", - '$ ably apps update app-id --name "Updated App Name" --json', - '$ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps update app-id --name "Updated App Name"', + '$ ably apps update "My App" --name "New App Name"', + "$ ably apps update my-app-id --tls-only", + "$ ably apps update my-app-id --no-tls-only", + '$ ably apps update "My App" --name "New App Name" --tls-only --json', ]; static flags = { @@ -26,6 +26,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand { description: "New name for the app", }), "tls-only": Flags.boolean({ + allowNo: true, description: "Whether the app should accept TLS connections only", }), }; @@ -44,13 +45,18 @@ export default class AppsUpdateCommand extends ControlBaseCommand { "At least one update parameter (--name or --tls-only) must be provided", flags, "appUpdate", - { appId: args.appId }, + { appNameOrId: args.appNameOrId }, ); } + // The appNameOrId arg accepts two formats: + // 1. App name — e.g. "My App" (human-readable, may contain spaces) + // 2. App ID — e.g. "s57drg" (the Ably-assigned app ID) + const appId = await this.resolveAppIdFromNameOrId(args.appNameOrId, flags); + try { const controlApi = this.createControlApi(flags); - this.logProgress(`Updating app ${formatResource(args.appId)}`, flags); + this.logProgress(`Updating app ${formatResource(appId)}`, flags); const updateData: { name?: string; tlsOnly?: boolean } = {}; @@ -62,7 +68,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand { updateData.tlsOnly = flags["tls-only"]; } - const app = await controlApi.updateApp(args.appId, updateData); + const app = await controlApi.updateApp(appId, updateData); if (this.shouldOutputJson(flags)) { this.logJsonResult( @@ -100,9 +106,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand { this.logSuccessMessage("App updated successfully.", flags); } catch (error) { - this.fail(error, flags, "appUpdate", { - appId: args.appId, - }); + this.fail(error, flags, "appUpdate", { appId }); } } } diff --git a/src/commands/auth/keys/get.ts b/src/commands/auth/keys/get.ts index 05cfc1d85..e98764ed5 100644 --- a/src/commands/auth/keys/get.ts +++ b/src/commands/auth/keys/get.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatCapabilities } from "../../../utils/key-display.js"; @@ -13,7 +13,7 @@ export default class KeysGetCommand extends ControlBaseCommand { static args = { keyNameOrValue: Args.string({ description: - "Key name (APP_ID.KEY_ID), key ID, key label (e.g. Root), or full key value to get details for", + 'Key name "." or value ".:"', required: true, }), }; @@ -21,46 +21,22 @@ export default class KeysGetCommand extends ControlBaseCommand { static description = "Get details for a specific key"; static examples = [ - "$ ably auth keys get APP_ID.KEY_ID", - "$ ably auth keys get Root --app APP_ID", - "$ ably auth keys get KEY_ID --app APP_ID", - "$ ably auth keys get APP_ID.KEY_ID --json", + '$ ably auth keys get "APP_ID.KEY_ID"', + '$ ably auth keys get "APP_ID.KEY_ID:KEY_SECRET"', + '$ ably auth keys get "APP_ID.KEY_ID" --json', ]; static flags = { ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - env: "ABLY_APP_ID", - }), }; async run(): Promise { const { args, flags } = await this.parse(KeysGetCommand); - let appId: string | undefined; const keyIdentifier = args.keyNameOrValue; - // If flags.app is set, resolve it (could be a name or ID) - if (flags.app) { - appId = await this.resolveAppIdFromNameOrId(flags.app, flags); - } - - // If keyNameOrValue is in APP_ID.KEY_ID format (one period, no colon), extract appId. - // Only attempt this when no appId is already known (from --app flag or current app), - // to avoid misinterpreting labels containing periods (e.g. "v1.0") as APP_ID.KEY_ID. - // When appId IS known, the full identifier is passed to getKey() which matches by - // label, key ID, APP_ID.KEY_ID format, or full key value. - if (!appId && keyIdentifier.includes(".") && !keyIdentifier.includes(":")) { - const parts = keyIdentifier.split("."); - if (parts.length === 2) { - appId = parts[0]; - } - } - - if (!appId) { - appId = await this.requireAppId(flags); - } + // Extract appId from the key identifier (key name or key value) + const appId = this.resolveAppIdForKey(keyIdentifier, flags); // Display authentication information (after app resolution so name→ID is correct) await this.showAuthInfoIfNeeded(flags); @@ -69,9 +45,9 @@ export default class KeysGetCommand extends ControlBaseCommand { this.logProgress("Fetching key details", flags); const controlApi = this.createControlApi(flags); - const key = await controlApi.getKey(appId, keyIdentifier); + const fullKeyObject = await controlApi.getKey(appId, keyIdentifier); - const keyName = `${key.appId}.${key.id}`; + const keyName = `${fullKeyObject.appId}.${fullKeyObject.id}`; // Check if env var overrides the current key const currentKeyId = this.configManager.getKeyId(appId); @@ -85,9 +61,9 @@ export default class KeysGetCommand extends ControlBaseCommand { this.logJsonResult( { key: { - ...key, - created: new Date(key.created).toISOString(), - modified: new Date(key.modified).toISOString(), + ...fullKeyObject, + created: new Date(fullKeyObject.created).toISOString(), + modified: new Date(fullKeyObject.modified).toISOString(), keyName, ...(hasEnvOverride ? { @@ -104,17 +80,23 @@ export default class KeysGetCommand extends ControlBaseCommand { } else { this.log(formatHeading("Key Details")); this.log(`${formatLabel("Key Name")} ${formatResource(keyName)}`); - this.log(`${formatLabel("Key Label")} ${key.name || "Unnamed key"}`); + this.log( + `${formatLabel("Key Label")} ${fullKeyObject.name || "Unnamed key"}`, + ); for (const line of formatCapabilities( - key.capability as Record, + fullKeyObject.capability as Record, )) { this.log(line); } - this.log(`${formatLabel("Created")} ${this.formatDate(key.created)}`); - this.log(`${formatLabel("Updated")} ${this.formatDate(key.modified)}`); - this.log(`${formatLabel("Full key")} ${key.key}`); + this.log( + `${formatLabel("Created")} ${this.formatDate(fullKeyObject.created)}`, + ); + this.log( + `${formatLabel("Updated")} ${this.formatDate(fullKeyObject.modified)}`, + ); + this.log(`${formatLabel("Full key")} ${fullKeyObject.key}`); if (hasEnvOverride) { this.logToStderr(""); diff --git a/src/commands/auth/keys/revoke.ts b/src/commands/auth/keys/revoke.ts index faf2090ce..db585bc10 100644 --- a/src/commands/auth/keys/revoke.ts +++ b/src/commands/auth/keys/revoke.ts @@ -1,15 +1,15 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { forceFlag } from "../../../flags.js"; import { formatCapabilities } from "../../../utils/key-display.js"; -import { parseKeyIdentifier } from "../../../utils/key-parsing.js"; import { formatLabel, formatResource } from "../../../utils/output.js"; export default class KeysRevokeCommand extends ControlBaseCommand { static args = { - keyName: Args.string({ - description: "Key name (APP_ID.KEY_ID) of the key to revoke", + keyNameOrValue: Args.string({ + description: + 'Key name "." or value ".:"', required: true, }), }; @@ -17,45 +17,42 @@ export default class KeysRevokeCommand extends ControlBaseCommand { static description = "Revoke an API key (permanently disables the key)"; static examples = [ - "$ ably auth keys revoke APP_ID.KEY_ID", - "$ ably auth keys revoke KEY_ID --app APP_ID", - "$ ably auth keys revoke APP_ID.KEY_ID --force", - "$ ably auth keys revoke APP_ID.KEY_ID --json", - "$ ably auth keys revoke APP_ID.KEY_ID --pretty-json", + '$ ably auth keys revoke "APP_ID.KEY_ID"', + '$ ably auth keys revoke "APP_ID.KEY_ID:KEY_SECRET"', + '$ ably auth keys revoke "APP_ID.KEY_ID" --force', + '$ ably auth keys revoke "APP_ID.KEY_ID" --json', ]; static flags = { ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - env: "ABLY_APP_ID", - }), ...forceFlag, }; async run(): Promise { const { args, flags } = await this.parse(KeysRevokeCommand); - const parsed = parseKeyIdentifier(args.keyName); - const keyId = parsed.keyId; + const keyIdentifier = args.keyNameOrValue; - const appId = parsed.appId ?? (await this.requireAppId(flags)); + // Extract appId from the key identifier (key name or key value) + const appId = this.resolveAppIdForKey(keyIdentifier, flags); try { const controlApi = this.createControlApi(flags); // Get the key details first to show info to the user - const key = await controlApi.getKey(appId, keyId); + const fullKeyObject = await controlApi.getKey(appId, keyIdentifier); - const keyName = `${key.appId}.${key.id}`; + const keyName = `${fullKeyObject.appId}.${fullKeyObject.id}`; if (!this.shouldOutputJson(flags)) { this.log(`Key to revoke:`); this.log(`${formatLabel("Key Name")} ${formatResource(keyName)}`); - this.log(`${formatLabel("Key Label")} ${key.name || "Unnamed key"}`); - this.log(`${formatLabel("Full key")} ${key.key}`); + this.log( + `${formatLabel("Key Label")} ${fullKeyObject.name || "Unnamed key"}`, + ); + this.log(`${formatLabel("Full key")} ${fullKeyObject.key}`); for (const line of formatCapabilities( - key.capability as Record, + fullKeyObject.capability as Record, )) { this.log(line); } @@ -83,7 +80,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand { } } - await controlApi.revokeKey(appId, keyId); + await controlApi.revokeKey(appId, fullKeyObject.id); if (this.shouldOutputJson(flags)) { this.logJsonResult( @@ -104,7 +101,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand { // Check if the revoked key is the current key for this app const currentKey = this.configManager.getApiKey(appId); - if (currentKey === key.key) { + if (currentKey === fullKeyObject.key) { if (this.shouldOutputJson(flags)) { // Auto-remove in JSON mode — key is already revoked, can't be used this.configManager.removeApiKey(appId); @@ -120,7 +117,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand { } } } catch (error) { - this.fail(error, flags, "keyRevoke", { appId, keyId }); + this.fail(error, flags, "keyRevoke", { appId, keyIdentifier }); } } } diff --git a/src/commands/auth/keys/switch.ts b/src/commands/auth/keys/switch.ts index 31694ee4e..85ba361ac 100644 --- a/src/commands/auth/keys/switch.ts +++ b/src/commands/auth/keys/switch.ts @@ -2,14 +2,13 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { ControlApi } from "../../../services/control-api.js"; -import { parseKeyIdentifier } from "../../../utils/key-parsing.js"; import { formatResource } from "../../../utils/output.js"; export default class KeysSwitchCommand extends ControlBaseCommand { static args = { keyNameOrValue: Args.string({ description: - "Key name (APP_ID.KEY_ID) or full value of the key to switch to", + 'Key name "." or value ".:"', required: false, }), }; @@ -18,8 +17,8 @@ export default class KeysSwitchCommand extends ControlBaseCommand { static examples = [ "$ ably auth keys switch", - "$ ably auth keys switch APP_ID.KEY_ID", - "$ ably auth keys switch KEY_ID --app APP_ID", + '$ ably auth keys switch "APP_ID.KEY_ID"', + '$ ably auth keys switch "APP_ID.KEY_ID:KEY_SECRET"', "$ ably auth keys switch --json", ]; @@ -34,27 +33,23 @@ export default class KeysSwitchCommand extends ControlBaseCommand { async run(): Promise { const { args, flags } = await this.parse(KeysSwitchCommand); - let keyId: string | undefined = args.keyNameOrValue; - let extractedAppId: string | undefined; + const keyIdentifier = args.keyNameOrValue; - if (args.keyNameOrValue) { - const parsed = parseKeyIdentifier(args.keyNameOrValue); - if (parsed.appId) extractedAppId = parsed.appId; - keyId = parsed.keyId; - } - - const appId = extractedAppId ?? (await this.requireAppId(flags)); + // When a key identifier is provided, extract appId from it. + // Otherwise, resolve appId from --app flag or current app for interactive selection. + const appId = keyIdentifier + ? this.resolveAppIdForKey(keyIdentifier, flags) + : await this.requireAppId(flags); try { const controlApi = this.createControlApi(flags); - // Get current app name (if available) to preserve it const existingAppName = this.configManager.getAppName(appId); - // If key ID or value is provided, switch directly - if (args.keyNameOrValue && keyId) { + // Key identifier provided — switch directly + if (keyIdentifier) { await this.switchToKey( appId, - keyId, + keyIdentifier, controlApi, flags, existingAppName, @@ -62,7 +57,8 @@ export default class KeysSwitchCommand extends ControlBaseCommand { return; } - // Otherwise, show interactive selection + // No key identifier — show interactive key selection + if (!this.shouldOutputJson(flags)) { this.log("Select a key to switch to:"); } @@ -121,16 +117,16 @@ export default class KeysSwitchCommand extends ControlBaseCommand { private async switchToKey( appId: string, - keyIdOrValue: string, + keyIdentifier: string, controlApi: ControlApi, flags: Record, existingAppName?: string, ): Promise { try { // Verify the key exists and get full details - const key = await controlApi.getKey(appId, keyIdOrValue); + const fullKeyObject = await controlApi.getKey(appId, keyIdentifier); - const keyName = `${appId}.${key.id}`; + const keyName = `${appId}.${fullKeyObject.id}`; // Get app details to ensure we have the app name let appName = existingAppName; @@ -147,10 +143,10 @@ export default class KeysSwitchCommand extends ControlBaseCommand { } // Save to config with metadata - this.configManager.storeAppKey(appId, key.key, { + this.configManager.storeAppKey(appId, fullKeyObject.key, { appName, - keyId: key.id, - keyName: key.name || "Unnamed key", + keyId: fullKeyObject.id, + keyName: fullKeyObject.name || "Unnamed key", }); if (this.shouldOutputJson(flags)) { @@ -159,7 +155,7 @@ export default class KeysSwitchCommand extends ControlBaseCommand { key: { appId, keyName, - keyLabel: key.name || "Unnamed key", + keyLabel: fullKeyObject.name || "Unnamed key", }, }, flags, @@ -172,7 +168,7 @@ export default class KeysSwitchCommand extends ControlBaseCommand { ); } catch { this.fail( - `Key "${keyIdOrValue}" not found or access denied.`, + `Key "${keyIdentifier}" not found or access denied. Run "ably auth keys list" to see available keys.`, flags, "keySwitch", ); diff --git a/src/commands/auth/keys/update.ts b/src/commands/auth/keys/update.ts index 42a0beb78..3ceee48b1 100644 --- a/src/commands/auth/keys/update.ts +++ b/src/commands/auth/keys/update.ts @@ -2,36 +2,30 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatCapabilityInline } from "../../../utils/key-display.js"; -import { - parseCapabilities, - parseKeyIdentifier, -} from "../../../utils/key-parsing.js"; +import { parseCapabilities } from "../../../utils/key-parsing.js"; import { formatLabel, formatResource } from "../../../utils/output.js"; export default class KeysUpdateCommand extends ControlBaseCommand { static args = { - keyName: Args.string({ - description: "Key name (APP_ID.KEY_ID) of the key to update", + keyNameOrValue: Args.string({ + description: + 'Key name "." or value ".:"', required: true, }), }; - static description = "Update a key's properties"; + static description = "Update the name or capabilities of a key"; static examples = [ - '$ ably auth keys update APP_ID.KEY_ID --name "New Name"', - '$ ably auth keys update KEY_ID --app APP_ID --capabilities "publish,subscribe"', - '$ ably auth keys update APP_ID.KEY_ID --name "New Name" --capabilities "publish,subscribe"', - `$ ably auth keys update APP_ID.KEY_ID --name "New Name" --capabilities '{"channel1":["publish"],"channel2":["subscribe"]}'`, - '$ ably auth keys update APP_ID.KEY_ID --name "New Name" --json', + '$ ably auth keys update "APP_ID.KEY_ID" --name "New Name"', + '$ ably auth keys update "APP_ID.KEY_ID:KEY_SECRET" --name "New Name"', + `$ ably auth keys update "APP_ID.KEY_ID" --capabilities '{"channel1":["publish"],"channel2":["subscribe"]}'`, + '$ ably auth keys update "APP_ID.KEY_ID" --capabilities "publish,subscribe,history"', + '$ ably auth keys update "APP_ID.KEY_ID" --name "New Name" --capabilities "publish,subscribe" --json', ]; static flags = { ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - env: "ABLY_APP_ID", - }), capabilities: Flags.string({ description: "New capabilities as JSON object (per-channel) or comma-separated list (all channels)", @@ -55,15 +49,15 @@ export default class KeysUpdateCommand extends ControlBaseCommand { ); } - const parsed = parseKeyIdentifier(args.keyName); - const keyId = parsed.keyId; + const keyIdentifier = args.keyNameOrValue; - const appId = parsed.appId ?? (await this.requireAppId(flags)); + // Extract appId from the key identifier (key name or key value) + const appId = this.resolveAppIdForKey(keyIdentifier, flags); try { const controlApi = this.createControlApi(flags); // Get original key details - const originalKey = await controlApi.getKey(appId, keyId); + const fullKeyObject = await controlApi.getKey(appId, keyIdentifier); // Prepare the update data const updateData: { @@ -84,7 +78,11 @@ export default class KeysUpdateCommand extends ControlBaseCommand { } // Update the key - const updatedKey = await controlApi.updateKey(appId, keyId, updateData); + const updatedKey = await controlApi.updateKey( + appId, + fullKeyObject.id, + updateData, + ); const keyName = `${updatedKey.appId}.${updatedKey.id}`; @@ -92,13 +90,13 @@ export default class KeysUpdateCommand extends ControlBaseCommand { const keyData: Record = { keyName }; if (flags.name) { keyData.name = { - before: originalKey.name || "Unnamed key", + before: fullKeyObject.name || "Unnamed key", after: updatedKey.name || "Unnamed key", }; } if (flags.capabilities) { keyData.capabilities = { - before: originalKey.capability, + before: fullKeyObject.capability, after: updatedKey.capability, }; } @@ -108,14 +106,14 @@ export default class KeysUpdateCommand extends ControlBaseCommand { if (flags.name) { this.log( - `${formatLabel("Key Label")} "${originalKey.name || "Unnamed key"}" → "${updatedKey.name || "Unnamed key"}"`, + `${formatLabel("Key Label")} "${fullKeyObject.name || "Unnamed key"}" → "${updatedKey.name || "Unnamed key"}"`, ); } if (flags.capabilities) { this.log(`${formatLabel("Capabilities")}`); this.log( - ` ${formatLabel("Before")} ${formatCapabilityInline(originalKey.capability as Record)}`, + ` ${formatLabel("Before")} ${formatCapabilityInline(fullKeyObject.capability as Record)}`, ); this.log( ` ${formatLabel("After")} ${formatCapabilityInline(updatedKey.capability as Record)}`, diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index 7f5e57dfb..5c83f6c06 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -7,8 +7,8 @@ import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class QueuesDeleteCommand extends ControlBaseCommand { static args = { - queueId: Args.string({ - description: "ID of the queue to delete", + queueNameOrId: Args.string({ + description: "Name or ID of the queue to delete", required: true, }), }; @@ -16,10 +16,10 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { static description = "Delete a queue"; static examples = [ - "$ ably queues delete appAbc:us-east-1-a:foo", - '$ ably queues delete appAbc:us-east-1-a:foo --app "My App"', - "$ ably queues delete appAbc:us-east-1-a:foo --force", - "$ ably queues delete appAbc:us-east-1-a:foo --json", + '$ ably queues delete "my-queue"', + '$ ably queues delete "my-queue" --app "My App"', + '$ ably queues delete "my-queue" --force', + '$ ably queues delete "my-queue" --json', ]; static flags = { @@ -33,21 +33,29 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { async run(): Promise { const { args, flags } = await this.parse(QueuesDeleteCommand); - if (!args.queueId.trim()) { - this.fail("Queue ID cannot be empty", flags, "parse"); + if (!args.queueNameOrId.trim()) { + this.fail("Queue name or ID cannot be empty", flags, "parse"); } const appId = await this.requireAppId(flags); try { const controlApi = this.createControlApi(flags); - // Get all queues and find the one we want to delete by ID + // Resolve the queue. The queueNameOrId arg accepts two formats: + // 1. Queue name — e.g. "my-queue" (human-readable) + // 2. Queue ID — e.g. "28AB1a:my-queue" (Ably-assigned) + // + // Name is tried first since it's the more common input. Queues have + // distinct name and id fields, unlike channel rules where they are + // the same value. const queues = await controlApi.listQueues(appId); - const queue = queues.find((q) => q.id === args.queueId); + const queue = + queues.find((q) => q.name === args.queueNameOrId) ?? + queues.find((q) => q.id === args.queueNameOrId); if (!queue) { this.fail( - `Queue with ID "${args.queueId}" not found`, + `Queue "${args.queueNameOrId}" not found. Run "ably queues list" to see available queues.`, flags, "queueDelete", ); diff --git a/src/commands/stats/app.ts b/src/commands/stats/app.ts index c7d276a0d..a10659a99 100644 --- a/src/commands/stats/app.ts +++ b/src/commands/stats/app.ts @@ -7,8 +7,9 @@ import { formatResource } from "../../utils/output.js"; export default class StatsAppCommand extends StatsBaseCommand { static args = { - appId: Args.string({ - description: "App ID to get stats for (uses default app if not provided)", + appNameOrId: Args.string({ + description: + "App name or ID to get stats for (uses default app if not provided)", required: false, }), }; @@ -17,12 +18,14 @@ export default class StatsAppCommand extends StatsBaseCommand { static examples = [ "$ ably stats app", + '$ ably stats app "My App"', "$ ably stats app app-id", "$ ably stats app --unit hour", "$ ably stats app app-id --unit hour", '$ ably stats app app-id --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', "$ ably stats app app-id --start 1h", "$ ably stats app app-id --limit 10", + '$ ably stats app "My App" --start 1h --limit 10', "$ ably stats app app-id --json", "$ ably stats app app-id --pretty-json", "$ ably stats app --live", @@ -49,10 +52,18 @@ export default class StatsAppCommand extends StatsBaseCommand { async run(): Promise { const { args, flags } = await this.parse(StatsAppCommand); - this.appId = args.appId || this.configManager.getCurrentAppId() || ""; + // The appNameOrId arg accepts two formats: + // 1. App name — e.g. "My App" (human-readable, may contain spaces) + // 2. App ID — e.g. "s57drg" (the Ably-assigned app ID) + if (args.appNameOrId) { + this.appId = await this.resolveAppIdFromNameOrId(args.appNameOrId, flags); + } else { + this.appId = this.configManager.getCurrentAppId() || ""; + } + if (!this.appId) { this.fail( - 'No app ID provided and no default app selected. Please specify an app ID or select a default app with "ably apps switch".', + 'No app specified and no default app selected. Please specify an app name or ID, or select a default app with "ably apps switch".', flags, "statsApp", ); diff --git a/src/control-base-command.ts b/src/control-base-command.ts index b50497435..8b39a74b6 100644 --- a/src/control-base-command.ts +++ b/src/control-base-command.ts @@ -141,7 +141,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { } this.fail( - `App "${appNameOrId}" not found. Please provide a valid app ID or name.`, + `App "${appNameOrId}" not found. Run "ably apps list" to see available apps.`, flags, "app", ); @@ -154,6 +154,65 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { } } + /** + * Resolve an account alias or ID to the account alias. + * Matches by alias first (exact), then by accountId (exact). + * Returns the alias string needed by configManager methods. + */ + protected resolveAccountAlias(aliasOrId: string, flags: BaseFlags): string { + const accounts = this.configManager.listAccounts(); + + // Try alias match first + const byAlias = accounts.find((a) => a.alias === aliasOrId); + if (byAlias) return byAlias.alias; + + // Try accountId match + const byId = accounts.find((a) => a.account.accountId === aliasOrId); + if (byId) return byId.alias; + + this.fail( + `Account "${aliasOrId}" not found. Run "ably accounts list" to see available accounts.`, + flags, + "account", + { + availableAccounts: accounts.map(({ account, alias }) => ({ + alias, + id: account.accountId, + name: account.accountName, + })), + }, + ); + } + + /** + * Extract the appId from a key identifier. + * + * Accepts two formats — both embed the appId: + * 1. Key name — "." (contains ".", no ":") + * 2. Full key value — ".:" (contains ":" and ".") + */ + protected resolveAppIdForKey( + keyNameOrValue: string, + flags: BaseFlags, + ): string { + // Both accepted formats always contain "." — reject bare identifiers + if (!keyNameOrValue || !keyNameOrValue.includes(".")) { + this.fail( + `Invalid key identifier "${keyNameOrValue}". Expected key name "." or full key value ".:". Run "ably auth keys list" to see available keys.`, + flags, + "keyResolve", + ); + } + + if (keyNameOrValue.includes(":")) { + // Full key value — appId is before the first dot + return keyNameOrValue.split(".")[0]!; + } + + // Key name — appId is the first segment + return keyNameOrValue.split(".")[0]!; + } + /** * Prompts the user to select an app */ @@ -164,7 +223,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { if (apps.length === 0) { this.fail( - "No apps found in your account. Please create an app first.", + 'No apps found in your account. Run "ably apps create" to create one.', flags, "app", ); diff --git a/src/services/control-api.ts b/src/services/control-api.ts index ea2442f02..cca9438fc 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -375,25 +375,25 @@ export class ControlApi { return this.request(`/apps/${appId}/stats${queryString}`); } - // Get a specific key by ID, key value, key name (APP_ID.KEY_ID), or label - async getKey(appId: string, keyIdOrValue: string): Promise { + // Get KEY by Key name "." or value ".:", keyId or key label + async getKey(appId: string, keyIdentifier: string): Promise { const keys = await this.listKeys(appId); const matchingKey = keys.find((k) => { // Full key value (contains colon) e.g. "s57drg.3bnE1Q:secretpart" - if (keyIdOrValue.includes(":") && k.key === keyIdOrValue) return true; + if (keyIdentifier.includes(":") && k.key === keyIdentifier) return true; // Full key name e.g. "s57drg.3bnE1Q" - if (keyIdOrValue.includes(".") && `${k.appId}.${k.id}` === keyIdOrValue) + if (keyIdentifier.includes(".") && `${k.appId}.${k.id}` === keyIdentifier) return true; // Key ID only e.g. "3bnE1Q" - if (k.id === keyIdOrValue) return true; + if (k.id === keyIdentifier) return true; // Key label/name e.g. "Root" - if (k.name === keyIdOrValue) return true; + if (k.name === keyIdentifier) return true; return false; }); if (!matchingKey) { - throw new Error(`Key "${keyIdOrValue}" not found`); + throw new Error(`Key "${keyIdentifier}" not found`); } return matchingKey; diff --git a/src/spaces-base-command.ts b/src/spaces-base-command.ts index 6981397ab..201cb887f 100644 --- a/src/spaces-base-command.ts +++ b/src/spaces-base-command.ts @@ -186,7 +186,11 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { // First create an Ably client this.realtimeClient = await this.createAblyRealtimeClient(flags); if (!this.realtimeClient) { - this.fail("Failed to create Ably client", flags, "client"); + this.fail( + 'Failed to create Ably client. Run "ably accounts login" to configure authentication, or set the ABLY_API_KEY environment variable.', + flags, + "client", + ); } // Create a Spaces client using the Ably client @@ -235,7 +239,11 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { this.logCliEvent(flags, "connection", "failed", errorMsg, { state: connection.state, }); - this.fail(errorMsg, flags, "connection"); + this.fail( + `${errorMsg}. Use --verbose to see detailed connection logs.`, + flags, + "connection", + ); } await new Promise((resolve, reject) => { diff --git a/src/utils/key-parsing.ts b/src/utils/key-parsing.ts index a515b9d26..754ae0029 100644 --- a/src/utils/key-parsing.ts +++ b/src/utils/key-parsing.ts @@ -1,21 +1,3 @@ -/** - * Parse a key identifier that may be in APP_ID.KEY_ID format. - * Returns the extracted appId (if present) and keyId. - */ -export function parseKeyIdentifier(identifier: string): { - appId?: string; - keyId: string; -} { - if (identifier.includes(".")) { - const parts = identifier.split("."); - // If it has exactly one period and no colon, it's likely an app_id.key_id - if (parts.length === 2 && !identifier.includes(":")) { - return { appId: parts[0]!, keyId: parts[1]! }; - } - } - return { keyId: identifier }; -} - /** * Resolve a current key ID into a full key name (appId.keyId). * Handles the case where keyId may already include the appId prefix. diff --git a/test/e2e/auth/auth-keys-e2e.test.ts b/test/e2e/auth/auth-keys-e2e.test.ts index f875956e9..8c65eab4e 100644 --- a/test/e2e/auth/auth-keys-e2e.test.ts +++ b/test/e2e/auth/auth-keys-e2e.test.ts @@ -7,6 +7,9 @@ import { afterAll, expect, } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { E2E_ACCESS_TOKEN, SHOULD_SKIP_CONTROL_E2E, @@ -21,14 +24,38 @@ import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Auth Keys E2E Tests", () => { let testAppId: string; let teardownApp: (() => Promise) | undefined; + // Temp config directory for switch/current tests that need a local account + let tempConfigDir: string; beforeAll(async () => { ({ appId: testAppId, teardown: teardownApp } = await createTestApp("e2e-keys-test")); + + // Create a temp config dir with a test account so switch/current commands + // can use configManager.storeAppKey / getApiKey (requires a local account). + // Uses ABLY_CLI_CONFIG_DIR env var to point the CLI at this config. + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "ably-e2e-keys-")); + const tomlConfig = `[current] +account = "e2e-test" + +[accounts.e2e-test] +accessToken = "${E2E_ACCESS_TOKEN || ""}" +accountId = "e2e-test-account" +accountName = "E2E Test Account" +userEmail = "e2e@test.com" +currentAppId = "${testAppId || ""}" +`; + fs.writeFileSync(path.join(tempConfigDir, "config"), tomlConfig, { + mode: 0o600, + }); }); afterAll(async () => { await teardownApp?.(); + // Clean up temp config directory + if (tempConfigDir) { + fs.rmSync(tempConfigDir, { recursive: true, force: true }); + } }); beforeEach(() => { @@ -100,9 +127,9 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Auth Keys E2E Tests", () => { const createdKey = createRecord.key as Record; const keyFullName = createdKey.keyName as string; - // Now get that key by its name + // Now get that key by its name (appId is embedded in keyFullName) const getResult = await runCommand( - ["auth", "keys", "get", keyFullName, "--app", testAppId, "--json"], + ["auth", "keys", "get", keyFullName, "--json"], { env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, }, @@ -139,18 +166,9 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Auth Keys E2E Tests", () => { // Update the key name const updatedName = `updated-key-${Date.now()}`; + // appId is embedded in keyFullName, no --app needed const updateResult = await runCommand( - [ - "auth", - "keys", - "update", - keyFullName, - "--app", - testAppId, - "--name", - updatedName, - "--json", - ], + ["auth", "keys", "update", keyFullName, "--name", updatedName, "--json"], { env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, }, @@ -167,4 +185,143 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Auth Keys E2E Tests", () => { expect(nameChange).toHaveProperty("before", originalName); expect(nameChange).toHaveProperty("after", updatedName); }); + + it("should revoke a key by key name", { timeout: 20000 }, async () => { + setupTestFailureHandler("should revoke a key by key name"); + + // First create a key to revoke + const keyName = `e2e-revoke-key-${Date.now()}`; + const createResult = await runCommand( + ["auth", "keys", "create", keyName, "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + const createRecord = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const createdKey = createRecord.key as Record; + const keyFullName = createdKey.keyName as string; + + // Revoke by key name (appId is embedded in keyFullName), --force to skip confirmation + const revokeResult = await runCommand( + ["auth", "keys", "revoke", keyFullName, "--force", "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(revokeResult.exitCode).toBe(0); + const revokeRecord = parseNdjsonLines(revokeResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(revokeRecord).toBeDefined(); + expect(revokeRecord).toHaveProperty("success", true); + const revokedKey = revokeRecord.key as Record; + expect(revokedKey).toHaveProperty("keyName", keyFullName); + expect(revokedKey).toHaveProperty("message", "Key has been revoked"); + }); + + it("should switch to a key by key name", { timeout: 20000 }, async () => { + setupTestFailureHandler("should switch to a key by key name"); + + // Create a key to switch to + const keyName = `e2e-switch-key-${Date.now()}`; + const createResult = await runCommand( + ["auth", "keys", "create", keyName, "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + const createRecord = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const createdKey = createRecord.key as Record; + const keyFullName = createdKey.keyName as string; + + // Switch requires a local account config to store the key. + // Use ABLY_CLI_CONFIG_DIR to point at our temp config with a test account. + const switchResult = await runCommand( + ["auth", "keys", "switch", keyFullName, "--json"], + { + env: { + ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "", + ABLY_CLI_CONFIG_DIR: tempConfigDir, + }, + }, + ); + + expect(switchResult.exitCode).toBe(0); + const switchRecord = parseNdjsonLines(switchResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(switchRecord).toBeDefined(); + expect(switchRecord).toHaveProperty("success", true); + const switchedKey = switchRecord.key as Record; + expect(switchedKey).toHaveProperty("appId", testAppId); + expect(switchedKey).toHaveProperty("keyName", keyFullName); + expect(switchedKey).toHaveProperty("keyLabel", keyName); + }); + + it( + "should show current key after switching", + { timeout: 25000 }, + async () => { + setupTestFailureHandler("should show current key after switching"); + + // Create a key and switch to it + const keyName = `e2e-current-key-${Date.now()}`; + const createResult = await runCommand( + ["auth", "keys", "create", keyName, "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + const createRecord = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const createdKey = createRecord.key as Record; + const keyFullName = createdKey.keyName as string; + const keyValue = createdKey.key as string; + + // Switch to the key (writes to temp config) + const switchResult = await runCommand( + ["auth", "keys", "switch", keyFullName, "--json"], + { + env: { + ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "", + ABLY_CLI_CONFIG_DIR: tempConfigDir, + }, + }, + ); + expect(switchResult.exitCode).toBe(0); + + // Verify current reads from the same temp config + const currentResult = await runCommand( + ["auth", "keys", "current", "--app", testAppId, "--json"], + { + env: { + ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "", + ABLY_CLI_CONFIG_DIR: tempConfigDir, + }, + }, + ); + + expect(currentResult.exitCode).toBe(0); + const currentRecord = parseNdjsonLines(currentResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(currentRecord).toBeDefined(); + expect(currentRecord).toHaveProperty("success", true); + const currentKey = currentRecord.key as Record; + expect(currentKey).toHaveProperty("id", keyFullName); + expect(currentKey).toHaveProperty("value", keyValue); + expect(currentKey).toHaveProperty("label", keyName); + const app = currentKey.app as Record; + expect(app).toHaveProperty("id", testAppId); + }, + ); }); diff --git a/test/e2e/control/control-api-workflows.test.ts b/test/e2e/control/control-api-workflows.test.ts index 20e4afce5..88e01cd77 100644 --- a/test/e2e/control/control-api-workflows.test.ts +++ b/test/e2e/control/control-api-workflows.test.ts @@ -970,7 +970,7 @@ describe("Control API E2E Workflow Tests", () => { ); expect(result.exitCode).not.toBe(0); - expect(result.stderr + result.stdout).toContain("404"); + expect(result.stderr + result.stdout).toContain("not found"); }); it("should validate required parameters", { timeout: 10000 }, async () => { diff --git a/test/e2e/stats/stats.test.ts b/test/e2e/stats/stats.test.ts index 0f14ff845..e55177e7e 100644 --- a/test/e2e/stats/stats.test.ts +++ b/test/e2e/stats/stats.test.ts @@ -229,8 +229,7 @@ describe.skipIf(SHOULD_SKIP_E2E || SKIP_ACCOUNT_STATS)( // Either success or an error about no app ID selected (which is expected without config) expect( - result.exitCode === 0 || - result.stderr.includes("No app ID provided"), + result.exitCode === 0 || result.stderr.includes("No app specified"), ).toBe(true); }, ); diff --git a/test/unit/commands/accounts/logout.test.ts b/test/unit/commands/accounts/logout.test.ts index 84785e7cf..429075f58 100644 --- a/test/unit/commands/accounts/logout.test.ts +++ b/test/unit/commands/accounts/logout.test.ts @@ -89,6 +89,23 @@ describe("accounts:logout command", () => { const config = mock.getConfig(); expect(config.accounts["testaccount"]).toBeUndefined(); }); + + it("should logout specific account by account ID with --force and --json", async () => { + const { stdout } = await runCommand( + ["accounts:logout", "acc-123", "--force", "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; + expect(result).toHaveProperty("success", true); + expect(result.account).toHaveProperty("alias", "testaccount"); + + const mock = getMockConfigManager(); + const config = mock.getConfig(); + expect(config.accounts["testaccount"]).toBeUndefined(); + }); }); describe("with multiple logged in accounts", () => { @@ -161,6 +178,24 @@ describe("accounts:logout command", () => { // Current account should still be primary expect(config.current?.account).toBe("primary"); }); + + it("should logout specific account by account ID", async () => { + const { stdout } = await runCommand( + ["accounts:logout", "acc-secondary", "--force", "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; + expect(result).toHaveProperty("success", true); + expect(result.account).toHaveProperty("alias", "secondary"); + + const mock = getMockConfigManager(); + const config = mock.getConfig(); + expect(config.accounts["secondary"]).toBeUndefined(); + expect(config.accounts["primary"]).toBeDefined(); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/accounts/switch.test.ts b/test/unit/commands/accounts/switch.test.ts index dd3d0960b..9c7f49061 100644 --- a/test/unit/commands/accounts/switch.test.ts +++ b/test/unit/commands/accounts/switch.test.ts @@ -54,6 +54,31 @@ describe("accounts:switch command", () => { expect(stdout).toContain(mockUserEmail); }); + it("should switch to existing account by account ID", async () => { + const mock = getMockConfigManager(); + + mock.storeAccount("token_second", "second", { + accountId: mockAccountId, + accountName: mockAccountName, + userEmail: mockUserEmail, + }); + + nockControl() + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, + }); + + const { stderr } = await runCommand( + ["accounts:switch", mockAccountId], + import.meta.url, + ); + + expect(stderr).toContain("Switched to account"); + expect(mock.getCurrentAccountAlias()).toBe("second"); + }); + it("should error on nonexistent alias", async () => { const { error } = await runCommand( ["accounts:switch", "nonexistent-alias"], diff --git a/test/unit/commands/apps/switch.test.ts b/test/unit/commands/apps/switch.test.ts index b9a583c38..d6ab06c79 100644 --- a/test/unit/commands/apps/switch.test.ts +++ b/test/unit/commands/apps/switch.test.ts @@ -14,12 +14,17 @@ import { describe("apps:switch command", () => { let mockAccountId: string; + let mockAccountName: string; + let mockUserEmail: string; let mockAppId: string; const mockAppName = "Switched App"; beforeEach(() => { const mockConfig = getMockConfigManager(); - mockAccountId = mockConfig.getCurrentAccount()!.accountId!; + const account = mockConfig.getCurrentAccount()!; + mockAccountId = account.accountId!; + mockAccountName = account.accountName!; + mockUserEmail = account.userEmail!; mockAppId = mockConfig.getCurrentAppId()!; }); @@ -32,11 +37,12 @@ describe("apps:switch command", () => { describe("functionality", () => { it("should switch to an app when appId is provided", async () => { + // Single listApps() call: GET /v1/me + GET /v1/accounts/:id/apps nockControl() .get("/v1/me") .reply(200, { - account: { id: mockAccountId, name: "Test Account" }, - user: { email: "test@example.com" }, + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, }); nockControl() @@ -64,11 +70,12 @@ describe("apps:switch command", () => { }); it("should output JSON when --json flag is used", async () => { + // Single listApps() call: GET /v1/me + GET /v1/accounts/:id/apps nockControl() .get("/v1/me") .reply(200, { - account: { id: mockAccountId, name: "Test Account" }, - user: { email: "test@example.com" }, + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, }); nockControl() @@ -98,6 +105,41 @@ describe("apps:switch command", () => { expect(result.app).toHaveProperty("id", mockAppId); expect(result.app).toHaveProperty("name", mockAppName); }); + + it("should switch to an app when app name is provided", async () => { + const appName = "SwitchedApp"; + + nockControl() + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, + }); + + nockControl() + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, [ + { + id: mockAppId, + accountId: mockAccountId, + name: appName, + status: "active", + created: 1640995200000, + modified: 1640995200000, + tlsOnly: false, + }, + ]); + + const { stdout } = await runCommand( + ["apps:switch", appName, "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + expect(result.app).toHaveProperty("id", mockAppId); + expect(result.app).toHaveProperty("name", appName); + }); }); standardFlagTests("apps:switch", import.meta.url, [ @@ -110,8 +152,8 @@ describe("apps:switch command", () => { nockControl() .get("/v1/me") .reply(200, { - account: { id: mockAccountId, name: "Test Account" }, - user: { email: "test@example.com" }, + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, }); nockControl().get(`/v1/accounts/${mockAccountId}/apps`).reply(200, []); diff --git a/test/unit/commands/apps/update.test.ts b/test/unit/commands/apps/update.test.ts index b09edbd60..6cb4bf48a 100644 --- a/test/unit/commands/apps/update.test.ts +++ b/test/unit/commands/apps/update.test.ts @@ -4,6 +4,7 @@ import nock from "nock"; import { nockControl, controlApiCleanup, + mockAppResolution, CONTROL_HOST, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; @@ -33,6 +34,9 @@ describe("apps:update command", () => { const appId = mock.getCurrentAppId()!; const updatedName = "UpdatedAppName"; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock the app update endpoint nockControl() .patch(`/v1/apps/${appId}`, { @@ -63,6 +67,9 @@ describe("apps:update command", () => { const accountId = mock.getCurrentAccount()!.accountId; const appId = mock.getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock the app update endpoint nockControl() .patch(`/v1/apps/${appId}`, { @@ -93,6 +100,9 @@ describe("apps:update command", () => { const appId = mock.getCurrentAppId()!; const updatedName = "UpdatedAppName"; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock the app update endpoint nockControl() .patch(`/v1/apps/${appId}`, { @@ -135,6 +145,9 @@ describe("apps:update command", () => { tlsOnly: false, }; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock the app update endpoint nockControl().patch(`/v1/apps/${appId}`).reply(200, mockApp); @@ -158,6 +171,46 @@ describe("apps:update command", () => { expect(result!.app).toHaveProperty("name", updatedName); }); + it("should update an app by name", async () => { + const mock = getMockConfigManager(); + const accountId = mock.getCurrentAccount()!.accountId; + const appId = mock.getCurrentAppId()!; + const updatedName = "UpdatedAppName"; + const appName = "TestApp"; + + // Mock app resolution with a matching app name + nockControl() + .get("/v1/me") + .reply(200, { + account: { id: accountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + nockControl() + .get(`/v1/accounts/${accountId}/apps`) + .reply(200, [{ id: appId, name: appName, accountId }]); + + nockControl() + .patch(`/v1/apps/${appId}`, { + name: updatedName, + }) + .reply(200, { + id: appId, + accountId: accountId, + name: updatedName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: false, + }); + + const { stderr } = await runCommand( + ["apps:update", appName, "--name", updatedName], + import.meta.url, + ); + + expect(stderr).toContain("App updated successfully"); + }); + it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { const mock = getMockConfigManager(); const accountId = mock.getCurrentAccount()!.accountId; @@ -167,6 +220,25 @@ describe("apps:update command", () => { process.env.ABLY_ACCESS_TOKEN = customToken; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) with custom token + nock(CONTROL_HOST, { + reqheaders: { + authorization: `Bearer ${customToken}`, + }, + }) + .get("/v1/me") + .reply(200, { + account: { id: accountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + nock(CONTROL_HOST, { + reqheaders: { + authorization: `Bearer ${customToken}`, + }, + }) + .get(`/v1/accounts/${accountId}/apps`) + .reply(200, [{ id: appId, name: "Test App", accountId }]); + // Mock the app update endpoint with custom token nock(CONTROL_HOST, { reqheaders: { @@ -212,6 +284,8 @@ describe("apps:update command", () => { importMetaUrl: import.meta.url, setupNock: (scenario) => { const appId = getMockConfigManager().getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); if (scenario === "401") { nockControl() .patch(`/v1/apps/${appId}`) @@ -278,6 +352,9 @@ describe("apps:update command", () => { const mock = getMockConfigManager(); const appId = mock.getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock forbidden response nockControl() .patch(`/v1/apps/${appId}`) @@ -296,6 +373,9 @@ describe("apps:update command", () => { const mock = getMockConfigManager(); const appId = mock.getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock not found response nockControl() .patch(`/v1/apps/${appId}`) @@ -314,6 +394,9 @@ describe("apps:update command", () => { const mock = getMockConfigManager(); const appId = mock.getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock server error nockControl() .patch(`/v1/apps/${appId}`) @@ -345,6 +428,9 @@ describe("apps:update command", () => { const accountId = mock.getCurrentAccount()!.accountId; const appId = mock.getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock the app update endpoint with APNS cert info nockControl().patch(`/v1/apps/${appId}`).reply(200, { id: appId, @@ -370,6 +456,9 @@ describe("apps:update command", () => { const accountId = mock.getCurrentAccount()!.accountId; const appId = mock.getCurrentAppId()!; + // Mock the resolve step (GET /v1/me + GET /v1/accounts/:id/apps) + mockAppResolution(appId); + // Mock the app update endpoint with APNS cert info nockControl().patch(`/v1/apps/${appId}`).reply(200, { id: appId, diff --git a/test/unit/commands/auth/keys/get.test.ts b/test/unit/commands/auth/keys/get.test.ts index 0fddc12af..2bbc2318e 100644 --- a/test/unit/commands/auth/keys/get.test.ts +++ b/test/unit/commands/auth/keys/get.test.ts @@ -3,7 +3,6 @@ import { runCommand } from "@oclif/test"; import { nockControl, controlApiCleanup, - mockAppResolution, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; import { @@ -14,7 +13,6 @@ import { standardHelpTests, standardArgValidationTests, standardFlagTests, - standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; import { parseJsonOutput } from "../../../../helpers/ndjson.js"; @@ -51,64 +49,12 @@ describe("auth:keys:get command", () => { expect(stdout).toContain("Key Label: Test Key"); }); - it("should get key details with --app flag", async () => { + it("should get key details by full key value", async () => { const appId = getMockConfigManager().getCurrentAppId()!; - mockAppResolution(appId); mockKeysList(appId, [buildMockKey(appId, mockKeyId)]); const { stdout } = await runCommand( - ["auth:keys:get", mockKeyId, "--app", appId], - import.meta.url, - ); - - expect(stdout).toContain(`Key Name: ${appId}.${mockKeyId}`); - expect(stdout).toContain("Key Label: Test Key"); - }); - - it("should get key details by label name", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - mockAppResolution(appId); - mockKeysList(appId, [ - buildMockKey(appId, mockKeyId, { name: "Root" }), - buildMockKey(appId, "otherkey", { name: "Secondary" }), - ]); - - const { stdout } = await runCommand( - ["auth:keys:get", "Root", "--app", appId], - import.meta.url, - ); - - expect(stdout).toContain(`Key Name: ${appId}.${mockKeyId}`); - expect(stdout).toContain("Key Label: Root"); - }); - - it("should get key details by label containing a period (e.g. v1.0)", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - mockAppResolution(appId); - mockKeysList(appId, [ - buildMockKey(appId, mockKeyId, { name: "v1.0" }), - buildMockKey(appId, "otherkey", { name: "Secondary" }), - ]); - - const { stdout } = await runCommand( - ["auth:keys:get", "v1.0", "--app", appId], - import.meta.url, - ); - - expect(stdout).toContain(`Key Name: ${appId}.${mockKeyId}`); - expect(stdout).toContain("Key Label: v1.0"); - }); - - it("should get key details by key ID only", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - mockAppResolution(appId); - mockKeysList(appId, [ - buildMockKey(appId, mockKeyId), - buildMockKey(appId, "otherkey", { name: "Secondary" }), - ]); - - const { stdout } = await runCommand( - ["auth:keys:get", mockKeyId, "--app", appId], + ["auth:keys:get", `${appId}.${mockKeyId}:secret`], import.meta.url, ); @@ -174,7 +120,7 @@ describe("auth:keys:get command", () => { process.env.ABLY_API_KEY = `${appId}.differentkey:secret`; const { stderr } = await runCommand( - ["auth:keys:get", mockKeyId, "--app", appId], + ["auth:keys:get", `${appId}.${mockKeyId}`], import.meta.url, ); @@ -255,17 +201,43 @@ describe("auth:keys:get command", () => { expect(error?.message).toMatch(/not found/); }); - standardControlApiErrorTests({ - commandArgs: ["auth:keys:get", mockKeyId], - importMetaUrl: import.meta.url, - setupNock: (scenario) => { - const appId = getMockConfigManager().getCurrentAppId()!; - const scope = nockControl().get(`/v1/apps/${appId}/keys`); - if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); - else if (scenario === "500") - scope.reply(500, { error: "Internal Server Error" }); - else scope.replyWithError("Network error"); - }, + it("should handle 401 authentication error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/keys`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["auth:keys:get", `${appId}.${mockKeyId}`], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + + it("should handle 500 server error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/keys`) + .reply(500, { error: "Internal Server Error" }); + + const { error } = await runCommand( + ["auth:keys:get", `${appId}.${mockKeyId}`], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + + it("should handle network error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/keys`) + .replyWithError("Network error"); + + const { error } = await runCommand( + ["auth:keys:get", `${appId}.${mockKeyId}`], + import.meta.url, + ); + expect(error).toBeDefined(); }); }); }); diff --git a/test/unit/commands/auth/keys/revoke.test.ts b/test/unit/commands/auth/keys/revoke.test.ts index 94c26dfa4..7734ea5cf 100644 --- a/test/unit/commands/auth/keys/revoke.test.ts +++ b/test/unit/commands/auth/keys/revoke.test.ts @@ -3,7 +3,6 @@ import { runCommand } from "@oclif/test"; import { nockControl, controlApiCleanup, - mockAppResolution, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; import { @@ -14,7 +13,6 @@ import { standardHelpTests, standardArgValidationTests, standardFlagTests, - standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; @@ -49,9 +47,8 @@ describe("auth:keys:revoke command", () => { expect(stdout).toContain("Key Label: Test Key"); }); - it("should revoke key with --app flag", async () => { + it("should revoke key by key name", async () => { const appId = getMockConfigManager().getCurrentAppId()!; - mockAppResolution(appId); mockKeysList(appId, [ buildMockKey(appId, mockKeyId, { capability: { "*": ["publish"] }, @@ -63,7 +60,7 @@ describe("auth:keys:revoke command", () => { .reply(200, {}); const { stdout } = await runCommand( - ["auth:keys:revoke", mockKeyId, "--app", appId, "--force"], + ["auth:keys:revoke", `${appId}.${mockKeyId}`, "--force"], import.meta.url, ); @@ -71,6 +68,22 @@ describe("auth:keys:revoke command", () => { expect(stdout).toContain("Key Label: Test Key"); }); + it("should revoke key by full key value", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + mockKeysList(appId, [buildMockKey(appId, mockKeyId)]); + + nockControl() + .post(`/v1/apps/${appId}/keys/${mockKeyId}/revoke`) + .reply(200, {}); + + const { stdout } = await runCommand( + ["auth:keys:revoke", `${appId}.${mockKeyId}:secret`, "--force"], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${appId}.${mockKeyId}`); + }); + it("should output JSON format when --json flag is used", async () => { const appId = getMockConfigManager().getCurrentAppId()!; mockKeysList(appId, [buildMockKey(appId, mockKeyId)]); @@ -101,7 +114,7 @@ describe("auth:keys:revoke command", () => { standardFlagTests("auth:keys:revoke", import.meta.url, ["--json"]); describe("error handling", () => { - it("should require keyName argument", async () => { + it("should require key identifier argument", async () => { const { error } = await runCommand( ["auth:keys:revoke", "--force"], import.meta.url, @@ -125,17 +138,43 @@ describe("auth:keys:revoke command", () => { expect(error?.message).toMatch(/not found/); }); - standardControlApiErrorTests({ - commandArgs: ["auth:keys:revoke", mockKeyId, "--force"], - importMetaUrl: import.meta.url, - setupNock: (scenario) => { - const appId = getMockConfigManager().getCurrentAppId()!; - const scope = nockControl().get(`/v1/apps/${appId}/keys`); - if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); - else if (scenario === "500") - scope.reply(500, { error: "Internal Server Error" }); - else scope.replyWithError("Network error"); - }, + it("should handle 401 authentication error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/keys`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["auth:keys:revoke", `${appId}.${mockKeyId}`, "--force"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + + it("should handle 500 server error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/keys`) + .reply(500, { error: "Internal Server Error" }); + + const { error } = await runCommand( + ["auth:keys:revoke", `${appId}.${mockKeyId}`, "--force"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + + it("should handle network error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/keys`) + .replyWithError("Network error"); + + const { error } = await runCommand( + ["auth:keys:revoke", `${appId}.${mockKeyId}`, "--force"], + import.meta.url, + ); + expect(error).toBeDefined(); }); }); }); diff --git a/test/unit/commands/auth/keys/switch.test.ts b/test/unit/commands/auth/keys/switch.test.ts index c2e4d3841..9baa6350a 100644 --- a/test/unit/commands/auth/keys/switch.test.ts +++ b/test/unit/commands/auth/keys/switch.test.ts @@ -3,7 +3,6 @@ import { runCommand } from "@oclif/test"; import { nockControl, controlApiCleanup, - getControlApiContext, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; import { @@ -113,27 +112,14 @@ describe("auth:keys:switch command", () => { expect(error?.message).toMatch(/not found|access denied/i); }); - it("should handle no app specified when config has no current app", async () => { - const mockConfig = getMockConfigManager(); - const { accountId } = getControlApiContext(); - mockConfig.setCurrentAppIdForAccount(undefined); - - // Mock the app resolution flow (requireAppId → promptForApp → listApps) - nockControl() - .get("/v1/me") - .reply(200, { - account: { id: accountId, name: "Test Account" }, - user: { email: "test@example.com" }, - }); - nockControl().get(`/v1/accounts/${accountId}/apps`).reply(200, []); - + it("should reject bare key identifier without dot", async () => { const { error } = await runCommand( ["auth:keys:switch", "just-a-key-id"], import.meta.url, ); expect(error).toBeDefined(); - expect(error?.message).toMatch(/No apps found/i); + expect(error?.message).toMatch(/Invalid key identifier/i); }); it("should handle 401 authentication error", async () => { diff --git a/test/unit/commands/auth/keys/update.test.ts b/test/unit/commands/auth/keys/update.test.ts index 804a4ff01..ad20252e7 100644 --- a/test/unit/commands/auth/keys/update.test.ts +++ b/test/unit/commands/auth/keys/update.test.ts @@ -3,7 +3,6 @@ import { runCommand } from "@oclif/test"; import { nockControl, controlApiCleanup, - mockAppResolution, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; import { parseJsonOutput } from "../../../../helpers/ndjson.js"; @@ -58,6 +57,33 @@ describe("auth:keys:update command", () => { expect(stdout).toContain(`Key Label: "OldName" → "NewName"`); }); + it("should update key by full key value", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + mockKeysList(appId, [ + buildMockKey(appId, mockKeyId, { name: "OldName" }), + ]); + + nockControl() + .patch(`/v1/apps/${appId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId, + name: "NewName", + key: `${appId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["auth:keys:update", `${appId}.${mockKeyId}:secret`, "--name=NewName"], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${appId}.${mockKeyId}`); + expect(stdout).toContain(`Key Label: "OldName" → "NewName"`); + }); + it("should update key capabilities", async () => { const appId = getMockConfigManager().getCurrentAppId()!; mockKeysList(appId, [buildMockKey(appId, mockKeyId)]); @@ -165,9 +191,8 @@ describe("auth:keys:update command", () => { expect(result.key.name.after).toBe("NewName"); }); - it("should update key with --app flag", async () => { + it("should update key by key name", async () => { const appId = getMockConfigManager().getCurrentAppId()!; - mockAppResolution(appId); mockKeysList(appId, [ buildMockKey(appId, mockKeyId, { name: "OldName", @@ -188,7 +213,7 @@ describe("auth:keys:update command", () => { }); const { stdout } = await runCommand( - ["auth:keys:update", mockKeyId, "--app", appId, "--name=UpdatedName"], + ["auth:keys:update", `${appId}.${mockKeyId}`, "--name=UpdatedName"], import.meta.url, ); @@ -206,7 +231,7 @@ describe("auth:keys:update command", () => { standardFlagTests("auth:keys:update", import.meta.url, ["--json"]); describe("error handling", () => { - it("should require keyName argument", async () => { + it("should require key identifier argument", async () => { const { error } = await runCommand( ["auth:keys:update", "--name", "Test"], import.meta.url, diff --git a/test/unit/commands/queues/delete.test.ts b/test/unit/commands/queues/delete.test.ts index 5352b8a04..6d04bc00b 100644 --- a/test/unit/commands/queues/delete.test.ts +++ b/test/unit/commands/queues/delete.test.ts @@ -54,6 +54,26 @@ describe("queues:delete command", () => { expect(stderr).toContain("Queue deleted:"); }); + it("should delete a queue by name", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockQueueId = `${appId}:us-east-1-a:${mockQueueName}`; + + nockControl() + .get(`/v1/apps/${appId}/queues`) + .reply(200, [createMockQueue(appId, mockQueueId)]); + + nockControl() + .delete(`/v1/apps/${appId}/queues/${mockQueueId}`) + .reply(204); + + const { stderr } = await runCommand( + ["queues:delete", mockQueueName, "--force"], + import.meta.url, + ); + + expect(stderr).toContain("Queue deleted:"); + }); + it("should delete a queue with custom app ID", async () => { const accountId = getMockConfigManager().getCurrentAccount()!.accountId; const customAppId = "custom-app-id"; @@ -188,6 +208,7 @@ describe("queues:delete command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/Queue.*not found/); + expect(error?.message).toContain("ably queues list"); expect(error?.oclif?.exit).toBeGreaterThan(0); }); @@ -213,7 +234,7 @@ describe("queues:delete command", () => { expect(error?.oclif?.exit).toBeGreaterThan(0); }); - it("should require queue ID argument", async () => { + it("should require queue name argument", async () => { const { error } = await runCommand(["queues:delete"], import.meta.url); expect(error).toBeDefined(); @@ -234,7 +255,7 @@ describe("queues:delete command", () => { expect(error?.message).toMatch(/No access token|No app|not logged in/i); }); - it("should handle when specific queue ID is not found in list", async () => { + it("should handle when queue is not found by name or ID in list", async () => { const appId = getMockConfigManager().getCurrentAppId()!; const mockQueueId = `${appId}:us-east-1-a:${mockQueueName}`; @@ -275,9 +296,8 @@ describe("queues:delete command", () => { ); expect(error).toBeDefined(); - expect(error?.message).toMatch( - `Queue with ID "${mockQueueId}" not found`, - ); + expect(error?.message).toMatch(`Queue "${mockQueueId}" not found`); + expect(error?.message).toContain("ably queues list"); expect(error?.oclif?.exit).toBeGreaterThan(0); }); @@ -398,7 +418,7 @@ describe("queues:delete command", () => { standardHelpTests("queues:delete", import.meta.url); standardArgValidationTests("queues:delete", import.meta.url, { - requiredArgs: ["test-queue-id"], + requiredArgs: ["test-queue"], }); standardFlagTests("queues:delete", import.meta.url, ["--json"]); }); diff --git a/test/unit/commands/stats/app.test.ts b/test/unit/commands/stats/app.test.ts index cc325310e..fa73617e4 100644 --- a/test/unit/commands/stats/app.test.ts +++ b/test/unit/commands/stats/app.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { nockControl, controlApiCleanup, + mockAppResolution, } from "../../../helpers/control-api-test-helpers.js"; import { runCommand } from "@oclif/test"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; @@ -42,6 +43,7 @@ describe("stats:app command", () => { describe("functionality", () => { it("should display app stats successfully", async () => { + mockAppResolution(appId); const scope = nockControl() .get(`/v1/apps/${appId}/stats`) .query(true) @@ -57,7 +59,39 @@ describe("stats:app command", () => { expect(stdout).toContain("2023-01-01"); }); + it("should display app stats by app name", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId; + const appName = "TestApp"; + + // Mock app resolution returning an app whose name matches + nockControl() + .get("/v1/me") + .reply(200, { + account: { id: accountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + nockControl() + .get(`/v1/accounts/${accountId}/apps`) + .reply(200, [{ id: appId, name: appName, accountId }]); + + const scope = nockControl() + .get(`/v1/apps/${appId}/stats`) + .query(true) + .reply(200, mockStatsData); + + const { stdout, error } = await runCommand( + ["stats:app", appName, "--start", "1h"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); + it("should accept ISO 8601 for --start and --end", async () => { + mockAppResolution(appId); const scope = nockControl() .get(`/v1/apps/${appId}/stats`) .query(true) @@ -80,6 +114,7 @@ describe("stats:app command", () => { }); it("should accept relative time for --start", async () => { + mockAppResolution(appId); const scope = nockControl() .get(`/v1/apps/${appId}/stats`) .query(true) @@ -95,6 +130,7 @@ describe("stats:app command", () => { }); it("should accept Unix ms for --start", async () => { + mockAppResolution(appId); const scope = nockControl() .get(`/v1/apps/${appId}/stats`) .query(true) diff --git a/test/unit/utils/key-parsing.test.ts b/test/unit/utils/key-parsing.test.ts index dca9b9d67..1e9972b64 100644 --- a/test/unit/utils/key-parsing.test.ts +++ b/test/unit/utils/key-parsing.test.ts @@ -1,33 +1,9 @@ import { describe, it, expect } from "vitest"; import { - parseKeyIdentifier, resolveCurrentKeyName, parseCapabilities, } from "../../../src/utils/key-parsing.js"; -describe("parseKeyIdentifier", () => { - it("should parse appId.keyId format", () => { - expect(parseKeyIdentifier("app123.key456")).toEqual({ - appId: "app123", - keyId: "key456", - }); - }); - - it("should return keyId only when no dot present", () => { - expect(parseKeyIdentifier("key456")).toEqual({ keyId: "key456" }); - }); - - it("should return keyId only when identifier contains a colon", () => { - expect(parseKeyIdentifier("app123.key456:secret")).toEqual({ - keyId: "app123.key456:secret", - }); - }); - - it("should return keyId only when multiple dots present", () => { - expect(parseKeyIdentifier("a.b.c")).toEqual({ keyId: "a.b.c" }); - }); -}); - describe("resolveCurrentKeyName", () => { it("should prefix keyId with appId", () => { expect(resolveCurrentKeyName("app123", "key456")).toBe("app123.key456");