diff --git a/AGENTS.md b/AGENTS.md index e6f3ad3c..71b4dcb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v - **`durationFlag`** — `--duration` / `-D`. Use for long-running subscribe/stream commands that auto-exit after N seconds. - **`rewindFlag`** — `--rewind`. Use for subscribe commands that support message replay (default: 0). - **`timeRangeFlags`** — `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). +- **`forceFlag`** — `--force` / `-f`. Use for destructive commands (delete, revoke) that require user confirmation. When `--force` is provided, skip the interactive prompt. When `--json` is used without `--force`, fail with an error requiring `--force`. Use `promptForConfirmation()` from `src/utils/prompt-confirmation.js` for the interactive prompt — do NOT use `interactiveHelper.confirm()` (inquirer-based, inconsistent UX). - **`endpointFlag`** — `--endpoint`. Hidden, only on `accounts login` and `accounts switch`. **Flags vs positional arguments (POSIX / docopt convention):** @@ -311,6 +312,10 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers, - `--direction`: `"Direction of message retrieval"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`. - Channels use "publish", Rooms use "send" (matches SDK terminology) - Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`) +- **Destructive command confirmation pattern**: Commands that perform irreversible actions (delete, revoke) must use `...forceFlag` and `promptForConfirmation()`. The pattern: + 1. If `--json` without `--force`: `this.fail("The --force flag is required when using --json to confirm ", flags, component)` + 2. If no `--force` and not JSON: show what will be affected, then call `promptForConfirmation()` for yes/no + 3. If `--force`: skip prompt, proceed directly ## Ably Knowledge diff --git a/src/base-command.ts b/src/base-command.ts index a2d9a3b3..6f9189f8 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -17,6 +17,7 @@ import { CommandError } from "./errors/command-error.js"; import { getFriendlyAblyErrorHint } from "./utils/errors.js"; import { coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; +import { promptForConfirmation } from "./utils/prompt-confirmation.js"; import { BaseFlags, CommandConfig } from "./types/cli.js"; import { JsonRecordType, @@ -1368,7 +1369,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { "The configured API key appears to be invalid or revoked.", ); - const shouldRemove = await this.interactiveHelper.confirm( + const shouldRemove = await promptForConfirmation( "Would you like to remove this invalid key from your configuration?", ); diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index a6c99522..f40dade4 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -244,10 +244,23 @@ export default class AccountsSwitch extends ControlBaseCommand { this.configManager.storeEndpoint(flags.endpoint as string); } - this.log( - `Switched to account: ${formatResource(remoteAccount.name)} (${remoteAccount.id})`, - ); - this.log(`Saved as alias: ${formatResource(newAlias)}`); + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + account: { + alias: newAlias, + id: remoteAccount.id, + name: remoteAccount.name, + }, + }, + flags, + ); + } else { + this.logSuccessMessage( + `Switched to account ${formatResource(remoteAccount.name)} (${remoteAccount.id}). Saved as alias ${formatResource(newAlias)}.`, + flags, + ); + } } private async switchToLocalAccount( @@ -292,20 +305,14 @@ export default class AccountsSwitch extends ControlBaseCommand { } } catch { // The account switch already happened above, so this is non-fatal. - // Warn the user but still report success with a warning field. + // Report the switch success, then surface the verification failure as + // a separate warning record (consistent with other commands' JSON shape). const warningMessage = "Access token may have expired or is invalid. The account was switched, but token verification failed."; if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - account: { alias }, - warning: warningMessage, - }, - flags, - ); - } else { - this.logWarning(warningMessage, flags); + this.logJsonResult({ account: { alias } }, flags); } + this.logWarning(warningMessage, flags); } } } diff --git a/src/commands/auth/keys/revoke.ts b/src/commands/auth/keys/revoke.ts index db585bc1..0beab158 100644 --- a/src/commands/auth/keys/revoke.ts +++ b/src/commands/auth/keys/revoke.ts @@ -4,6 +4,7 @@ import { ControlBaseCommand } from "../../../control-base-command.js"; import { forceFlag } from "../../../flags.js"; import { formatCapabilities } from "../../../utils/key-display.js"; import { formatLabel, formatResource } from "../../../utils/output.js"; +import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class KeysRevokeCommand extends ControlBaseCommand { static args = { @@ -70,8 +71,8 @@ export default class KeysRevokeCommand extends ControlBaseCommand { } if (!flags.force && !this.shouldOutputJson(flags)) { - const confirmed = await this.interactiveHelper.confirm( - "This will permanently revoke this key and any applications using it will stop working. Continue?", + const confirmed = await promptForConfirmation( + "\nThis will permanently revoke this key and any applications using it will stop working. Are you sure you want to continue?", ); if (!confirmed) { @@ -106,7 +107,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand { // Auto-remove in JSON mode — key is already revoked, can't be used this.configManager.removeApiKey(appId); } else { - const shouldRemove = await this.interactiveHelper.confirm( + const shouldRemove = await promptForConfirmation( "The revoked key was your current key for this app. Remove it from configuration?", ); diff --git a/src/commands/auth/keys/switch.ts b/src/commands/auth/keys/switch.ts index 85ba361a..76f1c155 100644 --- a/src/commands/auth/keys/switch.ts +++ b/src/commands/auth/keys/switch.ts @@ -166,12 +166,8 @@ export default class KeysSwitchCommand extends ControlBaseCommand { `Switched to key ${formatResource(keyName)}.`, flags, ); - } catch { - this.fail( - `Key "${keyIdentifier}" not found or access denied. Run "ably auth keys list" to see available keys.`, - flags, - "keySwitch", - ); + } catch (error) { + this.fail(error, flags, "keySwitch", { keyIdentifier }); } } } diff --git a/src/commands/auth/revoke-token.ts b/src/commands/auth/revoke-token.ts index 696233d7..39ff5148 100644 --- a/src/commands/auth/revoke-token.ts +++ b/src/commands/auth/revoke-token.ts @@ -1,46 +1,76 @@ -import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import { Flags } from "@oclif/core"; import * as https from "node:https"; +import stripAnsi from "strip-ansi"; import { AblyBaseCommand } from "../../base-command.js"; -import { productApiFlags } from "../../flags.js"; +import { forceFlag, productApiFlags } from "../../flags.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; +import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class RevokeTokenCommand extends AblyBaseCommand { - static args = { - token: Args.string({ - description: "Token to revoke", - name: "token", - required: true, - }), - }; - - static description = "Revoke a token"; + static description = "Revoke tokens by client ID or revocation key"; static examples = [ - "$ ably auth revoke-token TOKEN", - "$ ably auth revoke-token TOKEN --client-id clientid", - "$ ably auth revoke-token TOKEN --json", - "$ ably auth revoke-token TOKEN --pretty-json", + `$ ably auth revoke-token --client-id "userClientId"`, + `$ ably auth revoke-token --client-id "userClientId" --force`, + `$ ably auth revoke-token --revocation-key group1`, + `$ ably auth revoke-token --client-id "userClientId" --allow-reauth-margin`, + `$ ably auth revoke-token --client-id "userClientId" --json --force`, ]; static flags = { ...productApiFlags, + ...forceFlag, app: Flags.string({ description: "The app ID or name (defaults to current app)", env: "ABLY_APP_ID", }), - "client-id": Flags.string({ - char: "c", - description: "Client ID to revoke tokens for", + description: "Revoke all tokens issued to this client ID", + exclusive: ["revocation-key"], + }), + "revocation-key": Flags.string({ + description: + "Revoke all tokens matching this revocation key (JWT tokens only)", + exclusive: ["client-id"], + }), + "allow-reauth-margin": Flags.boolean({ + default: false, + description: + "Delay enforcement by 30s so connected clients can obtain a new token before disconnection.", }), }; - // Property to store the Ably client - private ablyClient?: Ably.Realtime; - async run(): Promise { - const { args, flags } = await this.parse(RevokeTokenCommand); + const { flags } = await this.parse(RevokeTokenCommand); + + const clientId = flags["client-id"]; + const revocationKey = flags["revocation-key"]; + + // Require at least one target specifier + if (!clientId && !revocationKey) { + this.fail( + "Either --client-id or --revocation-key must be provided", + flags, + "revokeToken", + ); + } + + // Build target specifier + const targetSpecifier = clientId + ? `clientId:${clientId}` + : `revocationKey:${revocationKey}`; + const targetLabel = clientId ? "Client ID" : "Revocation Key"; + const targetValue = (clientId ?? revocationKey)!; + + // JSON mode guard — fail fast before config lookup + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm revocation", + flags, + "revokeToken", + ); + } // Get app and key const appAndKey = await this.ensureAppAndKey(flags); @@ -49,51 +79,49 @@ export default class RevokeTokenCommand extends AblyBaseCommand { } const { apiKey } = appAndKey; - const { token } = args; - - try { - // Create Ably Realtime client - const client = await this.createAblyRealtimeClient(flags); - if (!client) return; - this.ablyClient = client; + // Interactive confirmation + if (!flags.force && !this.shouldOutputJson(flags)) { + this.logToStderr(`\nYou are about to revoke all tokens matching:`); + this.logToStderr( + `${formatLabel(targetLabel)} ${formatResource(targetValue)}`, + ); - const clientId = flags["client-id"] || token; + const confirmed = await promptForConfirmation( + "\nThis will permanently revoke all matching tokens, and any applications using those tokens will need to be issued new tokens. Are you sure?", + ); - if (!flags["client-id"]) { - // We need to warn the user that we're using the token as a client ID - this.logWarning( - "Revoking a specific token is only possible if it has a client ID or revocation key.", - flags, - ); - this.logWarning( - "For advanced token revocation options, see: https://ably.com/docs/auth/revocation.", - flags, - ); - this.logWarning( - "Using the token argument as a client ID for this operation.", - flags, - ); + if (!confirmed) { + this.logWarning("Revocation cancelled.", flags); + return; } + } + try { // Extract the keyName (appId.keyId) from the API key const keyParts = apiKey.split(":"); if (keyParts.length !== 2) { this.fail( "Invalid API key format. Expected format: appId.keyId:secret", flags, - "tokenRevoke", + "revokeToken", ); } - const keyName = keyParts[0]!; // This gets the appId.keyId portion + const keyName = keyParts[0]!; const secret = keyParts[1]!; - // Create the properly formatted body for token revocation - const requestBody = { - targets: [`clientId:${clientId}`], + const requestBody: Record = { + targets: [targetSpecifier], }; + let reauthNote = ""; + if (flags["allow-reauth-margin"]) { + requestBody.allowReauthMargin = true; + reauthNote = + " Connected clients have a 30s grace period to obtain new tokens before disconnection."; + } + try { // Make direct HTTPS request to Ably REST API const response = await this.makeHttpRequest( @@ -101,33 +129,37 @@ export default class RevokeTokenCommand extends AblyBaseCommand { secret, requestBody, ); + const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`; if (this.shouldOutputJson(flags)) { this.logJsonResult( { revocation: { - message: "Token revocation processed successfully", + allowReauthMargin: flags["allow-reauth-margin"], + message: stripAnsi(successMessage), + target: targetSpecifier, response, }, }, flags, ); } else { - this.logSuccessMessage("Token successfully revoked.", flags); + this.logSuccessMessage(successMessage, flags); } } catch (requestError: unknown) { - // Handle specific API errors - const error = requestError as Error; - if (error.message && error.message.includes("token_not_found")) { - this.fail("Token not found or already revoked", flags, "tokenRevoke"); - } else { - throw requestError; + const error = requestError as Error & { statusCode?: number }; + if (error.statusCode === 404) { + this.fail( + "No matching tokens found or already revoked", + flags, + "revokeToken", + ); } + throw requestError; } } catch (error) { - this.fail(error, flags, "tokenRevoke"); + this.fail(error, flags, "revokeToken"); } - // Client cleanup is handled by base class finally() method } // Helper method to make a direct HTTP request to the Ably REST API @@ -172,11 +204,11 @@ export default class RevokeTokenCommand extends AblyBaseCommand { resolve(data); } } else { - reject( - new Error( - `Request failed with status code ${res.statusCode}: ${data}`, - ), - ); + const err = new Error( + `Request failed with status code ${res.statusCode}: ${data}`, + ) as Error & { statusCode?: number }; + err.statusCode = res.statusCode; + reject(err); } }); }); diff --git a/src/commands/bench/publisher.ts b/src/commands/bench/publisher.ts index 8ca3702c..b734f15d 100644 --- a/src/commands/bench/publisher.ts +++ b/src/commands/bench/publisher.ts @@ -6,6 +6,7 @@ import Table from "cli-table3"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; import { errorMessage } from "../../utils/errors.js"; +import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import type { BenchPresenceData } from "../../types/bench.js"; interface TestMetrics { @@ -388,7 +389,7 @@ export default class BenchPublisher extends AblyBaseCommand { `Found ${subscribers.length} subscribers present`, ); if (subscribers.length === 0 && !this.shouldOutputJson(flags)) { - const shouldContinue = await this.interactiveHelper.confirm( + const shouldContinue = await promptForConfirmation( "No subscribers found. Continue anyway?", ); if (!shouldContinue) { diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index 5c83f6c0..3e4a6b40 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -34,7 +34,7 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { async run(): Promise { const { args, flags } = await this.parse(QueuesDeleteCommand); if (!args.queueNameOrId.trim()) { - this.fail("Queue name or ID cannot be empty", flags, "parse"); + this.fail("Queue name or ID cannot be empty", flags, "queueDelete"); } const appId = await this.requireAppId(flags); diff --git a/test/e2e/auth/auth-tokens-e2e.test.ts b/test/e2e/auth/auth-tokens-e2e.test.ts index b856f63e..ac4acac5 100644 --- a/test/e2e/auth/auth-tokens-e2e.test.ts +++ b/test/e2e/auth/auth-tokens-e2e.test.ts @@ -1,4 +1,8 @@ +import { randomUUID } from "node:crypto"; + import { describe, it, beforeEach, afterEach, expect } from "vitest"; +import jwt from "jsonwebtoken"; +import { runCommand } from "../../helpers/command-helpers.js"; import { E2E_API_KEY, SHOULD_SKIP_E2E, @@ -6,7 +10,6 @@ import { setupTestFailureHandler, resetTestTracking, } from "../../helpers/e2e-test-helper.js"; -import { runCommand } from "../../helpers/command-helpers.js"; import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Auth Tokens E2E Tests", () => { @@ -107,17 +110,15 @@ describe.skipIf(SHOULD_SKIP_E2E)("Auth Tokens E2E Tests", () => { const issuedToken = issueResultRecord!.token as Record; expect(issuedToken.value).toBeDefined(); - const tokenValue = issuedToken.value as string; - - // Step 2: Revoke the token using its client ID + // Step 2: Revoke tokens for the client ID (token positional arg removed — API revokes by target specifier) const revokeResult = await runCommand( [ "auth", "revoke-token", - tokenValue, "--client-id", "e2e-revoke-test", "--json", + "--force", ], { env: { ABLY_API_KEY: E2E_API_KEY || "" }, @@ -132,6 +133,114 @@ describe.skipIf(SHOULD_SKIP_E2E)("Auth Tokens E2E Tests", () => { expect(revokeResultRecord).toBeDefined(); expect(revokeResultRecord!.success).toBe(true); + expect(revokeResultRecord!.revocation).toBeDefined(); + expect( + (revokeResultRecord!.revocation as Record).target, + ).toBe("clientId:e2e-revoke-test"); + }); + + it("should issue a JWT with revocation key, then revoke by that key", async () => { + setupTestFailureHandler( + "should issue a JWT with revocation key, then revoke by that key", + ); + + const apiKey = E2E_API_KEY || ""; + const [keyId, keySecret] = apiKey.split(":"); + const appId = keyId!.split(".")[0]; + const revocationKey = `e2e-revoke-group-${Date.now()}`; + + // Step 1: Manually create a JWT with the x-ably-revocation-key claim + const jwtToken = jwt.sign( + { + "x-ably-appId": appId, + "x-ably-capability": { "*": ["*"] }, + "x-ably-clientId": `e2e-revoke-key-client-${randomUUID().slice(0, 8)}`, + "x-ably-revocation-key": revocationKey, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + jti: randomUUID(), + }, + keySecret!, + { algorithm: "HS256", keyid: keyId }, + ); + + // Step 2: Verify the JWT is valid by subscribing briefly with it + const subscribeResult = await runCommand( + ["channels", "subscribe", "e2e-revoke-key-test", "--json"], + { + env: { ABLY_TOKEN: jwtToken }, + timeoutMs: 5000, + }, + ); + + // The subscribe auto-exits via ABLY_CLI_DEFAULT_DURATION or timeout; + // we just need it to have connected (exit 0 or timeout is fine) + expect([0, null]).toContain(subscribeResult.exitCode); + + // Step 3: Revoke by revocation key + const revokeResult = await runCommand( + [ + "auth", + "revoke-token", + "--revocation-key", + revocationKey, + "--json", + "--force", + ], + { + env: { ABLY_API_KEY: apiKey }, + timeoutMs: 30000, + }, + ); + + expect(revokeResult.exitCode).toBe(0); + + const revokeRecords = parseNdjsonLines(revokeResult.stdout); + const revokeResultRecord = revokeRecords.find((r) => r.type === "result"); + + expect(revokeResultRecord).toBeDefined(); + expect(revokeResultRecord!.success).toBe(true); + expect(revokeResultRecord!.revocation).toBeDefined(); + expect( + (revokeResultRecord!.revocation as Record).target, + ).toBe(`revocationKey:${revocationKey}`); + }); + + it("should revoke tokens with --allow-reauth-margin", async () => { + setupTestFailureHandler( + "should revoke tokens with --allow-reauth-margin", + ); + + const revokeResult = await runCommand( + [ + "auth", + "revoke-token", + "--client-id", + "e2e-reauth-test", + "--allow-reauth-margin", + "--json", + "--force", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(revokeResult.exitCode).toBe(0); + + const revokeRecords = parseNdjsonLines(revokeResult.stdout); + const revokeResultRecord = revokeRecords.find((r) => r.type === "result"); + + expect(revokeResultRecord).toBeDefined(); + expect(revokeResultRecord!.success).toBe(true); + + const revocation = revokeResultRecord!.revocation as Record< + string, + unknown + >; + expect(revocation.target).toBe("clientId:e2e-reauth-test"); + expect(revocation.allowReauthMargin).toBe(true); }); }); }); diff --git a/test/unit/commands/auth/revoke-token.test.ts b/test/unit/commands/auth/revoke-token.test.ts index 82dc2dde..9708860c 100644 --- a/test/unit/commands/auth/revoke-token.test.ts +++ b/test/unit/commands/auth/revoke-token.test.ts @@ -1,23 +1,21 @@ +import { Readable } from "node:stream"; + import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; import nock from "nock"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; -import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; import { standardHelpTests, - standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; import { parseNdjsonLines } from "../../../helpers/ndjson.js"; describe("auth:revoke-token command", () => { - const mockToken = "test-token-12345"; const mockClientId = "test-client-id"; + const mockRevocationKey = "group1"; beforeEach(() => { nock.cleanAll(); - // Initialize the mock (command creates one but doesn't use it for HTTP) - getMockAblyRealtime(); }); afterEach(() => { @@ -26,91 +24,215 @@ describe("auth:revoke-token command", () => { standardHelpTests("auth:revoke-token", import.meta.url); - standardArgValidationTests("auth:revoke-token", import.meta.url, { - requiredArgs: ["test-token"], - }); - - describe("token revocation", () => { - it("should successfully revoke a token with client-id", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - // Mock the token revocation endpoint - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - }) - .reply(200, {}); - - const { stderr } = await runCommand( - ["auth:revoke-token", mockToken, "--client-id", mockClientId], + describe("argument validation", () => { + it("should fail when neither --client-id nor --revocation-key is provided", async () => { + const { error } = await runCommand( + ["auth:revoke-token", "--force"], import.meta.url, ); - expect(stderr).toContain("Token successfully revoked"); + expect(error).toBeDefined(); + expect(error?.message).toContain( + "Either --client-id or --revocation-key must be provided", + ); }); - it("should use token as client-id when --client-id not provided", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - // When no client-id is provided, the token is used as the client-id - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockToken}`], - }) - .reply(200, {}); - - const { stderr } = await runCommand( - ["auth:revoke-token", mockToken], + it("should fail when both --client-id and --revocation-key are provided", async () => { + const { error } = await runCommand( + [ + "auth:revoke-token", + "--client-id", + mockClientId, + "--revocation-key", + mockRevocationKey, + "--force", + ], import.meta.url, ); - // Should show warnings about using token as client-id - expect(stderr).toContain( - "Revoking a specific token is only possible if it has a client ID", + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /cannot also be provided when using.*--client-id|cannot also be provided when using.*--revocation-key/i, ); - expect(stderr).toContain("Using the token argument as a client ID"); - expect(stderr).toContain("Token successfully revoked"); }); + }); - it("should output JSON format when --json flag is used", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - }) - .reply(200, { issuedBefore: 1234567890 }); + describe("functionality", () => { + describe("token revocation by client ID", () => { + it("should successfully revoke tokens for a client ID", async () => { + const mockConfig = getMockConfigManager(); + const keyId = mockConfig.getKeyId()!; + nock("https://rest.ably.io") + .post(`/keys/${keyId}/revokeTokens`, { + targets: [`clientId:${mockClientId}`], + }) + .reply(200, {}); - const { stdout } = await runCommand( - ["auth:revoke-token", mockToken, "--client-id", mockClientId, "--json"], - import.meta.url, - ); + const { stderr } = await runCommand( + ["auth:revoke-token", "--client-id", mockClientId, "--force"], + import.meta.url, + ); - const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; - expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("revocation"); - expect(result.revocation).toHaveProperty( - "message", - "Token revocation processed successfully", - ); - expect(result.revocation).toHaveProperty("response"); + expect(stderr).toContain("have been revoked"); + }); + + it("should include allowReauthMargin when --allow-reauth-margin is provided", async () => { + const mockConfig = getMockConfigManager(); + const keyId = mockConfig.getKeyId()!; + nock("https://rest.ably.io") + .post(`/keys/${keyId}/revokeTokens`, { + targets: [`clientId:${mockClientId}`], + allowReauthMargin: true, + }) + .reply(200, {}); + + const { stderr } = await runCommand( + [ + "auth:revoke-token", + "--client-id", + mockClientId, + "--allow-reauth-margin", + "--force", + ], + import.meta.url, + ); + + expect(stderr).toContain("have been revoked"); + }); + + it("should output JSON format when --json flag is used", async () => { + const mockConfig = getMockConfigManager(); + const keyId = mockConfig.getKeyId()!; + nock("https://rest.ably.io") + .post(`/keys/${keyId}/revokeTokens`, { + targets: [`clientId:${mockClientId}`], + }) + .reply(200, { issuedBefore: 1234567890 }); + + const { stdout } = await runCommand( + [ + "auth:revoke-token", + "--client-id", + mockClientId, + "--json", + "--force", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result", + )!; + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("revocation"); + expect(result.revocation).toHaveProperty( + "message", + `Tokens matching client id ${mockClientId} have been revoked.`, + ); + expect(result.revocation).toHaveProperty( + "target", + `clientId:${mockClientId}`, + ); + expect(result.revocation).toHaveProperty("response"); + }); + }); + + describe("token revocation by revocation key", () => { + it("should successfully revoke tokens for a revocation key", async () => { + const mockConfig = getMockConfigManager(); + const keyId = mockConfig.getKeyId()!; + nock("https://rest.ably.io") + .post(`/keys/${keyId}/revokeTokens`, { + targets: [`revocationKey:${mockRevocationKey}`], + }) + .reply(200, {}); + + const { stderr } = await runCommand( + [ + "auth:revoke-token", + "--revocation-key", + mockRevocationKey, + "--force", + ], + import.meta.url, + ); + + expect(stderr).toContain("have been revoked"); + }); + }); + + describe("confirmation prompt", () => { + const originalStdin = process.stdin; + + function mockStdinAnswer(answer: string) { + const readable = new Readable({ read() {} }); + Object.defineProperty(process, "stdin", { + value: readable, + writable: true, + configurable: true, + }); + queueMicrotask(() => { + for (const chunk of [`${answer}\n`, null]) readable.push(chunk); + }); + } + + afterEach(() => { + Object.defineProperty(process, "stdin", { + value: originalStdin, + writable: true, + configurable: true, + }); + }); + + it("should require --force in JSON mode", async () => { + const { stdout } = await runCommand( + ["auth:revoke-token", "--client-id", mockClientId, "--json"], + import.meta.url, + ); + + const lines = parseNdjsonLines(stdout); + const errorLine = lines.find((r) => r.type === "error"); + expect(errorLine).toBeDefined(); + const errorPayload = errorLine!.error as { message: string }; + expect(errorPayload.message).toContain( + "The --force flag is required when using --json to confirm revocation", + ); + }); + + it("should cancel when user declines confirmation", async () => { + mockStdinAnswer("n"); + + const revokeNock = nock("https://rest.ably.io") + .post(/\/keys\/.*\/revokeTokens/) + .reply(200, {}); + + const { stderr } = await runCommand( + ["auth:revoke-token", "--client-id", mockClientId], + import.meta.url, + ); + + expect(stderr).toContain("Revocation cancelled"); + expect(revokeNock.isDone()).toBe(false); + }); }); + }); - it("should handle token not found error with special message", async () => { + describe("error handling", () => { + it("should handle token not found error", async () => { const mockConfig = getMockConfigManager(); const keyId = mockConfig.getKeyId()!; - // The command handles token_not_found specifically in the response body nock("https://rest.ably.io") .post(`/keys/${keyId}/revokeTokens`) .reply(404, "token_not_found"); const { error } = await runCommand( - ["auth:revoke-token", mockToken, "--client-id", mockClientId], + ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); - // Command outputs error via fail - expect(error?.message).toContain("Token not found or already revoked"); + expect(error?.message).toContain( + "No matching tokens found or already revoked", + ); }); it("should handle authentication error (invalid API key)", async () => { @@ -121,7 +243,7 @@ describe("auth:revoke-token command", () => { .reply(401, { error: { message: "Unauthorized" } }); const { error } = await runCommand( - ["auth:revoke-token", mockToken, "--client-id", mockClientId], + ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); @@ -137,7 +259,7 @@ describe("auth:revoke-token command", () => { .reply(500, { error: "Internal Server Error" }); const { error } = await runCommand( - ["auth:revoke-token", mockToken, "--client-id", mockClientId], + ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); @@ -148,6 +270,9 @@ describe("auth:revoke-token command", () => { standardFlagTests("auth:revoke-token", import.meta.url, [ "--client-id", + "--revocation-key", + "--allow-reauth-margin", "--json", + "--force", ]); });