From d788a003ddb28eba6a26628dd5ce86111509185a Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 6 Jan 2026 16:05:11 +0100 Subject: [PATCH 01/20] feat(push): Add device registration commands for push notifications Implement Stage 1 of push notification support with full device registration management using the Ably SDK's push.admin.deviceRegistrations API. Commands added: - push devices save: Register devices (Android/FCM, iOS/APNs, Web Push) - push devices get: Retrieve device details by ID - push devices list: List devices with filters (clientId, state, limit) - push devices remove: Remove single device by ID - push devices remove-where: Bulk remove by clientId/deviceId Features: - Full support for all three platforms: Android (FCM), iOS (APNs), browser (Web Push) - Web Push requires --target-url, --p256dh-key, and --auth-secret flags - JSON output mode with --json flag - Confirmation prompts for destructive operations (--force to skip) - Token redaction in get command for security Includes 14 E2E tests hitting real Ably sandbox endpoints with fake device tokens (verified to work for testing purposes). --- src/commands/push/devices/get.ts | 149 ++++++ src/commands/push/devices/index.ts | 18 + src/commands/push/devices/list.ts | 151 ++++++ src/commands/push/devices/remove-where.ts | 152 ++++++ src/commands/push/devices/remove.ts | 102 ++++ src/commands/push/devices/save.ts | 355 ++++++++++++++ src/commands/push/index.ts | 16 + test/e2e/push/devices-e2e.test.ts | 540 ++++++++++++++++++++++ 8 files changed, 1483 insertions(+) create mode 100644 src/commands/push/devices/get.ts create mode 100644 src/commands/push/devices/index.ts create mode 100644 src/commands/push/devices/list.ts create mode 100644 src/commands/push/devices/remove-where.ts create mode 100644 src/commands/push/devices/remove.ts create mode 100644 src/commands/push/devices/save.ts create mode 100644 src/commands/push/index.ts create mode 100644 test/e2e/push/devices-e2e.test.ts diff --git a/src/commands/push/devices/get.ts b/src/commands/push/devices/get.ts new file mode 100644 index 00000000..81a04001 --- /dev/null +++ b/src/commands/push/devices/get.ts @@ -0,0 +1,149 @@ +import { Args } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import chalk from "chalk"; + +export default class PushDevicesGet extends AblyBaseCommand { + static override description = + "Get details of a registered push notification device (maps to push.admin.deviceRegistrations.get)"; + + static override examples = [ + "$ ably push devices get DEVICE_ID", + "$ ably push devices get my-device-123", + "$ ably push devices get my-device-123 --json", + ]; + + static override args = { + deviceId: Args.string({ + description: "The device ID to retrieve", + required: true, + }), + }; + + static override flags = { + ...AblyBaseCommand.globalFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(PushDevicesGet); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + const device = await rest.push.admin.deviceRegistrations.get( + args.deviceId, + ); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + device: { + id: device.id, + platform: device.platform, + formFactor: device.formFactor, + clientId: device.clientId, + state: device.push?.state, + metadata: device.metadata, + push: { + state: device.push?.state, + error: device.push?.error, + // Redact sensitive recipient info + recipient: device.push?.recipient + ? { transportType: device.push.recipient.transportType } + : undefined, + }, + }, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log(chalk.bold("Device Details\n")); + + this.log(`${chalk.dim("Device ID:")} ${chalk.green(device.id)}`); + + if (device.clientId) { + this.log(`${chalk.dim("Client ID:")} ${device.clientId}`); + } + + this.log(`${chalk.dim("Platform:")} ${device.platform}`); + this.log(`${chalk.dim("Form Factor:")} ${device.formFactor}`); + + if (device.push?.state) { + const stateColor = + device.push.state === "ACTIVE" + ? chalk.green + : device.push.state === "FAILING" + ? chalk.yellow + : chalk.red; + this.log( + `${chalk.dim("State:")} ${stateColor(device.push.state)}`, + ); + } + + if (device.push?.error) { + this.log(""); + this.log(chalk.red("Last Error:")); + this.log(` ${chalk.dim("Message:")} ${device.push.error.message}`); + if (device.push.error.code) { + this.log(` ${chalk.dim("Code:")} ${device.push.error.code}`); + } + } + + // Show recipient info (redacted) + if (device.push?.recipient) { + this.log(""); + this.log(chalk.dim("Push Recipient:")); + this.log( + ` ${chalk.dim("Transport:")} ${device.push.recipient.transportType}`, + ); + + // Show redacted token for security + const recipient = device.push.recipient as Record; + const token = recipient.registrationToken || recipient.deviceToken; + + if (typeof token === "string" && token.length > 8) { + const redacted = `${token.slice(0, 4)}...${token.slice(-4)}`; + this.log(` ${chalk.dim("Token:")} ${redacted} (redacted)`); + } + } + + if (device.metadata && Object.keys(device.metadata).length > 0) { + this.log(""); + this.log(chalk.dim("Metadata:")); + for (const [key, value] of Object.entries(device.metadata)) { + this.log(` ${chalk.dim(key + ":")} ${String(value)}`); + } + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + if (errorCode === 40400) { + this.error(`Device not found: ${args.deviceId}`); + } else { + this.error(`Error getting device: ${errorMessage}`); + } + } + } + } +} diff --git a/src/commands/push/devices/index.ts b/src/commands/push/devices/index.ts new file mode 100644 index 00000000..e1dcf261 --- /dev/null +++ b/src/commands/push/devices/index.ts @@ -0,0 +1,18 @@ +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class PushDevices extends BaseTopicCommand { + protected topicName = "push:devices"; + protected commandGroup = "push device registration"; + + static override description = + "Manage push notification device registrations (maps to push.admin.deviceRegistrations)"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> list", + "<%= config.bin %> <%= command.id %> list --client-id user-123", + "<%= config.bin %> <%= command.id %> get DEVICE_ID", + "<%= config.bin %> <%= command.id %> save --id my-device --platform android --form-factor phone --transport-type fcm --device-token TOKEN", + "<%= config.bin %> <%= command.id %> remove DEVICE_ID", + "<%= config.bin %> <%= command.id %> remove-where --client-id user-123", + ]; +} diff --git a/src/commands/push/devices/list.ts b/src/commands/push/devices/list.ts new file mode 100644 index 00000000..edcadf64 --- /dev/null +++ b/src/commands/push/devices/list.ts @@ -0,0 +1,151 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import * as Ably from "ably"; +import chalk from "chalk"; + +export default class PushDevicesList extends AblyBaseCommand { + static override description = + "List registered push notification devices (maps to push.admin.deviceRegistrations.list)"; + + static override examples = [ + "$ ably push devices list", + "$ ably push devices list --client-id user-123", + "$ ably push devices list --device-id device-456", + "$ ably push devices list --state ACTIVE", + "$ ably push devices list --limit 50", + "$ ably push devices list --json", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + "client-id": Flags.string({ + description: "Filter devices by client ID", + }), + "device-id": Flags.string({ + description: "Filter by device ID", + }), + state: Flags.string({ + description: "Filter by device state", + options: ["ACTIVE", "FAILING", "FAILED"], + }), + limit: Flags.integer({ + default: 100, + description: "Maximum number of devices to return (max: 1000)", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushDevicesList); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Build filter params + const params: Ably.DeviceRegistrationParams = {}; + + if (flags["client-id"]) { + params.clientId = flags["client-id"]; + } + + if (flags["device-id"]) { + params.deviceId = flags["device-id"]; + } + + if (flags.state) { + params.state = flags.state as Ably.DevicePushState; + } + + if (flags.limit) { + params.limit = Math.min(flags.limit, 1000); + } + + // Fetch devices + const devicesPage = + await rest.push.admin.deviceRegistrations.list(params); + const devices = devicesPage.items; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + devices: devices.map((device) => ({ + id: device.id, + platform: device.platform, + formFactor: device.formFactor, + clientId: device.clientId, + state: device.push?.state, + metadata: device.metadata, + })), + hasMore: devicesPage.hasNext(), + success: true, + timestamp: new Date().toISOString(), + total: devices.length, + }, + flags, + ), + ); + } else { + if (devices.length === 0) { + this.log("No devices found."); + return; + } + + this.log( + `Found ${chalk.cyan(devices.length.toString())} device${devices.length === 1 ? "" : "s"}:\n`, + ); + + for (const device of devices) { + this.log(`${chalk.green(device.id)}`); + this.log(` ${chalk.dim("Platform:")} ${device.platform}`); + this.log(` ${chalk.dim("Form Factor:")} ${device.formFactor}`); + + if (device.clientId) { + this.log(` ${chalk.dim("Client ID:")} ${device.clientId}`); + } + + if (device.push?.state) { + const stateColor = + device.push.state === "ACTIVE" + ? chalk.green + : device.push.state === "FAILING" + ? chalk.yellow + : chalk.red; + this.log( + ` ${chalk.dim("State:")} ${stateColor(device.push.state)}`, + ); + } + + this.log(""); // Add spacing between devices + } + + if (devicesPage.hasNext()) { + this.log( + chalk.yellow( + `Showing first ${devices.length} devices. Use --limit to show more.`, + ), + ); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error listing devices: ${errorMessage}`); + } + } + } +} diff --git a/src/commands/push/devices/remove-where.ts b/src/commands/push/devices/remove-where.ts new file mode 100644 index 00000000..2bbacf43 --- /dev/null +++ b/src/commands/push/devices/remove-where.ts @@ -0,0 +1,152 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import * as Ably from "ably"; +import chalk from "chalk"; + +export default class PushDevicesRemoveWhere extends AblyBaseCommand { + static override description = + "Remove all devices matching specified criteria (maps to push.admin.deviceRegistrations.removeWhere)"; + + static override examples = [ + "$ ably push devices remove-where --client-id user-123", + "$ ably push devices remove-where --device-id device-prefix", + "$ ably push devices remove-where --client-id user-123 --force", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + "client-id": Flags.string({ + description: "Remove all devices for this client ID", + }), + "device-id": Flags.string({ + description: "Remove device with this ID", + }), + force: Flags.boolean({ + char: "f", + default: false, + description: "Skip confirmation prompt", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushDevicesRemoveWhere); + + // Require at least one filter criterion + if (!flags["client-id"] && !flags["device-id"]) { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: + "At least one filter criterion is required: --client-id or --device-id", + success: false, + }, + flags, + ), + ); + } else { + this.error( + "At least one filter criterion is required: --client-id or --device-id", + ); + } + + return; + } + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Build filter params + const params: Ably.DeviceRegistrationParams = {}; + + if (flags["client-id"]) { + params.clientId = flags["client-id"]; + } + + if (flags["device-id"]) { + params.deviceId = flags["device-id"]; + } + + // Build description of what will be removed + const filterDescription = this.buildFilterDescription(flags); + + // Confirm deletion unless --force is used + if (!flags.force && !this.shouldOutputJson(flags)) { + const { default: inquirer } = await import("inquirer"); + const { confirmed } = await inquirer.prompt([ + { + type: "confirm", + name: "confirmed", + message: `Are you sure you want to remove all devices ${filterDescription}?`, + default: false, + }, + ]); + + if (!confirmed) { + this.log("Operation cancelled."); + return; + } + } + + // Remove matching devices + await rest.push.admin.deviceRegistrations.removeWhere(params); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + filter: { + clientId: flags["client-id"], + deviceId: flags["device-id"], + }, + removed: true, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + chalk.green(`Devices removed successfully ${filterDescription}`), + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error removing devices: ${errorMessage}`); + } + } + } + + private buildFilterDescription(flags: Record): string { + const parts: string[] = []; + + if (flags["client-id"]) { + parts.push(`with client ID ${chalk.cyan(flags["client-id"] as string)}`); + } + + if (flags["device-id"]) { + parts.push(`with device ID ${chalk.cyan(flags["device-id"] as string)}`); + } + + return parts.join(" and "); + } +} diff --git a/src/commands/push/devices/remove.ts b/src/commands/push/devices/remove.ts new file mode 100644 index 00000000..d12b3ff2 --- /dev/null +++ b/src/commands/push/devices/remove.ts @@ -0,0 +1,102 @@ +import { Args, Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import chalk from "chalk"; + +export default class PushDevicesRemove extends AblyBaseCommand { + static override description = + "Remove a registered push notification device (maps to push.admin.deviceRegistrations.remove)"; + + static override examples = [ + "$ ably push devices remove DEVICE_ID", + "$ ably push devices remove my-device-123", + "$ ably push devices remove my-device-123 --force", + "$ ably push devices remove my-device-123 --json", + ]; + + static override args = { + deviceId: Args.string({ + description: "The device ID to remove", + required: true, + }), + }; + + static override flags = { + ...AblyBaseCommand.globalFlags, + force: Flags.boolean({ + char: "f", + default: false, + description: "Skip confirmation prompt", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PushDevicesRemove); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Confirm deletion unless --force is used + if (!flags.force && !this.shouldOutputJson(flags)) { + const { default: inquirer } = await import("inquirer"); + const { confirmed } = await inquirer.prompt([ + { + type: "confirm", + name: "confirmed", + message: `Are you sure you want to remove device ${chalk.cyan(args.deviceId)}?`, + default: false, + }, + ]); + + if (!confirmed) { + this.log("Operation cancelled."); + return; + } + } + + // Remove the device + await rest.push.admin.deviceRegistrations.remove(args.deviceId); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + deviceId: args.deviceId, + removed: true, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log(chalk.green(`Device removed successfully: ${args.deviceId}`)); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + if (errorCode === 40400) { + this.error(`Device not found: ${args.deviceId}`); + } else { + this.error(`Error removing device: ${errorMessage}`); + } + } + } + } +} diff --git a/src/commands/push/devices/save.ts b/src/commands/push/devices/save.ts new file mode 100644 index 00000000..07dd9e6c --- /dev/null +++ b/src/commands/push/devices/save.ts @@ -0,0 +1,355 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import * as Ably from "ably"; +import chalk from "chalk"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export default class PushDevicesSave extends AblyBaseCommand { + static override description = + "Register a new device or update an existing device registration (maps to push.admin.deviceRegistrations.save)"; + + static override examples = [ + // Android FCM + "$ ably push devices save --id my-device --platform android --form-factor phone --transport-type fcm --device-token FCM_REGISTRATION_TOKEN", + // iOS APNs + "$ ably push devices save --id my-device --platform ios --form-factor phone --transport-type apns --device-token APNS_DEVICE_TOKEN", + // Web Push (browser) + "$ ably push devices save --id my-device --platform browser --form-factor desktop --transport-type web --target-url https://fcm.googleapis.com/fcm/send/... --p256dh-key BASE64_P256DH_KEY --auth-secret BASE64_AUTH_SECRET", + // With client ID + "$ ably push devices save --id my-device --platform android --form-factor phone --transport-type fcm --device-token TOKEN --client-id user-123", + // From JSON file + "$ ably push devices save --data ./device.json", + // From inline JSON + '$ ably push devices save --data \'{"id":"device-1","platform":"android","formFactor":"phone","push":{"recipient":{"transportType":"fcm","registrationToken":"TOKEN"}}}\'', + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + id: Flags.string({ + description: "Unique device identifier", + }), + platform: Flags.string({ + description: "Device platform", + options: ["android", "ios", "browser"], + }), + "form-factor": Flags.string({ + description: "Device form factor", + options: [ + "phone", + "tablet", + "desktop", + "tv", + "watch", + "car", + "embedded", + "other", + ], + }), + "client-id": Flags.string({ + description: "Client ID to associate with the device", + }), + "transport-type": Flags.string({ + description: + "Push transport type (fcm for Android, apns for iOS, web for browsers)", + options: ["fcm", "apns", "web"], + }), + "device-token": Flags.string({ + description: + "Device token for APNs (iOS) or FCM registration token (Android). Not used for web push.", + }), + "target-url": Flags.string({ + description: + "Web push endpoint URL (from PushSubscription.endpoint). Required for web transport type.", + }), + "p256dh-key": Flags.string({ + description: + "Web push p256dh public key (from PushSubscription.getKey('p256dh'), base64 encoded). Required for web transport type.", + }), + "auth-secret": Flags.string({ + description: + "Web push auth secret (from PushSubscription.getKey('auth'), base64 encoded). Required for web transport type.", + }), + metadata: Flags.string({ + description: "Device metadata as JSON string", + }), + data: Flags.string({ + description: "Full device details as JSON string or path to JSON file", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushDevicesSave); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + let deviceDetails: Ably.DeviceDetails; + + if (flags.data) { + // Parse device details from JSON string or file + deviceDetails = this.parseDeviceData(flags.data); + } else { + // Build device details from individual flags + deviceDetails = this.buildDeviceDetails(flags); + } + + // Validate required fields + this.validateDeviceDetails(deviceDetails); + + // Save the device + const savedDevice = + await rest.push.admin.deviceRegistrations.save(deviceDetails); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + device: { + id: savedDevice.id, + platform: savedDevice.platform, + formFactor: savedDevice.formFactor, + clientId: savedDevice.clientId, + state: savedDevice.push?.state, + }, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + chalk.green(`Device registered successfully: ${savedDevice.id}`), + ); + this.log(""); + this.log(`${chalk.dim("Platform:")} ${savedDevice.platform}`); + this.log(`${chalk.dim("Form Factor:")} ${savedDevice.formFactor}`); + + if (savedDevice.clientId) { + this.log(`${chalk.dim("Client ID:")} ${savedDevice.clientId}`); + } + + if (savedDevice.push?.state) { + this.log( + `${chalk.dim("State:")} ${chalk.green(savedDevice.push.state)}`, + ); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error saving device: ${errorMessage}`); + } + } + } + + private parseDeviceData(data: string): Ably.DeviceDetails { + let jsonString = data; + + // Check if it's a file path + if (!data.trim().startsWith("{")) { + const filePath = path.resolve(data); + + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + jsonString = fs.readFileSync(filePath, "utf8"); + } + + try { + return JSON.parse(jsonString) as Ably.DeviceDetails; + } catch { + throw new Error("Invalid JSON format in --data"); + } + } + + private buildDeviceDetails( + flags: Record, + ): Ably.DeviceDetails { + if (!flags.id) { + throw new Error("--id is required when not using --data"); + } + + if (!flags.platform) { + throw new Error("--platform is required when not using --data"); + } + + if (!flags["form-factor"]) { + throw new Error("--form-factor is required when not using --data"); + } + + if (!flags["transport-type"]) { + throw new Error("--transport-type is required when not using --data"); + } + + const transportType = flags["transport-type"] as string; + + // Validate transport-specific required flags + if (transportType === "web") { + if (!flags["target-url"]) { + throw new Error("--target-url is required for web transport type"); + } + + if (!flags["p256dh-key"]) { + throw new Error("--p256dh-key is required for web transport type"); + } + + if (!flags["auth-secret"]) { + throw new Error("--auth-secret is required for web transport type"); + } + } else { + if (!flags["device-token"]) { + throw new Error( + "--device-token is required for fcm and apns transport types", + ); + } + } + + const device: Ably.DeviceDetails = { + id: flags.id as string, + platform: flags.platform as Ably.DevicePlatform, + formFactor: flags["form-factor"] as Ably.DeviceFormFactor, + push: { + recipient: this.buildRecipient(transportType, flags), + }, + }; + + if (flags["client-id"]) { + device.clientId = flags["client-id"] as string; + } + + if (flags.metadata) { + try { + device.metadata = JSON.parse(flags.metadata as string); + } catch { + throw new Error("Invalid JSON format in --metadata"); + } + } + + return device; + } + + private buildRecipient( + transportType: string, + flags: Record, + ): Record { + const recipient: Record = { + transportType, + }; + + switch (transportType) { + case "fcm": { + recipient.registrationToken = flags["device-token"] as string; + break; + } + + case "apns": { + recipient.deviceToken = flags["device-token"] as string; + break; + } + + case "web": { + // Web push requires targetUrl and encryptionKey object with p256dh and auth + recipient.targetUrl = flags["target-url"] as string; + recipient.encryptionKey = { + p256dh: flags["p256dh-key"] as string, + auth: flags["auth-secret"] as string, + }; + break; + } + + default: { + throw new Error(`Unsupported transport type: ${transportType}`); + } + } + + return recipient; + } + + private validateDeviceDetails(device: Ably.DeviceDetails): void { + if (!device.id) { + throw new Error("Device ID is required"); + } + + if (!device.platform) { + throw new Error("Device platform is required"); + } + + if (!device.formFactor) { + throw new Error("Device form factor is required"); + } + + if (!device.push?.recipient) { + throw new Error("Push recipient configuration is required"); + } + + const recipient = device.push.recipient as Record; + + if (!recipient.transportType) { + throw new Error("Transport type is required in push recipient"); + } + + // Validate transport-specific fields + switch (recipient.transportType) { + case "fcm": { + if (!recipient.registrationToken) { + throw new Error("FCM registrationToken is required"); + } + + break; + } + + case "apns": { + if (!recipient.deviceToken) { + throw new Error("APNs deviceToken is required"); + } + + break; + } + + case "web": { + if (!recipient.targetUrl) { + throw new Error("Web push targetUrl is required"); + } + + if (!recipient.encryptionKey) { + throw new Error("Web push encryptionKey is required"); + } + + const encryptionKey = recipient.encryptionKey as Record< + string, + unknown + >; + + if (!encryptionKey.p256dh) { + throw new Error("Web push encryptionKey.p256dh is required"); + } + + if (!encryptionKey.auth) { + throw new Error("Web push encryptionKey.auth is required"); + } + + break; + } + } + } +} diff --git a/src/commands/push/index.ts b/src/commands/push/index.ts new file mode 100644 index 00000000..c2841c14 --- /dev/null +++ b/src/commands/push/index.ts @@ -0,0 +1,16 @@ +import { BaseTopicCommand } from "../../base-topic-command.js"; + +export default class Push extends BaseTopicCommand { + protected topicName = "push"; + protected commandGroup = "push notification"; + + static override description = + "Manage push notifications, device registrations, and channel subscriptions"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> devices list", + "<%= config.bin %> <%= command.id %> devices save --id my-device --platform android --form-factor phone --transport-type fcm --device-token TOKEN", + "<%= config.bin %> <%= command.id %> channels save --channel alerts --device-id my-device", + '<%= config.bin %> <%= command.id %> publish --device-id my-device --title "Hello" --body "World"', + ]; +} diff --git a/test/e2e/push/devices-e2e.test.ts b/test/e2e/push/devices-e2e.test.ts new file mode 100644 index 00000000..a30d0fd6 --- /dev/null +++ b/test/e2e/push/devices-e2e.test.ts @@ -0,0 +1,540 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import * as Ably from "ably"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, + createAblyClient, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe("Push Devices E2E Tests", () => { + // Skip all tests if API key not available + let testDeviceIdBase: string; + let client: Ably.Rest; + + beforeAll(async () => { + if (SHOULD_SKIP_E2E) { + return; + } + + process.on("SIGINT", forceExit); + + // Generate unique device ID base for this test run + testDeviceIdBase = `cli-e2e-test-${Date.now()}`; + + // Create Ably client for verification + client = createAblyClient(); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + // Clear tracked commands and output files before each test + resetTestTracking(); + }); + + afterEach(async () => { + // Set up failure handler for debug output + setupTestFailureHandler(); + await cleanupTrackedResources(); + }); + + describe("push devices save", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should register an Android device with FCM token", + async () => { + const deviceId = `${testDeviceIdBase}-android-save`; + const fakeToken = `fake-fcm-token-${Date.now()}`; + + const result = await runCommand( + [ + "push", + "devices", + "save", + "--id", + deviceId, + "--platform", + "android", + "--form-factor", + "phone", + "--transport-type", + "fcm", + "--device-token", + fakeToken, + "--client-id", + "e2e-test-user", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Device registered successfully"); + expect(result.stdout).toContain(deviceId); + expect(result.stdout).toContain("android"); + + // Verify with SDK + const device = + await client.push.admin.deviceRegistrations.get(deviceId); + expect(device.id).toBe(deviceId); + expect(device.platform).toBe("android"); + expect(device.formFactor).toBe("phone"); + expect(device.clientId).toBe("e2e-test-user"); + + // Cleanup + await client.push.admin.deviceRegistrations.remove(deviceId); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should register an iOS device with APNs token", + async () => { + const deviceId = `${testDeviceIdBase}-ios-save`; + const fakeToken = `fake-apns-token-${Date.now()}`; + + const result = await runCommand( + [ + "push", + "devices", + "save", + "--id", + deviceId, + "--platform", + "ios", + "--form-factor", + "tablet", + "--transport-type", + "apns", + "--device-token", + fakeToken, + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Device registered successfully"); + expect(result.stdout).toContain("ios"); + expect(result.stdout).toContain("tablet"); + + // Cleanup + await client.push.admin.deviceRegistrations.remove(deviceId); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should output JSON when --json flag is used", + async () => { + const deviceId = `${testDeviceIdBase}-json-save`; + const fakeToken = `fake-fcm-token-json-${Date.now()}`; + + const result = await runCommand( + [ + "push", + "devices", + "save", + "--id", + deviceId, + "--platform", + "android", + "--form-factor", + "phone", + "--transport-type", + "fcm", + "--device-token", + fakeToken, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.device).toBeDefined(); + expect(json.device.id).toBe(deviceId); + expect(json.device.platform).toBe("android"); + + // Cleanup + await client.push.admin.deviceRegistrations.remove(deviceId); + }, + ); + }); + + describe("push devices get", () => { + let testDeviceId: string; + + beforeAll(async () => { + if (SHOULD_SKIP_E2E) return; + + // Create a test device for get tests + testDeviceId = `${testDeviceIdBase}-get-test`; + await client.push.admin.deviceRegistrations.save({ + id: testDeviceId, + platform: "android", + formFactor: "phone", + clientId: "e2e-get-test-user", + push: { + recipient: { + transportType: "fcm", + registrationToken: `fake-token-get-${Date.now()}`, + }, + }, + }); + }); + + afterAll(async () => { + if (SHOULD_SKIP_E2E) return; + + // Cleanup test device + try { + await client.push.admin.deviceRegistrations.remove(testDeviceId); + } catch { + // Ignore cleanup errors + } + }); + + it.skipIf(SHOULD_SKIP_E2E)("should get device details by ID", async () => { + const result = await runCommand( + ["push", "devices", "get", testDeviceId], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Device Details"); + expect(result.stdout).toContain(testDeviceId); + expect(result.stdout).toContain("android"); + expect(result.stdout).toContain("phone"); + expect(result.stdout).toContain("e2e-get-test-user"); + }); + + it.skipIf(SHOULD_SKIP_E2E)( + "should output JSON when --json flag is used", + async () => { + const result = await runCommand( + ["push", "devices", "get", testDeviceId, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.device).toBeDefined(); + expect(json.device.id).toBe(testDeviceId); + expect(json.device.platform).toBe("android"); + expect(json.device.clientId).toBe("e2e-get-test-user"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should handle non-existent device", + async () => { + const result = await runCommand( + ["push", "devices", "get", "non-existent-device-12345"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Command should fail with non-zero exit code + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("not found"); + }, + ); + }); + + describe("push devices list", () => { + const listTestDevices: string[] = []; + + beforeAll(async () => { + if (SHOULD_SKIP_E2E) return; + + // Create multiple test devices for list tests + for (let i = 0; i < 3; i++) { + const deviceId = `${testDeviceIdBase}-list-${i}`; + listTestDevices.push(deviceId); + + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: i % 2 === 0 ? "android" : "ios", + formFactor: "phone", + clientId: "e2e-list-test-user", + push: { + recipient: { + transportType: i % 2 === 0 ? "fcm" : "apns", + ...(i % 2 === 0 + ? { registrationToken: `fake-fcm-list-${i}-${Date.now()}` } + : { deviceToken: `fake-apns-list-${i}-${Date.now()}` }), + }, + }, + }); + } + }); + + afterAll(async () => { + if (SHOULD_SKIP_E2E) return; + + // Cleanup test devices + for (const deviceId of listTestDevices) { + try { + await client.push.admin.deviceRegistrations.remove(deviceId); + } catch { + // Ignore cleanup errors + } + } + }); + + it.skipIf(SHOULD_SKIP_E2E)("should list devices", async () => { + const result = await runCommand(["push", "devices", "list"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Found"); + expect(result.stdout).toContain("device"); + }); + + it.skipIf(SHOULD_SKIP_E2E)("should filter by client ID", async () => { + const result = await runCommand( + ["push", "devices", "list", "--client-id", "e2e-list-test-user"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + // All our test devices have this client ID + for (const deviceId of listTestDevices) { + expect(result.stdout).toContain(deviceId); + } + }); + + it.skipIf(SHOULD_SKIP_E2E)( + "should output JSON when --json flag is used", + async () => { + const result = await runCommand( + [ + "push", + "devices", + "list", + "--client-id", + "e2e-list-test-user", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.devices).toBeInstanceOf(Array); + expect(json.devices.length).toBeGreaterThanOrEqual(3); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)("should respect --limit flag", async () => { + const result = await runCommand( + [ + "push", + "devices", + "list", + "--client-id", + "e2e-list-test-user", + "--limit", + "2", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.devices.length).toBeLessThanOrEqual(2); + }); + }); + + describe("push devices remove", () => { + it.skipIf(SHOULD_SKIP_E2E)("should remove a device by ID", async () => { + const deviceId = `${testDeviceIdBase}-remove-test`; + + // First create a device + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: "android", + formFactor: "phone", + push: { + recipient: { + transportType: "fcm", + registrationToken: `fake-remove-${Date.now()}`, + }, + }, + }); + + // Remove using CLI with --force to skip confirmation + const result = await runCommand( + ["push", "devices", "remove", deviceId, "--force"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("removed successfully"); + expect(result.stdout).toContain(deviceId); + + // Verify device is gone + await expect( + client.push.admin.deviceRegistrations.get(deviceId), + ).rejects.toMatchObject({ code: 40400 }); + }); + + it.skipIf(SHOULD_SKIP_E2E)( + "should output JSON when --json flag is used", + async () => { + const deviceId = `${testDeviceIdBase}-remove-json`; + + // First create a device + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: "ios", + formFactor: "phone", + push: { + recipient: { + transportType: "apns", + deviceToken: `fake-remove-json-${Date.now()}`, + }, + }, + }); + + const result = await runCommand( + ["push", "devices", "remove", deviceId, "--force", "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.removed).toBe(true); + expect(json.deviceId).toBe(deviceId); + }, + ); + }); + + describe("push devices remove-where", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should remove devices by client ID", + async () => { + const clientIdForRemoval = `e2e-remove-where-${Date.now()}`; + const deviceIds: string[] = []; + + // Create multiple devices with same client ID + for (let i = 0; i < 2; i++) { + const deviceId = `${testDeviceIdBase}-remove-where-${i}`; + deviceIds.push(deviceId); + + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: "android", + formFactor: "phone", + clientId: clientIdForRemoval, + push: { + recipient: { + transportType: "fcm", + registrationToken: `fake-remove-where-${i}-${Date.now()}`, + }, + }, + }); + } + + // Remove using CLI + const result = await runCommand( + [ + "push", + "devices", + "remove-where", + "--client-id", + clientIdForRemoval, + "--force", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("removed successfully"); + + // Verify all devices are gone + for (const deviceId of deviceIds) { + await expect( + client.push.admin.deviceRegistrations.get(deviceId), + ).rejects.toMatchObject({ code: 40400 }); + } + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should require at least one filter criterion", + async () => { + const result = await runCommand( + ["push", "devices", "remove-where", "--force"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "At least one filter criterion is required", + ); + }, + ); + }); +}); From 638b90540c70f53ae338f8393a79527cd79f2255 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 6 Jan 2026 18:40:17 +0100 Subject: [PATCH 02/20] feat(push): Add channel subscription commands for push notifications Implement Stage 2 of push notification support with full channel subscription management using the Ably SDK's push.admin.channelSubscriptions API. Commands added: - push channels save: Subscribe device or client to a push channel - push channels list: List subscriptions for a channel with filters - push channels list-channels: List all channels with push subscriptions - push channels remove: Remove single subscription - push channels remove-where: Bulk remove subscriptions by filter Features: - Subscribe by device ID or client ID (not both) - Filter subscriptions by deviceId or clientId - JSON output mode with --json flag - Confirmation prompts for destructive operations (--force to skip) - Helpful error messages for push-not-enabled (40100) errors Note: Full integration tests require a push-enabled channel namespace which is not available in the standard test environment. Validation tests verify CLI command structure and error handling. Part of #124 --- src/commands/push/channels/index.ts | 16 ++ src/commands/push/channels/list-channels.ts | 95 ++++++++ src/commands/push/channels/list.ts | 129 +++++++++++ src/commands/push/channels/remove-where.ts | 135 ++++++++++++ src/commands/push/channels/remove.ts | 141 ++++++++++++ src/commands/push/channels/save.ts | 126 +++++++++++ test/e2e/push/channels-e2e.test.ts | 230 ++++++++++++++++++++ 7 files changed, 872 insertions(+) create mode 100644 src/commands/push/channels/index.ts create mode 100644 src/commands/push/channels/list-channels.ts create mode 100644 src/commands/push/channels/list.ts create mode 100644 src/commands/push/channels/remove-where.ts create mode 100644 src/commands/push/channels/remove.ts create mode 100644 src/commands/push/channels/save.ts create mode 100644 test/e2e/push/channels-e2e.test.ts diff --git a/src/commands/push/channels/index.ts b/src/commands/push/channels/index.ts new file mode 100644 index 00000000..abccb38e --- /dev/null +++ b/src/commands/push/channels/index.ts @@ -0,0 +1,16 @@ +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class PushChannels extends BaseTopicCommand { + protected topicName = "push:channels"; + protected commandGroup = "push channel subscription"; + + static override description = + "Manage push notification channel subscriptions (maps to push.admin.channelSubscriptions)"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> save --channel alerts --device-id my-device", + "<%= config.bin %> <%= command.id %> list --channel alerts", + "<%= config.bin %> <%= command.id %> list-channels", + "<%= config.bin %> <%= command.id %> remove --channel alerts --device-id my-device", + ]; +} diff --git a/src/commands/push/channels/list-channels.ts b/src/commands/push/channels/list-channels.ts new file mode 100644 index 00000000..360733cc --- /dev/null +++ b/src/commands/push/channels/list-channels.ts @@ -0,0 +1,95 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import chalk from "chalk"; + +export default class PushChannelsListChannels extends AblyBaseCommand { + static override description = + "List all channels that have at least one push subscription (maps to push.admin.channelSubscriptions.listChannels)"; + + static override examples = [ + // List all channels with push subscriptions + "$ ably push channels list-channels", + // With limit + "$ ably push channels list-channels --limit 50", + // JSON output + "$ ably push channels list-channels --json", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + limit: Flags.integer({ + description: "Maximum number of results (default: 100, max: 1000)", + default: 100, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushChannelsListChannels); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // List channels with push subscriptions + const result = await rest.push.admin.channelSubscriptions.listChannels({ + limit: Math.min(flags.limit, 1000), + }); + const channels = result.items; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + channels: channels, + count: channels.length, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + chalk.bold( + `Channels with Push Subscriptions (${channels.length} found)\n`, + ), + ); + + if (channels.length === 0) { + this.log(chalk.dim("No channels with push subscriptions found.")); + return; + } + + // Table header + this.log(chalk.dim("CHANNEL")); + this.log(chalk.dim("-".repeat(50))); + + // List channels + for (const channel of channels) { + this.log(channel); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error listing channels: ${errorMessage}`); + } + } + } +} diff --git a/src/commands/push/channels/list.ts b/src/commands/push/channels/list.ts new file mode 100644 index 00000000..55ab661f --- /dev/null +++ b/src/commands/push/channels/list.ts @@ -0,0 +1,129 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import chalk from "chalk"; + +export default class PushChannelsList extends AblyBaseCommand { + static override description = + "List push channel subscriptions (maps to push.admin.channelSubscriptions.list)"; + + static override examples = [ + // List all subscriptions for a channel + "$ ably push channels list --channel alerts", + // Filter by device ID + "$ ably push channels list --channel alerts --device-id my-device-123", + // Filter by client ID + "$ ably push channels list --channel alerts --client-id user-456", + // With limit + "$ ably push channels list --channel alerts --limit 50", + // JSON output + "$ ably push channels list --channel alerts --json", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + channel: Flags.string({ + description: "Channel name to list subscriptions for", + required: true, + }), + "device-id": Flags.string({ + description: "Filter by device ID", + }), + "client-id": Flags.string({ + description: "Filter by client ID", + }), + limit: Flags.integer({ + description: "Maximum number of results (default: 100, max: 1000)", + default: 100, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushChannelsList); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Build filter params + const params: Record = { + channel: flags.channel, + limit: Math.min(flags.limit, 1000), + }; + + if (flags["device-id"]) { + params.deviceId = flags["device-id"]; + } + + if (flags["client-id"]) { + params.clientId = flags["client-id"]; + } + + // List subscriptions + const result = await rest.push.admin.channelSubscriptions.list(params); + const subscriptions = result.items; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + subscriptions: subscriptions.map((sub) => ({ + channel: sub.channel, + deviceId: sub.deviceId, + clientId: sub.clientId, + })), + count: subscriptions.length, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + chalk.bold( + `Push Subscriptions for channel "${flags.channel}" (${subscriptions.length} found)\n`, + ), + ); + + if (subscriptions.length === 0) { + this.log(chalk.dim("No subscriptions found.")); + return; + } + + // Table header + this.log(`${chalk.dim("TYPE".padEnd(12))}${chalk.dim("ID")}`); + this.log(chalk.dim("-".repeat(60))); + + // List subscriptions + for (const sub of subscriptions) { + if (sub.deviceId) { + this.log(`${"device".padEnd(12)}${sub.deviceId}`); + } else if (sub.clientId) { + this.log(`${"client".padEnd(12)}${sub.clientId}`); + } + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error listing subscriptions: ${errorMessage}`); + } + } + } +} diff --git a/src/commands/push/channels/remove-where.ts b/src/commands/push/channels/remove-where.ts new file mode 100644 index 00000000..405b20d6 --- /dev/null +++ b/src/commands/push/channels/remove-where.ts @@ -0,0 +1,135 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import chalk from "chalk"; + +export default class PushChannelsRemoveWhere extends AblyBaseCommand { + static override description = + "Remove push channel subscriptions matching filter criteria (maps to push.admin.channelSubscriptions.removeWhere)"; + + static override examples = [ + // Remove all subscriptions for a device on a channel + "$ ably push channels remove-where --channel alerts --device-id my-device-123 --force", + // Remove all subscriptions for a client on a channel + "$ ably push channels remove-where --channel alerts --client-id user-456 --force", + // JSON output + "$ ably push channels remove-where --channel alerts --device-id my-device-123 --json --force", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + channel: Flags.string({ + description: "Channel to remove subscriptions from", + required: true, + }), + "device-id": Flags.string({ + description: "Filter by device ID", + }), + "client-id": Flags.string({ + description: "Filter by client ID", + }), + force: Flags.boolean({ + char: "f", + default: false, + description: "Skip confirmation prompt", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushChannelsRemoveWhere); + + // Validate that at least one filter is provided + if (!flags["device-id"] && !flags["client-id"]) { + this.error( + "At least one filter criterion (--device-id or --client-id) is required to prevent accidentally removing all subscriptions", + ); + } + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Build filter description for confirmation + const filters: string[] = [`channel=${flags.channel}`]; + if (flags["device-id"]) filters.push(`deviceId=${flags["device-id"]}`); + if (flags["client-id"]) filters.push(`clientId=${flags["client-id"]}`); + const filterDescription = filters.join(", "); + + // Confirm deletion unless --force is used + if (!flags.force && !this.shouldOutputJson(flags)) { + const { default: inquirer } = await import("inquirer"); + const { confirmed } = await inquirer.prompt([ + { + type: "confirm", + name: "confirmed", + message: `Are you sure you want to remove all subscriptions matching: ${chalk.cyan(filterDescription)}?`, + default: false, + }, + ]); + + if (!confirmed) { + this.log("Operation cancelled."); + return; + } + } + + // Build params for removeWhere + const params: Record = { + channel: flags.channel, + }; + + if (flags["device-id"]) { + params.deviceId = flags["device-id"]; + } + + if (flags["client-id"]) { + params.clientId = flags["client-id"]; + } + + // Remove matching subscriptions + await rest.push.admin.channelSubscriptions.removeWhere(params); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + channel: flags.channel, + deviceId: flags["device-id"], + clientId: flags["client-id"], + removed: true, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + chalk.green( + `Subscriptions removed successfully matching: ${filterDescription}`, + ), + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error removing subscriptions: ${errorMessage}`); + } + } + } +} diff --git a/src/commands/push/channels/remove.ts b/src/commands/push/channels/remove.ts new file mode 100644 index 00000000..0551f6b0 --- /dev/null +++ b/src/commands/push/channels/remove.ts @@ -0,0 +1,141 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import * as Ably from "ably"; +import chalk from "chalk"; + +export default class PushChannelsRemove extends AblyBaseCommand { + static override description = + "Remove a push channel subscription (maps to push.admin.channelSubscriptions.remove)"; + + static override examples = [ + // Remove device subscription + "$ ably push channels remove --channel alerts --device-id my-device-123", + // Remove client subscription + "$ ably push channels remove --channel alerts --client-id user-456", + // With force flag + "$ ably push channels remove --channel alerts --device-id my-device-123 --force", + // JSON output + "$ ably push channels remove --channel alerts --device-id my-device-123 --json", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + channel: Flags.string({ + description: "Channel to unsubscribe from", + required: true, + }), + "device-id": Flags.string({ + description: "Device ID to unsubscribe", + }), + "client-id": Flags.string({ + description: "Client ID to unsubscribe", + }), + force: Flags.boolean({ + char: "f", + default: false, + description: "Skip confirmation prompt", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushChannelsRemove); + + // Validate that either device-id or client-id is provided + if (!flags["device-id"] && !flags["client-id"]) { + this.error("Either --device-id or --client-id must be specified"); + } + + if (flags["device-id"] && flags["client-id"]) { + this.error( + "Only one of --device-id or --client-id can be specified, not both", + ); + } + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + const subscriberId = flags["device-id"] || flags["client-id"]; + const subscriberType = flags["device-id"] ? "device" : "client"; + + // Confirm deletion unless --force is used + if (!flags.force && !this.shouldOutputJson(flags)) { + const { default: inquirer } = await import("inquirer"); + const { confirmed } = await inquirer.prompt([ + { + type: "confirm", + name: "confirmed", + message: `Are you sure you want to unsubscribe ${subscriberType} ${chalk.cyan(subscriberId)} from channel ${chalk.cyan(flags.channel)}?`, + default: false, + }, + ]); + + if (!confirmed) { + this.log("Operation cancelled."); + return; + } + } + + // Build subscription object for removal + const subscription: Ably.PushChannelSubscription = { + channel: flags.channel, + }; + + if (flags["device-id"]) { + subscription.deviceId = flags["device-id"]; + } else if (flags["client-id"]) { + subscription.clientId = flags["client-id"]; + } + + // Remove the subscription + await rest.push.admin.channelSubscriptions.remove(subscription); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + channel: flags.channel, + deviceId: flags["device-id"], + clientId: flags["client-id"], + removed: true, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + chalk.green( + `Subscription removed successfully: ${subscriberType} ${subscriberId} from channel ${flags.channel}`, + ), + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + if (errorCode === 40400) { + this.error(`Subscription not found`); + } else { + this.error(`Error removing subscription: ${errorMessage}`); + } + } + } + } +} diff --git a/src/commands/push/channels/save.ts b/src/commands/push/channels/save.ts new file mode 100644 index 00000000..15ba1a6f --- /dev/null +++ b/src/commands/push/channels/save.ts @@ -0,0 +1,126 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../../base-command.js"; +import * as Ably from "ably"; +import chalk from "chalk"; + +export default class PushChannelsSave extends AblyBaseCommand { + static override description = + "Subscribe a device or client to a push-enabled channel (maps to push.admin.channelSubscriptions.save)"; + + static override examples = [ + // Subscribe by device ID + "$ ably push channels save --channel alerts --device-id my-device-123", + // Subscribe by client ID (subscribes all client's devices) + "$ ably push channels save --channel notifications --client-id user-456", + // With JSON output + "$ ably push channels save --channel alerts --device-id my-device-123 --json", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + channel: Flags.string({ + description: "Channel name to subscribe to", + required: true, + }), + "device-id": Flags.string({ + description: "Device ID to subscribe", + }), + "client-id": Flags.string({ + description: + "Client ID to subscribe (subscribes all of the client's devices)", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushChannelsSave); + + // Validate that either device-id or client-id is provided + if (!flags["device-id"] && !flags["client-id"]) { + this.error("Either --device-id or --client-id must be specified"); + } + + if (flags["device-id"] && flags["client-id"]) { + this.error( + "Only one of --device-id or --client-id can be specified, not both", + ); + } + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Build subscription object + const subscription: Ably.PushChannelSubscription = { + channel: flags.channel, + }; + + if (flags["device-id"]) { + subscription.deviceId = flags["device-id"]; + } else if (flags["client-id"]) { + subscription.clientId = flags["client-id"]; + } + + // Save the subscription + const savedSubscription = + await rest.push.admin.channelSubscriptions.save(subscription); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + subscription: { + channel: savedSubscription.channel, + deviceId: savedSubscription.deviceId, + clientId: savedSubscription.clientId, + }, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + const subscriberId = flags["device-id"] + ? `device ${chalk.cyan(flags["device-id"])}` + : `client ${chalk.cyan(flags["client-id"])}`; + + this.log( + chalk.green( + `Successfully subscribed ${subscriberId} to channel ${chalk.cyan(flags.channel)}`, + ), + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + if (errorCode === 40100) { + this.error( + `Push not enabled for this channel namespace. Configure push rules in the Ably dashboard.`, + ); + } else if (errorCode === 40400) { + this.error( + `Device or client not found. Ensure the device/client is registered first.`, + ); + } else { + this.error(`Error saving subscription: ${errorMessage}`); + } + } + } + } +} diff --git a/test/e2e/push/channels-e2e.test.ts b/test/e2e/push/channels-e2e.test.ts new file mode 100644 index 00000000..0999d61e --- /dev/null +++ b/test/e2e/push/channels-e2e.test.ts @@ -0,0 +1,230 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import * as Ably from "ably"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, + createAblyClient, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe("Push Channel Subscriptions E2E Tests", () => { + let testDeviceIdBase: string; + let client: Ably.Rest; + let testDeviceId: string; + + beforeAll(async () => { + if (SHOULD_SKIP_E2E) { + return; + } + + process.on("SIGINT", forceExit); + + // Generate unique device ID base for this test run + testDeviceIdBase = `cli-e2e-channel-test-${Date.now()}`; + testDeviceId = `${testDeviceIdBase}-device`; + + // Create Ably client for verification + client = createAblyClient(); + + // Create a test device for subscription tests + await client.push.admin.deviceRegistrations.save({ + id: testDeviceId, + platform: "android", + formFactor: "phone", + clientId: "e2e-channel-test-user", + push: { + recipient: { + transportType: "fcm", + registrationToken: `fake-fcm-channel-test-${Date.now()}`, + }, + }, + }); + }); + + afterAll(async () => { + if (SHOULD_SKIP_E2E) { + return; + } + + // Cleanup test device + try { + await client.push.admin.deviceRegistrations.remove(testDeviceId); + } catch { + // Ignore cleanup errors + } + + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + setupTestFailureHandler(); + await cleanupTrackedResources(); + }); + + describe("push channels save - validation", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should error when neither device-id nor client-id provided", + async () => { + const result = await runCommand( + ["push", "channels", "save", "--channel", "test-channel"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Either --device-id or --client-id must be specified", + ); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error when both device-id and client-id provided", + async () => { + const result = await runCommand( + [ + "push", + "channels", + "save", + "--channel", + "test-channel", + "--device-id", + "device1", + "--client-id", + "client1", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Only one of --device-id or --client-id can be specified", + ); + }, + ); + }); + + describe("push channels list - validation", () => { + it.skipIf(SHOULD_SKIP_E2E)("should require --channel flag", async () => { + const result = await runCommand(["push", "channels", "list"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Missing required flag channel"); + }); + }); + + describe("push channels list-channels", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should list channels (may be empty if no push subscriptions)", + async () => { + const result = await runCommand(["push", "channels", "list-channels"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Channels with Push Subscriptions"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should output JSON when --json flag is used", + async () => { + const result = await runCommand( + ["push", "channels", "list-channels", "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.channels).toBeInstanceOf(Array); + }, + ); + }); + + describe("push channels remove - validation", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should error when neither device-id nor client-id provided", + async () => { + const result = await runCommand( + [ + "push", + "channels", + "remove", + "--channel", + "test-channel", + "--force", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Either --device-id or --client-id must be specified", + ); + }, + ); + }); + + describe("push channels remove-where - validation", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should require at least one filter criterion", + async () => { + const result = await runCommand( + [ + "push", + "channels", + "remove-where", + "--channel", + "test-channel", + "--force", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("At least one filter criterion"); + }, + ); + }); + + // Note: Tests that require push-enabled channels are not included here + // because the test environment doesn't have push configured for channels. + // The validation tests above verify the CLI command structure is correct. + // Full integration tests would require a push-enabled namespace configuration. +}); From 19bdfba53d335ecf8a924afa9b79829092a0e7dc Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 6 Jan 2026 19:16:53 +0100 Subject: [PATCH 03/20] feat(push): add publish and batch-publish commands Add push notification publishing commands: - `push publish` - Publish notification to device or client - Supports --device-id or --client-id targeting - Simple flags: --title, --body, --sound, --icon, --badge - Custom data via --data, full control via --payload - Platform-specific overrides: --apns, --fcm, --web - `push batch-publish` - Publish up to 10,000 notifications - Accepts JSON array via --payload (file, inline, or stdin) - Reports success/failure counts per notification --- src/commands/push/batch-publish.ts | 240 +++++++++++++++++ src/commands/push/publish.ts | 283 ++++++++++++++++++++ test/e2e/push/publish-e2e.test.ts | 401 +++++++++++++++++++++++++++++ 3 files changed, 924 insertions(+) create mode 100644 src/commands/push/batch-publish.ts create mode 100644 src/commands/push/publish.ts create mode 100644 test/e2e/push/publish-e2e.test.ts diff --git a/src/commands/push/batch-publish.ts b/src/commands/push/batch-publish.ts new file mode 100644 index 00000000..5187e843 --- /dev/null +++ b/src/commands/push/batch-publish.ts @@ -0,0 +1,240 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../base-command.js"; +import chalk from "chalk"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +interface BatchItem { + recipient: { + deviceId?: string; + clientId?: string; + }; + payload: Record; +} + +export default class PushBatchPublish extends AblyBaseCommand { + static override description = + "Publish push notifications to multiple recipients in a single request (up to 10,000 notifications)"; + + static override examples = [ + // From JSON file + "$ ably push batch-publish --payload ./batch-notifications.json", + // From inline JSON + '$ ably push batch-publish --payload \'[{"recipient":{"deviceId":"abc"},"payload":{"notification":{"title":"Hi"}}}]\'', + // From stdin + "$ cat batch.json | ably push batch-publish --payload -", + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + payload: Flags.string({ + description: + "Batch payload as JSON array, file path, or - for stdin (required)", + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushBatchPublish); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Parse batch payload + const batchItems = await this.parseBatchPayload(flags.payload); + + // Validate batch items + this.validateBatchItems(batchItems); + + if (batchItems.length > 10000) { + this.error("Batch size exceeds maximum of 10,000 notifications"); + } + + if (batchItems.length === 0) { + this.error("Batch payload is empty"); + } + + // Publish batch using REST API directly + // The SDK's push.admin.publish doesn't have a batch method, + // so we use the REST API directly + const response = await rest.request( + "POST", + "/push/batch/publish", + 2, // API version + {}, + batchItems, + {}, + ); + + // Process response + const results = response.items || []; + const successful = results.filter( + (r: Record) => !r.error, + ).length; + const failed = results.filter( + (r: Record) => r.error, + ).length; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + total: batchItems.length, + successful, + failed, + results: results.map((r: Record, i: number) => ({ + index: i, + success: !r.error, + error: r.error, + })), + success: failed === 0, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log(chalk.bold("Batch Push Results\n")); + this.log(`${chalk.dim("Total:")} ${batchItems.length}`); + this.log( + `${chalk.dim("Successful:")} ${chalk.green(successful.toString())}`, + ); + + if (failed > 0) { + this.log( + `${chalk.dim("Failed:")} ${chalk.red(failed.toString())}`, + ); + this.log(""); + this.log(chalk.yellow("Failed Notifications:")); + + results.forEach((r: Record, i: number) => { + if (r.error) { + const error = r.error as Record; + const recipient = batchItems[i]?.recipient; + const recipientStr = recipient?.deviceId + ? `deviceId=${recipient.deviceId}` + : recipient?.clientId + ? `clientId=${recipient.clientId}` + : "unknown"; + + this.log(` - Recipient: ${recipientStr}`); + this.log( + ` Error: ${error.message || "Unknown error"} (code: ${error.code || "unknown"})`, + ); + } + }); + } else { + this.log(""); + this.log(chalk.green("All notifications sent successfully!")); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + this.error(`Error publishing batch notifications: ${errorMessage}`); + } + } + } + + private async parseBatchPayload(payload: string): Promise { + let jsonString: string; + + if (payload === "-") { + // Read from stdin + jsonString = await this.readStdin(); + } else if ( + !payload.trim().startsWith("[") && + !payload.trim().startsWith("{") + ) { + // It's a file path + const filePath = path.resolve(payload); + + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + jsonString = fs.readFileSync(filePath, "utf8"); + } else { + jsonString = payload; + } + + try { + const parsed = JSON.parse(jsonString); + + // Ensure it's an array + if (!Array.isArray(parsed)) { + throw new TypeError("Batch payload must be a JSON array"); + } + + return parsed as BatchItem[]; + } catch (error) { + if (error instanceof SyntaxError) { + throw new TypeError("Invalid JSON format in batch payload"); + } + + throw error; + } + } + + private async readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + + process.stdin.setEncoding("utf8"); + + process.stdin.on("data", (chunk) => { + data += chunk; + }); + + process.stdin.on("end", () => { + resolve(data); + }); + + process.stdin.on("error", (err) => { + reject(err); + }); + + // Set a timeout for stdin reading + setTimeout(() => { + if (data === "") { + reject(new Error("No data received from stdin")); + } + }, 5000); + }); + } + + private validateBatchItems(items: BatchItem[]): void { + items.forEach((item, index) => { + if (!item.recipient) { + throw new Error(`Item ${index}: missing 'recipient' field`); + } + + if (!item.recipient.deviceId && !item.recipient.clientId) { + throw new Error( + `Item ${index}: recipient must have either 'deviceId' or 'clientId'`, + ); + } + + if (!item.payload) { + throw new Error(`Item ${index}: missing 'payload' field`); + } + }); + } +} diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts new file mode 100644 index 00000000..5b0b0583 --- /dev/null +++ b/src/commands/push/publish.ts @@ -0,0 +1,283 @@ +import { Flags } from "@oclif/core"; +import { AblyBaseCommand } from "../../base-command.js"; +import chalk from "chalk"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export default class PushPublish extends AblyBaseCommand { + static override description = + "Publish a push notification directly to device(s) or client(s) (maps to push.admin.publish)"; + + static override examples = [ + // Simple notification to a device + '$ ably push publish --device-id my-device --title "Hello" --body "World"', + // Notification to all devices of a client + '$ ably push publish --client-id user-123 --title "Alert" --body "New message"', + // With custom data payload + '$ ably push publish --device-id my-device --title "Order" --body "Shipped" --data \'{"orderId":"123"}\'', + // iOS-specific with badge + '$ ably push publish --device-id my-device --title "Messages" --body "3 unread" --badge 3', + // Full payload from file + "$ ably push publish --device-id my-device --payload ./notification.json", + // Full payload inline + '$ ably push publish --device-id my-device --payload \'{"notification":{"title":"Hi","body":"Hello"}}\'', + ]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + "device-id": Flags.string({ + description: "Target device ID", + }), + "client-id": Flags.string({ + description: "Target client ID (sends to all client's devices)", + }), + title: Flags.string({ + description: "Notification title", + }), + body: Flags.string({ + description: "Notification body", + }), + sound: Flags.string({ + description: "Notification sound (default, or filename)", + }), + icon: Flags.string({ + description: "Notification icon (Android/Web)", + }), + badge: Flags.integer({ + description: "Badge count (iOS)", + }), + data: Flags.string({ + description: "Custom data payload as JSON string", + }), + payload: Flags.string({ + description: + "Full notification payload as JSON string or path to JSON file", + }), + "collapse-key": Flags.string({ + description: "Collapse key for notification grouping", + }), + ttl: Flags.integer({ + description: "Time-to-live in seconds", + }), + apns: Flags.string({ + description: "APNs-specific overrides as JSON", + }), + fcm: Flags.string({ + description: "FCM-specific overrides as JSON", + }), + web: Flags.string({ + description: "Web Push-specific overrides as JSON", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushPublish); + + // Validate recipient + if (!flags["device-id"] && !flags["client-id"]) { + this.error("Either --device-id or --client-id must be specified"); + } + + if (flags["device-id"] && flags["client-id"]) { + this.error( + "Only one of --device-id or --client-id can be specified, not both", + ); + } + + // Validate payload options + if (flags.payload && (flags.title || flags.body)) { + this.error( + "Cannot use --payload with --title or --body. Use --payload for full control or individual flags for simple notifications.", + ); + } + + if (!flags.payload && !flags.title && !flags.body) { + this.error( + "Either --payload or at least one of --title/--body must be specified", + ); + } + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) { + return; + } + + // Build recipient + const recipient: Record = {}; + if (flags["device-id"]) { + recipient.deviceId = flags["device-id"]; + } else if (flags["client-id"]) { + recipient.clientId = flags["client-id"]; + } + + // Build payload + let pushPayload: Record; + + if (flags.payload) { + pushPayload = this.parsePayload(flags.payload); + } else { + pushPayload = this.buildPayload(flags); + } + + // Publish the notification + await rest.push.admin.publish(recipient, pushPayload); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + recipient: { + deviceId: flags["device-id"], + clientId: flags["client-id"], + }, + published: true, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + const target = flags["device-id"] + ? `device ${chalk.cyan(flags["device-id"])}` + : `client ${chalk.cyan(flags["client-id"])}`; + + this.log( + chalk.green(`Push notification sent successfully to ${target}`), + ); + + if (flags.title) { + this.log(`${chalk.dim("Title:")} ${flags.title}`); + } + + if (flags.body) { + this.log(`${chalk.dim("Body:")} ${flags.body}`); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = (error as { code?: number }).code; + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + error: errorMessage, + code: errorCode, + success: false, + }, + flags, + ), + ); + } else { + if (errorCode === 40400) { + this.error( + `Device or client not found. Ensure the device/client is registered first.`, + ); + } else { + this.error(`Error publishing notification: ${errorMessage}`); + } + } + } + } + + private parsePayload(payload: string): Record { + let jsonString = payload; + + // Check if it's a file path + if (!payload.trim().startsWith("{") && !payload.trim().startsWith("[")) { + const filePath = path.resolve(payload); + + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + jsonString = fs.readFileSync(filePath, "utf8"); + } + + try { + return JSON.parse(jsonString) as Record; + } catch { + throw new Error("Invalid JSON format in --payload"); + } + } + + private buildPayload( + flags: Record, + ): Record { + const payload: Record = {}; + + // Build notification object + const notification: Record = {}; + + if (flags.title) { + notification.title = flags.title as string; + } + + if (flags.body) { + notification.body = flags.body as string; + } + + if (flags.sound) { + notification.sound = flags.sound as string; + } + + if (flags.icon) { + notification.icon = flags.icon as string; + } + + if (flags.badge !== undefined) { + notification.badge = flags.badge as number; + } + + if (flags["collapse-key"]) { + notification.collapseKey = flags["collapse-key"] as string; + } + + if (flags.ttl !== undefined) { + notification.ttl = flags.ttl as number; + } + + if (Object.keys(notification).length > 0) { + payload.notification = notification; + } + + // Add custom data + if (flags.data) { + try { + payload.data = JSON.parse(flags.data as string); + } catch { + throw new Error("Invalid JSON format in --data"); + } + } + + // Add platform-specific overrides + if (flags.apns) { + try { + payload.apns = JSON.parse(flags.apns as string); + } catch { + throw new Error("Invalid JSON format in --apns"); + } + } + + if (flags.fcm) { + try { + payload.fcm = JSON.parse(flags.fcm as string); + } catch { + throw new Error("Invalid JSON format in --fcm"); + } + } + + if (flags.web) { + try { + payload.web = JSON.parse(flags.web as string); + } catch { + throw new Error("Invalid JSON format in --web"); + } + } + + return payload; + } +} diff --git a/test/e2e/push/publish-e2e.test.ts b/test/e2e/push/publish-e2e.test.ts new file mode 100644 index 00000000..8d6f4ff1 --- /dev/null +++ b/test/e2e/push/publish-e2e.test.ts @@ -0,0 +1,401 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import * as Ably from "ably"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, + createAblyClient, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe("Push Publish E2E Tests", () => { + let testDeviceId: string; + let client: Ably.Rest; + + beforeAll(async () => { + if (SHOULD_SKIP_E2E) { + return; + } + + process.on("SIGINT", forceExit); + + // Generate unique device ID for this test run + testDeviceId = `cli-e2e-publish-test-${Date.now()}`; + + // Create Ably client + client = createAblyClient(); + + // Create a test device for publish tests + await client.push.admin.deviceRegistrations.save({ + id: testDeviceId, + platform: "android", + formFactor: "phone", + clientId: "e2e-publish-test-user", + push: { + recipient: { + transportType: "fcm", + registrationToken: `fake-fcm-publish-test-${Date.now()}`, + }, + }, + }); + }); + + afterAll(async () => { + if (SHOULD_SKIP_E2E) { + return; + } + + // Cleanup test device + try { + await client.push.admin.deviceRegistrations.remove(testDeviceId); + } catch { + // Ignore cleanup errors + } + + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + setupTestFailureHandler(); + await cleanupTrackedResources(); + }); + + describe("push publish", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should publish a simple notification to a device", + async () => { + const result = await runCommand( + [ + "push", + "publish", + "--device-id", + testDeviceId, + "--title", + "Test Title", + "--body", + "Test Body", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("sent successfully"); + expect(result.stdout).toContain(testDeviceId); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)("should publish with JSON output", async () => { + const result = await runCommand( + [ + "push", + "publish", + "--device-id", + testDeviceId, + "--title", + "JSON Test", + "--body", + "Testing JSON output", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.published).toBe(true); + expect(json.recipient.deviceId).toBe(testDeviceId); + }); + + it.skipIf(SHOULD_SKIP_E2E)( + "should publish with custom data payload", + async () => { + const result = await runCommand( + [ + "push", + "publish", + "--device-id", + testDeviceId, + "--title", + "Data Test", + "--body", + "With custom data", + "--data", + '{"orderId":"12345","action":"view"}', + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("sent successfully"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)("should publish with full payload", async () => { + const payload = JSON.stringify({ + notification: { + title: "Full Payload Test", + body: "Testing full payload", + }, + data: { + key: "value", + }, + }); + + const result = await runCommand( + ["push", "publish", "--device-id", testDeviceId, "--payload", payload], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("sent successfully"); + }); + + it.skipIf(SHOULD_SKIP_E2E)("should publish to a client ID", async () => { + const result = await runCommand( + [ + "push", + "publish", + "--client-id", + "e2e-publish-test-user", + "--title", + "Client Test", + "--body", + "To client", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("sent successfully"); + expect(result.stdout).toContain("e2e-publish-test-user"); + }); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error when neither device-id nor client-id provided", + async () => { + const result = await runCommand( + ["push", "publish", "--title", "Test", "--body", "Test"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Either --device-id or --client-id must be specified", + ); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error when both device-id and client-id provided", + async () => { + const result = await runCommand( + [ + "push", + "publish", + "--device-id", + "device1", + "--client-id", + "client1", + "--title", + "Test", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Only one of --device-id or --client-id can be specified", + ); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error when no payload or title/body provided", + async () => { + const result = await runCommand( + ["push", "publish", "--device-id", testDeviceId], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--payload"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error when payload and title both provided", + async () => { + const result = await runCommand( + [ + "push", + "publish", + "--device-id", + testDeviceId, + "--payload", + '{"notification":{"title":"Test"}}', + "--title", + "Conflict", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Cannot use --payload with --title"); + }, + ); + }); + + describe("push batch-publish", () => { + it.skipIf(SHOULD_SKIP_E2E)( + "should batch publish notifications", + async () => { + const batchPayload = JSON.stringify([ + { + recipient: { deviceId: testDeviceId }, + payload: { notification: { title: "Batch 1", body: "First" } }, + }, + { + recipient: { deviceId: testDeviceId }, + payload: { notification: { title: "Batch 2", body: "Second" } }, + }, + ]); + + const result = await runCommand( + ["push", "batch-publish", "--payload", batchPayload], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Batch Push Results"); + expect(result.stdout).toContain("Total:"); + expect(result.stdout).toContain("2"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should batch publish with JSON output", + async () => { + const batchPayload = JSON.stringify([ + { + recipient: { deviceId: testDeviceId }, + payload: { notification: { title: "JSON Batch", body: "Test" } }, + }, + ]); + + const result = await runCommand( + ["push", "batch-publish", "--payload", batchPayload, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.total).toBe(1); + expect(json.results).toBeInstanceOf(Array); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error with invalid batch payload format", + async () => { + const result = await runCommand( + ["push", "batch-publish", "--payload", '{"not":"an array"}'], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + // Check for part of the error message (full message may wrap across lines) + expect(result.stderr).toContain("must be a JSON"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error with empty batch payload", + async () => { + const result = await runCommand( + ["push", "batch-publish", "--payload", "[]"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("empty"); + }, + ); + + it.skipIf(SHOULD_SKIP_E2E)( + "should error with missing recipient in batch item", + async () => { + const batchPayload = JSON.stringify([ + { payload: { notification: { title: "No recipient" } } }, + ]); + + const result = await runCommand( + ["push", "batch-publish", "--payload", batchPayload], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("recipient"); + }, + ); + }); +}); From c9e69ff1862bb0f882dbb4b045f5e87032ff7270 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 6 Jan 2026 19:41:19 +0100 Subject: [PATCH 04/20] feat(push): add APNs configuration commands for push notifications Implements Stage 4 of push notification support with commands to manage Apple Push Notification service (APNs) credentials: - `push config show` - Display current push notification configuration - `push config set-apns` - Configure APNs with P12 certificate or P8 token auth - `push config clear-apns` - Remove APNs configuration Key implementation details: - P12 certificate upload uses multipart/form-data with correct endpoint - Support for both certificate-based (.p12) and token-based (.p8) auth - Token auth requires key-id, team-id, and bundle-id flags - Sandbox/production environment determined by certificate type (not flag) Also fixes: - Control API uploadApnsP12 now uses FormData/Blob for proper multipart upload - apps:set-apns-p12 updated to use Buffer instead of base64 string - Updated unit tests to use correct /v1/apps/{id}/pkcs12 endpoint --- src/commands/apps/set-apns-p12.ts | 10 +- src/commands/push/config/clear-apns.ts | 126 +++++++++++++ src/commands/push/config/index.ts | 15 ++ src/commands/push/config/set-apns.ts | 180 +++++++++++++++++++ src/commands/push/config/show.ts | 137 ++++++++++++++ src/services/control-api.ts | 62 +++++-- test/unit/commands/apps/set-apns-p12.test.ts | 11 +- 7 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 src/commands/push/config/clear-apns.ts create mode 100644 src/commands/push/config/index.ts create mode 100644 src/commands/push/config/set-apns.ts create mode 100644 src/commands/push/config/show.ts diff --git a/src/commands/apps/set-apns-p12.ts b/src/commands/apps/set-apns-p12.ts index 4fc1c15e..c30cea77 100644 --- a/src/commands/apps/set-apns-p12.ts +++ b/src/commands/apps/set-apns-p12.ts @@ -55,16 +55,16 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { this.log(`Uploading APNS P12 certificate for app ${args.id}...`); - // Read certificate file and encode as base64 - const certificateData = fs - .readFileSync(certificatePath) - .toString("base64"); + // Read certificate file as Buffer + const certificateData = fs.readFileSync(certificatePath); const result = await controlApi.uploadApnsP12(args.id, certificateData, { password: flags.password, - useForSandbox: flags["use-for-sandbox"], }); + // Note: Sandbox vs Production is determined by the certificate type + // (Development vs Distribution certificate from Apple Developer Portal) + if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(result, flags)); } else { diff --git a/src/commands/push/config/clear-apns.ts b/src/commands/push/config/clear-apns.ts new file mode 100644 index 00000000..185c02ea --- /dev/null +++ b/src/commands/push/config/clear-apns.ts @@ -0,0 +1,126 @@ +import { Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; + +export default class PushConfigClearApns extends ControlBaseCommand { + static override description = + "Remove APNs (Apple Push Notification service) configuration from an app"; + + static override examples = [ + "$ ably push config clear-apns", + "$ ably push config clear-apns --app my-app", + "$ ably push config clear-apns --force", + ]; + + static override flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "App ID or name to clear APNs configuration for", + }), + force: Flags.boolean({ + char: "f", + default: false, + description: "Skip confirmation prompt", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushConfigClearApns); + + await this.runControlCommand( + flags, + async (api) => { + const appId = await this.resolveAppId(flags); + + // Get current app to show what will be cleared + const app = await api.getApp(appId); + const hasApnsConfig = Boolean( + app.apnsCertificate || app.apnsPrivateKey || app.applePushKeyId, + ); + + if (!hasApnsConfig) { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + success: true, + appId, + message: "No APNs configuration to clear", + }, + flags, + ), + ); + } else { + this.log( + chalk.yellow( + `No APNs configuration found for app "${app.name}" (${appId})`, + ), + ); + } + return; + } + + // Confirm unless --force is used + if (!flags.force) { + const confirmed = await this.confirm( + `Are you sure you want to remove APNs configuration from app "${app.name}"?`, + ); + if (!confirmed) { + this.log("Operation cancelled."); + return; + } + } + + this.log(`Clearing APNs configuration for app ${appId}...`); + + // Clear APNs configuration by setting fields to null/empty + await api.updateApp(appId, { + apnsCertificate: null, + apnsPrivateKey: null, + applePushKeyId: null, + applePushTeamId: null, + applePushBundleId: null, + apnsUsesSandboxCert: false, + } as Record); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + success: true, + appId, + appName: app.name, + message: "APNs configuration cleared", + }, + flags, + ), + ); + } else { + this.log( + chalk.green( + `\nAPNs configuration cleared successfully for app "${app.name}"`, + ), + ); + } + }, + "Error clearing APNs configuration", + ); + } + + private async confirm(message: string): Promise { + // Use readline for confirmation + const readline = await import("node:readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${message} (y/N) `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); + } +} diff --git a/src/commands/push/config/index.ts b/src/commands/push/config/index.ts new file mode 100644 index 00000000..2a7ced14 --- /dev/null +++ b/src/commands/push/config/index.ts @@ -0,0 +1,15 @@ +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class PushConfig extends BaseTopicCommand { + protected topicName = "push:config"; + protected commandGroup = "push notification configuration"; + + static override description = + "Manage push notification configuration (APNs and FCM credentials)"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> show", + "<%= config.bin %> <%= command.id %> set-apns --certificate ./cert.p12 --password SECRET", + "<%= config.bin %> <%= command.id %> clear-apns --force", + ]; +} diff --git a/src/commands/push/config/set-apns.ts b/src/commands/push/config/set-apns.ts new file mode 100644 index 00000000..cacb873f --- /dev/null +++ b/src/commands/push/config/set-apns.ts @@ -0,0 +1,180 @@ +import { Flags } from "@oclif/core"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import chalk from "chalk"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; + +export default class PushConfigSetApns extends ControlBaseCommand { + static override description = + "Configure Apple Push Notification service (APNs) credentials for an app. Supports both certificate-based (.p12) and token-based (.p8) authentication."; + + static override examples = [ + // Certificate-based authentication + "$ ably push config set-apns --certificate ./cert.p12 --password SECRET", + "$ ably push config set-apns --app my-app --certificate ./push-prod.p12", + // Token-based authentication + "$ ably push config set-apns --key-file ./AuthKey.p8 --key-id ABC123 --team-id XYZ789 --bundle-id com.myapp", + ]; + + static override flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "App ID or name to configure", + }), + // Certificate-based auth flags + certificate: Flags.string({ + description: "Path to .p12 certificate file", + }), + password: Flags.string({ + description: "Password for the .p12 certificate", + }), + // Token-based auth flags + "key-file": Flags.string({ + description: "Path to .p8 private key file (token-based auth)", + }), + "key-id": Flags.string({ + description: "Key ID from Apple Developer portal (token-based auth)", + }), + "team-id": Flags.string({ + description: "Team ID from Apple Developer portal (token-based auth)", + }), + "bundle-id": Flags.string({ + description: "App bundle identifier (token-based auth)", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushConfigSetApns); + + // Validate that either certificate or key-file is provided + if (!flags.certificate && !flags["key-file"]) { + this.error( + "Either --certificate (for .p12) or --key-file (for .p8 token auth) must be specified", + ); + } + + if (flags.certificate && flags["key-file"]) { + this.error( + "Cannot use both --certificate and --key-file. Choose one authentication method.", + ); + } + + // Validate token-based auth requires all fields + if ( + flags["key-file"] && + (!flags["key-id"] || !flags["team-id"] || !flags["bundle-id"]) + ) { + this.error( + "Token-based auth requires --key-file, --key-id, --team-id, and --bundle-id", + ); + } + + await this.runControlCommand( + flags, + async (api) => { + const appId = await this.resolveAppId(flags); + + if (flags.certificate) { + // Certificate-based authentication (P12) + return this.uploadP12Certificate(api, appId, flags); + } else { + // Token-based authentication (P8) + return this.configureTokenAuth(api, appId, flags); + } + }, + "Error configuring APNs", + ); + } + + private async uploadP12Certificate( + api: ReturnType, + appId: string, + flags: Record, + ): Promise { + const certificatePath = path.resolve(flags.certificate as string); + + if (!fs.existsSync(certificatePath)) { + this.error(`Certificate file not found: ${certificatePath}`); + } + + this.log(`Uploading APNs P12 certificate for app ${appId}...`); + + // Read certificate file as Buffer + const certificateData = fs.readFileSync(certificatePath); + + const result = await api.uploadApnsP12(appId, certificateData, { + password: flags.password as string | undefined, + }); + + // Note: Sandbox vs Production is determined by the certificate type + // (Development vs Distribution certificate from Apple Developer Portal) + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + success: true, + appId, + authType: "certificate", + certificateId: result.id, + }, + flags, + ), + ); + } else { + this.log(chalk.green("\nAPNs P12 certificate uploaded successfully!")); + this.log(`${chalk.dim("Certificate ID:")} ${result.id}`); + } + } + + private async configureTokenAuth( + api: ReturnType, + appId: string, + flags: Record, + ): Promise { + const keyFilePath = path.resolve(flags["key-file"] as string); + + if (!fs.existsSync(keyFilePath)) { + this.error(`Key file not found: ${keyFilePath}`); + } + + this.log(`Configuring APNs token-based authentication for app ${appId}...`); + + // Read the P8 key file + const privateKey = fs.readFileSync(keyFilePath, "utf8"); + + // Update app with APNs token auth configuration + await api.updateApp(appId, { + apnsPrivateKey: privateKey, + applePushKeyId: flags["key-id"] as string, + applePushTeamId: flags["team-id"] as string, + applePushBundleId: flags["bundle-id"] as string, + } as Record); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + success: true, + appId, + authType: "token", + keyId: flags["key-id"], + teamId: flags["team-id"], + bundleId: flags["bundle-id"], + }, + flags, + ), + ); + } else { + this.log( + chalk.green( + "\nAPNs token-based authentication configured successfully!", + ), + ); + this.log(`${chalk.dim("Key ID:")} ${flags["key-id"]}`); + this.log(`${chalk.dim("Team ID:")} ${flags["team-id"]}`); + this.log(`${chalk.dim("Bundle ID:")} ${flags["bundle-id"]}`); + } + } +} diff --git a/src/commands/push/config/show.ts b/src/commands/push/config/show.ts new file mode 100644 index 00000000..3a95757e --- /dev/null +++ b/src/commands/push/config/show.ts @@ -0,0 +1,137 @@ +import { Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; + +export default class PushConfigShow extends ControlBaseCommand { + static override description = + "Show push notification configuration status for an app"; + + static override examples = [ + "$ ably push config show", + "$ ably push config show --app my-app", + "$ ably push config show --json", + ]; + + static override flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "App ID or name to show configuration for", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushConfigShow); + + await this.runControlCommand( + flags, + async (api) => { + // Resolve app ID + const appId = await this.resolveAppId(flags); + + // Get app details + const app = await api.getApp(appId); + + if (this.shouldOutputJson(flags)) { + // Extract push-related fields + const pushConfig = { + appId: app.id, + appName: app.name, + apns: { + configured: Boolean( + app.apnsCertificate || app.apnsPrivateKey || app.applePushKeyId, + ), + useSandbox: app.apnsUsesSandboxCert || false, + // Token-based auth fields + keyId: app.applePushKeyId || null, + teamId: app.applePushTeamId || null, + bundleId: app.applePushBundleId || null, + }, + fcm: { + configured: Boolean(app.fcmServiceAccount || app.fcmProjectId), + projectId: app.fcmProjectId || null, + }, + }; + this.log(this.formatJsonOutput(pushConfig, flags)); + } else { + this.log( + chalk.bold( + `Push Notification Configuration for app "${app.name}" (${app.id})\n`, + ), + ); + + // APNs Configuration + this.log(chalk.cyan("APNs (iOS):")); + const apnsConfigured = Boolean( + app.apnsCertificate || app.apnsPrivateKey || app.applePushKeyId, + ); + + if (apnsConfigured) { + this.log( + ` ${chalk.dim("Status:")} ${chalk.green("Configured")}`, + ); + const environment = app.apnsUsesSandboxCert + ? "Sandbox" + : "Production"; + this.log(` ${chalk.dim("Environment:")} ${environment}`); + + // Check if using token-based or certificate-based auth + if (app.applePushKeyId) { + this.log(` ${chalk.dim("Auth Type:")} Token-based (.p8)`); + this.log(` ${chalk.dim("Key ID:")} ${app.applePushKeyId}`); + if (app.applePushTeamId) { + this.log( + ` ${chalk.dim("Team ID:")} ${app.applePushTeamId}`, + ); + } + if (app.applePushBundleId) { + this.log( + ` ${chalk.dim("Bundle ID:")} ${app.applePushBundleId}`, + ); + } + } else { + this.log( + ` ${chalk.dim("Auth Type:")} Certificate-based (.p12)`, + ); + } + } else { + this.log( + ` ${chalk.dim("Status:")} ${chalk.yellow("Not configured")}`, + ); + } + + this.log(""); + + // FCM Configuration + this.log(chalk.cyan("FCM (Android):")); + const fcmConfigured = Boolean( + app.fcmServiceAccount || app.fcmProjectId, + ); + + if (fcmConfigured) { + this.log( + ` ${chalk.dim("Status:")} ${chalk.green("Configured")}`, + ); + if (app.fcmProjectId) { + this.log(` ${chalk.dim("Project ID:")} ${app.fcmProjectId}`); + } + } else { + this.log( + ` ${chalk.dim("Status:")} ${chalk.yellow("Not configured")}`, + ); + } + + // Web Push info + this.log(""); + this.log(chalk.cyan("Web Push:")); + this.log( + ` ${chalk.dim("Status:")} ${chalk.green("Available")} (no configuration required)`, + ); + } + + return app; + }, + "Error retrieving push configuration", + ); + } +} diff --git a/src/services/control-api.ts b/src/services/control-api.ts index b4d22ecd..cddd79f8 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -1,4 +1,4 @@ -import fetch, { type RequestInit } from "node-fetch"; +import fetch, { type RequestInit, FormData, Blob } from "node-fetch"; import { getCliVersion } from "../utils/version.js"; import isTestMode from "../utils/test-mode.js"; @@ -484,24 +484,58 @@ export class ControlApi { // Upload Apple Push Notification Service P12 certificate for an app async uploadApnsP12( appId: string, - certificateData: string, + certificateData: Buffer, options: { password?: string; - useForSandbox?: boolean; } = {}, ): Promise<{ id: string }> { - const data = { - p12Certificate: certificateData, - password: options.password, - useForSandbox: options.useForSandbox, - }; + // This endpoint requires multipart/form-data + const formData = new FormData(); - // App ID-specific operations don't need account ID in the path - return this.request<{ id: string }>( - `/apps/${appId}/push/certificate`, - "POST", - data, - ); + // Add the certificate file + const blob = new Blob([certificateData], { + type: "application/x-pkcs12", + }); + formData.append("p12File", blob, "certificate.p12"); + + // Add the password (required by the API) + formData.append("p12Pass", options.password || ""); + + const url = this.controlHost.includes("local") + ? `http://${this.controlHost}/api/v1/apps/${appId}/pkcs12` + : `https://${this.controlHost}/v1/apps/${appId}/pkcs12`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Ably-Agent": `ably-cli/${getCliVersion()}`, + }, + body: formData, + }); + + if (!response.ok) { + const responseBody = await response.text(); + let responseData: unknown = responseBody; + try { + responseData = JSON.parse(responseBody); + } catch { + /* Ignore parsing errors */ + } + + let errorMessage = `API request failed (${response.status} ${response.statusText})`; + if ( + typeof responseData === "object" && + responseData !== null && + "message" in responseData && + typeof responseData.message === "string" + ) { + errorMessage += `: ${responseData.message}`; + } + throw new Error(errorMessage); + } + + return (await response.json()) as { id: string }; } private async request( diff --git a/test/unit/commands/apps/set-apns-p12.test.ts b/test/unit/commands/apps/set-apns-p12.test.ts index 5ceb4bab..f4611f6d 100644 --- a/test/unit/commands/apps/set-apns-p12.test.ts +++ b/test/unit/commands/apps/set-apns-p12.test.ts @@ -32,7 +32,7 @@ describe("apps:set-apns-p12 command", () => { it("should upload APNS P12 certificate successfully", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nock("https://control.ably.net") - .post(`/v1/apps/${appId}/push/certificate`) + .post(`/v1/apps/${appId}/pkcs12`) .reply(200, { id: "cert-123", appId, @@ -49,7 +49,7 @@ describe("apps:set-apns-p12 command", () => { it("should upload certificate with password", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nock("https://control.ably.net") - .post(`/v1/apps/${appId}/push/certificate`) + .post(`/v1/apps/${appId}/pkcs12`) .reply(200, { id: "cert-123", appId, @@ -72,8 +72,9 @@ describe("apps:set-apns-p12 command", () => { it("should upload certificate for sandbox environment", async () => { const appId = getMockConfigManager().getCurrentAppId()!; + // Note: sandbox flag doesn't change the API call - environment is determined by cert type nock("https://control.ably.net") - .post(`/v1/apps/${appId}/push/certificate`) + .post(`/v1/apps/${appId}/pkcs12`) .reply(200, { id: "cert-123", appId, @@ -136,7 +137,7 @@ describe("apps:set-apns-p12 command", () => { it("should handle API errors", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nock("https://control.ably.net") - .post(`/v1/apps/${appId}/push/certificate`) + .post(`/v1/apps/${appId}/pkcs12`) .reply(400, { error: "Invalid certificate" }); const { error } = await runCommand( @@ -151,7 +152,7 @@ describe("apps:set-apns-p12 command", () => { it("should handle 401 authentication error", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nock("https://control.ably.net") - .post(`/v1/apps/${appId}/push/certificate`) + .post(`/v1/apps/${appId}/pkcs12`) .reply(401, { error: "Unauthorized" }); const { error } = await runCommand( From e65949f974d21ffb35f9b6ffaefa1b9ad3054b65 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 6 Jan 2026 20:48:08 +0100 Subject: [PATCH 05/20] feat(push): add FCM configuration commands Implements Stage 5 of push notification support with commands to manage Firebase Cloud Messaging (FCM) credentials: - `push config set-fcm` - Configure FCM using a service account JSON file - Validates file exists and contains valid JSON - Validates type="service_account" and project_id fields - Displays project ID and client email on success - `push config clear-fcm` - Remove FCM configuration - Confirms before clearing (skip with --force) - Detects and reports if FCM is not configured Uses Control API PATCH /apps/{id} with fcmServiceAccount field. Progress messages suppressed in JSON output mode. Note: Ably API validates credentials with Google/Firebase before persisting - test credentials will be accepted but not stored. --- src/commands/push/config/clear-fcm.ts | 104 +++++++++ src/commands/push/config/index.ts | 2 + src/commands/push/config/set-fcm.ts | 116 ++++++++++ .../commands/push/config/clear-fcm.test.ts | 163 +++++++++++++ .../unit/commands/push/config/set-fcm.test.ts | 216 ++++++++++++++++++ 5 files changed, 601 insertions(+) create mode 100644 src/commands/push/config/clear-fcm.ts create mode 100644 src/commands/push/config/set-fcm.ts create mode 100644 test/unit/commands/push/config/clear-fcm.test.ts create mode 100644 test/unit/commands/push/config/set-fcm.test.ts diff --git a/src/commands/push/config/clear-fcm.ts b/src/commands/push/config/clear-fcm.ts new file mode 100644 index 00000000..d6d4c2ff --- /dev/null +++ b/src/commands/push/config/clear-fcm.ts @@ -0,0 +1,104 @@ +import { Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; + +export default class PushConfigClearFcm extends ControlBaseCommand { + static override description = + "Remove Firebase Cloud Messaging (FCM) configuration from an app."; + + static override examples = [ + "$ ably push config clear-fcm", + "$ ably push config clear-fcm --app my-app", + "$ ably push config clear-fcm --force", + ]; + + static override flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "App ID or name to clear FCM configuration for", + }), + force: Flags.boolean({ + char: "f", + description: "Skip confirmation prompt", + default: false, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushConfigClearFcm); + + await this.runControlCommand( + flags, + async (api) => { + const appId = await this.resolveAppId(flags); + + // Get current app to check if FCM is configured + const app = await api.getApp(appId); + + if (!app.fcmServiceAccount && !app.fcmProjectId) { + this.log(chalk.yellow("FCM is not configured for this app.")); + return; + } + + // Confirm unless --force + if (!flags.force) { + const confirmed = await this.confirm( + `Are you sure you want to remove FCM configuration from app "${app.name}" (${appId})? ` + + `This will disable push notifications for Android devices.`, + ); + if (!confirmed) { + this.log("Operation cancelled."); + return; + } + } + + if (!this.shouldOutputJson(flags)) { + this.log(`Removing FCM configuration from app ${appId}...`); + } + + // Clear FCM configuration by setting field to null + await api.updateApp(appId, { + fcmServiceAccount: null, + } as Record); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + success: true, + appId, + message: "FCM configuration cleared", + }, + flags, + ), + ); + } else { + this.log(chalk.green("\nFCM configuration removed successfully!")); + this.log( + chalk.dim( + "Push notifications to Android devices will no longer work until FCM is reconfigured.", + ), + ); + } + }, + "Error clearing FCM configuration", + ); + } + + private async confirm(message: string): Promise { + // Use readline for confirmation + const readline = await import("node:readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${message} (y/N) `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); + } +} diff --git a/src/commands/push/config/index.ts b/src/commands/push/config/index.ts index 2a7ced14..901e50f2 100644 --- a/src/commands/push/config/index.ts +++ b/src/commands/push/config/index.ts @@ -10,6 +10,8 @@ export default class PushConfig extends BaseTopicCommand { static override examples = [ "<%= config.bin %> <%= command.id %> show", "<%= config.bin %> <%= command.id %> set-apns --certificate ./cert.p12 --password SECRET", + "<%= config.bin %> <%= command.id %> set-fcm --service-account ./firebase-sa.json", "<%= config.bin %> <%= command.id %> clear-apns --force", + "<%= config.bin %> <%= command.id %> clear-fcm --force", ]; } diff --git a/src/commands/push/config/set-fcm.ts b/src/commands/push/config/set-fcm.ts new file mode 100644 index 00000000..697a1550 --- /dev/null +++ b/src/commands/push/config/set-fcm.ts @@ -0,0 +1,116 @@ +import { Flags } from "@oclif/core"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import chalk from "chalk"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; + +interface ServiceAccount { + project_id?: string; + type?: string; + client_email?: string; + [key: string]: unknown; +} + +export default class PushConfigSetFcm extends ControlBaseCommand { + static override description = + "Configure Firebase Cloud Messaging (FCM) credentials for an app using a service account JSON file."; + + static override examples = [ + "$ ably push config set-fcm --service-account ./firebase-service-account.json", + "$ ably push config set-fcm --app my-app --service-account ./firebase-prod.json", + ]; + + static override flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "App ID or name to configure", + }), + "service-account": Flags.string({ + description: "Path to Firebase service account JSON file", + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PushConfigSetFcm); + + await this.runControlCommand( + flags, + async (api) => { + const appId = await this.resolveAppId(flags); + + // Read and validate service account JSON + const serviceAccountPath = path.resolve( + flags["service-account"] as string, + ); + + if (!fs.existsSync(serviceAccountPath)) { + this.error(`Service account file not found: ${serviceAccountPath}`); + } + + let serviceAccountJson: string; + let serviceAccount: ServiceAccount; + + try { + serviceAccountJson = fs.readFileSync(serviceAccountPath, "utf8"); + serviceAccount = JSON.parse(serviceAccountJson) as ServiceAccount; + } catch { + this.error( + `Invalid JSON in service account file: ${serviceAccountPath}`, + ); + } + + // Validate it looks like a service account + if (serviceAccount.type !== "service_account") { + this.error( + `Invalid service account file: expected "type": "service_account" field. ` + + `Got type: "${serviceAccount.type || "undefined"}"`, + ); + } + + if (!serviceAccount.project_id) { + this.error( + `Invalid service account file: missing "project_id" field`, + ); + } + + if (!this.shouldOutputJson(flags)) { + this.log( + `Configuring FCM credentials for app ${appId} with project "${serviceAccount.project_id}"...`, + ); + } + + // Update app with FCM configuration + await api.updateApp(appId, { + fcmServiceAccount: serviceAccountJson, + } as Record); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + success: true, + appId, + projectId: serviceAccount.project_id, + clientEmail: serviceAccount.client_email || null, + }, + flags, + ), + ); + } else { + this.log(chalk.green("\nFCM credentials configured successfully!")); + this.log( + `${chalk.dim("Project ID:")} ${serviceAccount.project_id}`, + ); + if (serviceAccount.client_email) { + this.log( + `${chalk.dim("Service Account:")} ${serviceAccount.client_email}`, + ); + } + } + }, + "Error configuring FCM", + ); + } +} diff --git a/test/unit/commands/push/config/clear-fcm.test.ts b/test/unit/commands/push/config/clear-fcm.test.ts new file mode 100644 index 00000000..86f046dd --- /dev/null +++ b/test/unit/commands/push/config/clear-fcm.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; + +// Helper to set up common mocks for Control API +function setupControlApiMocks( + appId: string, + appConfig: Record = {}, +) { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()?.accountId || "test-account"; + + // Mock /me endpoint (called by listApps -> getMe) + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: accountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock listApps endpoint (called by getApp -> listApps) + nock("https://control.ably.net") + .get(`/v1/accounts/${accountId}/apps`) + .reply(200, [ + { + id: appId, + name: "Test App", + ...appConfig, + }, + ]); +} + +describe("push:config:clear-fcm command", () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe("when FCM is configured", () => { + it("should clear FCM configuration with --force flag", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + setupControlApiMocks(appId, { + fcmServiceAccount: "{ ... }", + fcmProjectId: "test-project", + }); + + // Mock updateApp to clear FCM + nock("https://control.ably.net").patch(`/v1/apps/${appId}`).reply(200, { + id: appId, + name: "Test App", + }); + + const { stdout } = await runCommand( + ["push:config:clear-fcm", "--force"], + import.meta.url, + ); + + expect(stdout).toContain("FCM configuration removed successfully"); + }); + + it("should output JSON when --json flag is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + setupControlApiMocks(appId, { + fcmServiceAccount: "{ ... }", + fcmProjectId: "test-project", + }); + + nock("https://control.ably.net").patch(`/v1/apps/${appId}`).reply(200, { + id: appId, + name: "Test App", + }); + + const { stdout } = await runCommand( + ["push:config:clear-fcm", "--force", "--json"], + import.meta.url, + ); + + const output = JSON.parse(stdout); + expect(output.success).toBe(true); + expect(output.message).toBe("FCM configuration cleared"); + }); + }); + + describe("when FCM is not configured", () => { + it("should report that FCM is not configured", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + setupControlApiMocks(appId, { + // No FCM configuration + }); + + const { stdout } = await runCommand( + ["push:config:clear-fcm", "--force"], + import.meta.url, + ); + + expect(stdout).toContain("FCM is not configured"); + }); + }); + + describe("error handling", () => { + it("should handle API errors when getting app", async () => { + const mockConfig = getMockConfigManager(); + const accountId = + mockConfig.getCurrentAccount()?.accountId || "test-account"; + + // Mock /me endpoint + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: accountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock listApps to fail + nock("https://control.ably.net") + .get(`/v1/accounts/${accountId}/apps`) + .reply(500, { error: "Internal server error" }); + + const { error } = await runCommand( + ["push:config:clear-fcm", "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/500/); + }); + + it("should handle API errors when clearing FCM", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + setupControlApiMocks(appId, { + fcmServiceAccount: "{ ... }", + fcmProjectId: "test-project", + }); + + nock("https://control.ably.net") + .patch(`/v1/apps/${appId}`) + .reply(400, { error: "Bad request" }); + + const { error } = await runCommand( + ["push:config:clear-fcm", "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/400/); + }); + + it("should handle 401 authentication error", async () => { + // Mock /me to fail with 401 + nock("https://control.ably.net") + .get("/v1/me") + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["push:config:clear-fcm", "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + }); +}); diff --git a/test/unit/commands/push/config/set-fcm.test.ts b/test/unit/commands/push/config/set-fcm.test.ts new file mode 100644 index 00000000..6d843a49 --- /dev/null +++ b/test/unit/commands/push/config/set-fcm.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; + +describe("push:config:set-fcm command", () => { + let testTempDir: string; + let validServiceAccountFile: string; + let invalidTypeFile: string; + let missingProjectIdFile: string; + let invalidJsonFile: string; + + beforeEach(() => { + // Create temp directory for test files + testTempDir = resolve(tmpdir(), `ably-cli-test-fcm-${Date.now()}`); + mkdirSync(testTempDir, { recursive: true, mode: 0o700 }); + + // Create valid service account file + validServiceAccountFile = resolve(testTempDir, "valid-sa.json"); + writeFileSync( + validServiceAccountFile, + JSON.stringify({ + type: "service_account", + project_id: "test-project-123", + private_key_id: "key-id-123", + private_key: + "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n", + client_email: + "firebase-adminsdk@test-project-123.iam.gserviceaccount.com", + client_id: "123456789", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + }), + ); + + // Create file with wrong type + invalidTypeFile = resolve(testTempDir, "invalid-type.json"); + writeFileSync( + invalidTypeFile, + JSON.stringify({ + type: "wrong_type", + project_id: "test-project", + }), + ); + + // Create file missing project_id + missingProjectIdFile = resolve(testTempDir, "missing-project.json"); + writeFileSync( + missingProjectIdFile, + JSON.stringify({ + type: "service_account", + }), + ); + + // Create invalid JSON file + invalidJsonFile = resolve(testTempDir, "invalid.json"); + writeFileSync(invalidJsonFile, "not valid json {{{"); + }); + + afterEach(() => { + nock.cleanAll(); + + if (existsSync(testTempDir)) { + rmSync(testTempDir, { recursive: true, force: true }); + } + }); + + describe("successful configuration", () => { + it("should configure FCM with valid service account", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net").patch(`/v1/apps/${appId}`).reply(200, { + id: appId, + name: "Test App", + }); + + const { stdout } = await runCommand( + ["push:config:set-fcm", "--service-account", validServiceAccountFile], + import.meta.url, + ); + + expect(stdout).toContain("FCM credentials configured successfully"); + expect(stdout).toContain("test-project-123"); + }); + + it("should show client email in output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net").patch(`/v1/apps/${appId}`).reply(200, { + id: appId, + name: "Test App", + }); + + const { stdout } = await runCommand( + ["push:config:set-fcm", "--service-account", validServiceAccountFile], + import.meta.url, + ); + + expect(stdout).toContain( + "firebase-adminsdk@test-project-123.iam.gserviceaccount.com", + ); + }); + + it("should output JSON when --json flag is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net").patch(`/v1/apps/${appId}`).reply(200, { + id: appId, + name: "Test App", + }); + + const { stdout } = await runCommand( + [ + "push:config:set-fcm", + "--service-account", + validServiceAccountFile, + "--json", + ], + import.meta.url, + ); + + const output = JSON.parse(stdout); + expect(output.success).toBe(true); + expect(output.projectId).toBe("test-project-123"); + expect(output.clientEmail).toBe( + "firebase-adminsdk@test-project-123.iam.gserviceaccount.com", + ); + }); + }); + + describe("error handling", () => { + it("should require service-account flag", async () => { + const { error } = await runCommand( + ["push:config:set-fcm"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing required flag.*service-account/); + }); + + it("should error when service account file does not exist", async () => { + const { error } = await runCommand( + [ + "push:config:set-fcm", + "--service-account", + "/nonexistent/path/sa.json", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/not found/); + }); + + it("should error when file contains invalid JSON", async () => { + const { error } = await runCommand( + ["push:config:set-fcm", "--service-account", invalidJsonFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Invalid JSON/); + }); + + it("should error when service account type is wrong", async () => { + const { error } = await runCommand( + ["push:config:set-fcm", "--service-account", invalidTypeFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/expected.*service_account/); + }); + + it("should error when project_id is missing", async () => { + const { error } = await runCommand( + ["push:config:set-fcm", "--service-account", missingProjectIdFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/missing.*project_id/); + }); + + it("should handle API errors", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .patch(`/v1/apps/${appId}`) + .reply(400, { error: "Invalid request" }); + + const { error } = await runCommand( + ["push:config:set-fcm", "--service-account", validServiceAccountFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/400/); + }); + + it("should handle 401 authentication error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .patch(`/v1/apps/${appId}`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["push:config:set-fcm", "--service-account", validServiceAccountFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + }); +}); From 03d70cbec378ff4f799adc7725b4ddf82716b015 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 6 Jan 2026 21:09:52 +0100 Subject: [PATCH 06/20] Docs update + push fixtures Push fixtures useful for real CLI testing --- README.md | 1394 ++++++++++++++--- test/fixtures/push/README.md | 31 + test/fixtures/push/test-apns-cert.pem | 8 + test/fixtures/push/test-apns-key.p8 | 6 + test/fixtures/push/test-apns-private-key.pem | 6 + .../push/test-fcm-service-account.json | 12 + 6 files changed, 1222 insertions(+), 235 deletions(-) create mode 100644 test/fixtures/push/README.md create mode 100644 test/fixtures/push/test-apns-cert.pem create mode 100644 test/fixtures/push/test-apns-key.p8 create mode 100644 test/fixtures/push/test-apns-private-key.pem create mode 100644 test/fixtures/push/test-fcm-service-account.json diff --git a/README.md b/README.md index d97885b3..d2816a72 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ See [MCP Server section](#mcp-server) for more details on how to use the MCP Ser * [`ably apps channel-rules update NAMEORID`](#ably-apps-channel-rules-update-nameorid) * [`ably apps create`](#ably-apps-create) * [`ably apps current`](#ably-apps-current) -* [`ably apps delete [ID]`](#ably-apps-delete-id) +* [`ably apps delete [APPID]`](#ably-apps-delete-appid) * [`ably apps list`](#ably-apps-list) * [`ably apps logs`](#ably-apps-logs) * [`ably apps logs history`](#ably-apps-logs-history) @@ -142,6 +142,8 @@ See [MCP Server section](#mcp-server) for more details on how to use the MCP Ser * [`ably channels publish CHANNEL MESSAGE`](#ably-channels-publish-channel-message) * [`ably channels subscribe CHANNELS`](#ably-channels-subscribe-channels) * [`ably config`](#ably-config) +* [`ably config path`](#ably-config-path) +* [`ably config show`](#ably-config-show) * [`ably connections`](#ably-connections) * [`ably connections logs [TOPIC]`](#ably-connections-logs-topic) * [`ably connections stats`](#ably-connections-stats) @@ -149,7 +151,7 @@ See [MCP Server section](#mcp-server) for more details on how to use the MCP Ser * [`ably help [COMMANDS]`](#ably-help-commands) * [`ably integrations`](#ably-integrations) * [`ably integrations create`](#ably-integrations-create) -* [`ably integrations delete RULEID`](#ably-integrations-delete-ruleid) +* [`ably integrations delete INTEGRATIONID`](#ably-integrations-delete-integrationid) * [`ably integrations get RULEID`](#ably-integrations-get-ruleid) * [`ably integrations list`](#ably-integrations-list) * [`ably integrations update RULEID`](#ably-integrations-update-ruleid) @@ -169,9 +171,30 @@ See [MCP Server section](#mcp-server) for more details on how to use the MCP Ser * [`ably logs push subscribe`](#ably-logs-push-subscribe) * [`ably mcp`](#ably-mcp) * [`ably mcp start-server`](#ably-mcp-start-server) +* [`ably push`](#ably-push) +* [`ably push batch-publish`](#ably-push-batch-publish) +* [`ably push channels`](#ably-push-channels) +* [`ably push channels list`](#ably-push-channels-list) +* [`ably push channels list-channels`](#ably-push-channels-list-channels) +* [`ably push channels remove`](#ably-push-channels-remove) +* [`ably push channels remove-where`](#ably-push-channels-remove-where) +* [`ably push channels save`](#ably-push-channels-save) +* [`ably push config`](#ably-push-config) +* [`ably push config clear-apns`](#ably-push-config-clear-apns) +* [`ably push config clear-fcm`](#ably-push-config-clear-fcm) +* [`ably push config set-apns`](#ably-push-config-set-apns) +* [`ably push config set-fcm`](#ably-push-config-set-fcm) +* [`ably push config show`](#ably-push-config-show) +* [`ably push devices`](#ably-push-devices) +* [`ably push devices get DEVICEID`](#ably-push-devices-get-deviceid) +* [`ably push devices list`](#ably-push-devices-list) +* [`ably push devices remove DEVICEID`](#ably-push-devices-remove-deviceid) +* [`ably push devices remove-where`](#ably-push-devices-remove-where) +* [`ably push devices save`](#ably-push-devices-save) +* [`ably push publish`](#ably-push-publish) * [`ably queues`](#ably-queues) * [`ably queues create`](#ably-queues-create) -* [`ably queues delete QUEUENAME`](#ably-queues-delete-queuename) +* [`ably queues delete QUEUEID`](#ably-queues-delete-queueid) * [`ably queues list`](#ably-queues-list) * [`ably rooms`](#ably-rooms) * [`ably rooms list`](#ably-rooms-list) @@ -182,7 +205,7 @@ See [MCP Server section](#mcp-server) for more details on how to use the MCP Ser * [`ably rooms messages reactions send ROOM MESSAGESERIAL REACTION`](#ably-rooms-messages-reactions-send-room-messageserial-reaction) * [`ably rooms messages reactions subscribe ROOM`](#ably-rooms-messages-reactions-subscribe-room) * [`ably rooms messages send ROOM TEXT`](#ably-rooms-messages-send-room-text) -* [`ably rooms messages subscribe ROOM`](#ably-rooms-messages-subscribe-room) +* [`ably rooms messages subscribe ROOMS`](#ably-rooms-messages-subscribe-rooms) * [`ably rooms occupancy`](#ably-rooms-occupancy) * [`ably rooms occupancy get ROOM`](#ably-rooms-occupancy-get-room) * [`ably rooms occupancy subscribe ROOM`](#ably-rooms-occupancy-subscribe-room) @@ -811,17 +834,17 @@ EXAMPLES _See code: [src/commands/apps/current.ts](https://github.com/ably/ably-cli/blob/v0.15.0/src/commands/apps/current.ts)_ -## `ably apps delete [ID]` +## `ably apps delete [APPID]` Delete an app ``` USAGE - $ ably apps delete [ID] [--access-token ] [--api-key ] [--client-id ] [--env ] + $ ably apps delete [APPID] [--access-token ] [--api-key ] [--client-id ] [--env ] [--endpoint ] [--host ] [--json | --pretty-json] [--token ] [-v] [-f] [--app ] ARGUMENTS - ID App ID to delete (uses current app if not specified) + APPID App ID to delete (uses current app if not specified) FLAGS -f, --force Skip confirmation prompt @@ -1227,7 +1250,7 @@ FLAGS --pretty-json Output in colorized JSON format --token= Authenticate using an Ably Token or JWT Token instead of an API key --token-only Output only the token string without any formatting or additional information - --ttl= [default: 3600] Time to live in seconds + --ttl= [default: 3600] Time to live in seconds (default: 3600, 1 hour) DESCRIPTION Creates an Ably Token with capabilities @@ -1279,7 +1302,7 @@ FLAGS --pretty-json Output in colorized JSON format --token= Authenticate using an Ably Token or JWT Token instead of an API key --token-only Output only the token string without any formatting or additional information - --ttl= [default: 3600] Time to live in seconds + --ttl= [default: 3600] Time to live in seconds (default: 3600, 1 hour) DESCRIPTION Creates an Ably JWT token with capabilities @@ -1772,7 +1795,7 @@ Run a subscriber benchmark test ``` USAGE $ ably bench subscriber CHANNEL [--access-token ] [--api-key ] [--client-id ] [--env ] - [--endpoint ] [--host ] [--json | --pretty-json] [--token ] [-v] + [--endpoint ] [--host ] [--json | --pretty-json] [--token ] [-v] [-d ] ARGUMENTS CHANNEL The channel name to subscribe to @@ -1845,13 +1868,14 @@ ARGUMENTS MESSAGE The message to publish (JSON format or plain text, not needed if using --spec) FLAGS - -e, --encoding= The encoding for the message - -n, --name= The event name (if not specified in the message JSON) + -e, --encoding= The encoding for the message (not used with --spec) + -n, --name= The event name (if not specified in the message JSON, not used with --spec) -v, --verbose Output verbose logs --access-token= Overrides any configured access token used for the Control API --api-key= Overrides any configured API key used for the product APIs - --channels= Comma-separated list of channel names to publish to - --channels-json= JSON array of channel names to publish to + --channels= Comma-separated list of channel names to publish to (mutually exclusive with + --channels-json and --spec) + --channels-json= JSON array of channel names to publish to (mutually exclusive with --channels and --spec) --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set no client ID. Not applicable when using token authentication. --endpoint= Override the endpoint for all product API calls @@ -1860,7 +1884,7 @@ FLAGS --json Output in JSON format --pretty-json Output in colorized JSON format --spec= Complete batch spec JSON (either a single BatchSpec object or an array of BatchSpec - objects) + objects). When used, --channels, --channels-json, --name, and --encoding are ignored --token= Authenticate using an Ably Token or JWT Token instead of an API key DESCRIPTION @@ -1904,14 +1928,14 @@ FLAGS --cipher= Decryption key for encrypted messages (AES-128) --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set no client ID. Not applicable when using token authentication. - --direction=