diff --git a/README.md b/README.md index 6d781018..fe03433a 100644 --- a/README.md +++ b/README.md @@ -2341,7 +2341,70 @@ ALIASES _See code: [src/commands/runtime/trigger/update.js](https://github.com/adobe/aio-cli-plugin-runtime/blob/8.2.0/src/commands/runtime/trigger/update.js)_ +### Using `aio runtime ip-list get` across multiple Adobe orgs +The `aio runtime ip-list get` command returns the egress IP allowlist for whichever Adobe org you have selected via `aio console org select`. If you belong to multiple Adobe orgs and want to retrieve the list for a different one, switch the CLI's selected org first and then re-run the command: + +```bash +aio console org select # pick the target org +aio runtime ip-list get +``` + +#### First-use terms acceptance + +The first time a given Adobe user runs the command against a given Adobe org, the service requires acceptance of the terms of use for that org and prompts for a contact email used for IP-change notifications: + +```bash +aio runtime ip-list get +# ? Accept terms v1? (Y/n) +# ? Contact email (for IP-change notifications): you@example.com +``` + +For non-interactive contexts (CI, scripts, automation), supply the acceptance flag and a contact email directly: + +```bash +aio runtime ip-list get --accept-terms --contact-email ops@example.com +``` + +Once acceptance is recorded for that (org, user) pair, subsequent calls are non-interactive and return the IP list immediately. + +#### Switching orgs + +Terms acceptance is stored per `(org, user)`. After accepting terms in org A, switching to org B will prompt for acceptance again the first time you call the command against org B. This is by design — each Adobe org accepts terms independently. After acceptance, you can flip back and forth between orgs without further prompts: + +```bash +aio console org select # pick org A +aio runtime ip-list get # first call against A: terms prompt, then IPs +aio console org select # pick org B +aio runtime ip-list get # first call against B: terms prompt, then IPs +aio console org select # back to A +aio runtime ip-list get # no prompt — IPs returned directly +``` + +#### If you previously ran `aio app use` + +`aio app use` writes an IMS org id binding to `project.org.ims_org_id` (in both the global aio config and a local `.aio` file in the project directory). That binding takes precedence over `aio console org select` for this command. To target a different org: + +```bash +# Option 1 — rebind the project to a workspace in the new org: +aio console org select # new org +aio console project select # a project you belong to in that org +aio console workspace select +aio app use + +# Option 2 — drop the project binding entirely and use the console org selection: +aio config delete project.org.ims_org_id +# If a local .aio file exists in your current directory, also remove it +# (or run the command from a different directory). +``` + +#### Common errors and how to resolve them + +| Symptom | Likely cause | Resolution | +| --- | --- | --- | +| `IMS org id not found in aio config.` | No org has been bound to the CLI. | Run `aio console org select` or `aio app use`. | +| `ip-list service returned 403: token does not grant access to org X@AdobeOrg` | Your IMS token does not grant access to the org id the CLI is sending. Common after switching Adobe accounts. | Verify which org the CLI is using with `aio config get console.org.code` and `aio config get project.org.ims_org_id`; update whichever is stale (see "Switching orgs" above). | +| Stuck on terms prompt in CI. | Non-interactive context cannot answer the prompt. | Re-run with `--accept-terms --contact-email `. | ### Contributing diff --git a/src/commands/runtime/ip-list/get.js b/src/commands/runtime/ip-list/get.js new file mode 100644 index 00000000..9a809b47 --- /dev/null +++ b/src/commands/runtime/ip-list/get.js @@ -0,0 +1,406 @@ +/* +Copyright 2026 Adobe Inc. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Flags } = require('@oclif/core') +const config = require('@adobe/aio-lib-core-config') +const chalk = require('chalk') +const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') +const { getToken, context } = require('@adobe/aio-lib-ims') +const { CLI } = require('@adobe/aio-lib-ims/src/context') + +/* + * Service endpoint. Targets the runtime host directly rather than the CDN + * fronted by adobeio-static.net: the CDN rewrites non-2xx origin responses + * as a generic 503 HTML page, which masks the 403 TERMS_REQUIRED envelope + * this command relies on. The runtime host preserves the original status + * code and JSON body. + */ +const DEFAULT_SERVICE_HOST = '53444-iplistservice.adobeioruntime.net' +const SERVICE_PATH = '/api/v1/web/ip-list' +const SURFACE = 'cli' +const VALID_REGIONS = ['amer', 'emea', 'apac', 'aus'] + +/** + * Resolve the ip-list service host, preferring explicit flags, then env + * overrides, then the compiled-in default. + * + * @param {object} flags - Parsed oclif flags for this command. + * @returns {string} Host to target (without scheme, trailing slash, or path). + */ +function resolveHost (flags) { + return flags['service-host'] || + process.env.AIO_IP_LIST_HOST || + DEFAULT_SERVICE_HOST +} + +/** + * Resolve an IMS access token for the current CLI context. Uses the + * active IMS context if one is set; otherwise falls back to the bare + * CLI context, matching how `aio app deploy` selects its token. + * + * @returns {Promise} Bearer token for the Authorization header. + */ +async function getAccessToken () { + let contextName = CLI + const currentContext = await context.getCurrent() + if (currentContext && currentContext !== CLI) { + contextName = currentContext + } else { + await context.setCli({ 'cli.bare-output': true }, false) + } + return getToken(contextName) +} + +/** + * Resolve the caller's IMS org id from local aio config, or null if no + * binding is configured. The service validates the IMS token against + * the claimed imsOrgId and rejects mismatches with 400, so an unbound + * shell must short-circuit before any network call. + * + * Key precedence reflects what each aio flow actually writes: + * - `project.org.ims_org_id` — populated by `aio app use` in the + * local project config; most specific binding. + * - `console.org.code` — populated by `aio console org select`. Note + * this is the `@AdobeOrg` value despite the field name; the sibling + * `console.org.id` is the Developer Console numeric id, which the + * service does not accept. + * - `ims.org_id` — legacy key retained for back-compat. + * + * @returns {string|null} IMS org id in `...@AdobeOrg` form, or null. + */ +function resolveImsOrgId () { + return config.get('project.org.ims_org_id') || + config.get('console.org.code') || + config.get('ims.org_id') || + null +} + +/** + * Low-level HTTP helper for the ip-list service. Returns the parsed JSON + * body when the response decodes as JSON, plus the raw text so callers + * can render a useful message when the origin returns non-JSON content. + * + * The service's web actions perform IMS validation inside the action + * rather than at the gateway, so the canonical request shape is POST + * with `token` and `imsOrgId` carried in the JSON body rather than in + * Authorization / x-gw-ims-org-id headers. + * + * @param {object} opts - Request options. + * @param {string} opts.host - Service host. + * @param {string} opts.path - Request path. + * @param {object} opts.body - JSON body; `token` and `imsOrgId` are merged in. + * @param {string} opts.token - IMS bearer token. + * @param {string} opts.orgId - IMS org id (`@AdobeOrg`). + * @param {Function} [opts.fetchImpl] - Fetch override; used by tests. + * @returns {Promise<{status: number, body: object|null, rawBody: string}>} + * Response envelope with HTTP status, parsed JSON body (or null when + * the response is not valid JSON), and the raw response text. + */ +async function callService ({ host, path, body, token, orgId, fetchImpl }) { + const f = fetchImpl || global.fetch + // Normalize the host: tolerate a scheme prefix or trailing slashes so + // callers can pass either `host.example.com` or `https://host.example.com/`. + const cleanHost = host.replace(/^https?:\/\//, '').replace(/\/+$/, '') + const url = `https://${cleanHost}${path}` + const payload = { ...(body || {}), token, imsOrgId: orgId } + const init = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(payload) + } + const res = await f(url, init) + const text = await res.text() + let parsed = null + if (text) { + try { + parsed = JSON.parse(text) + } catch (_) { + /* leave parsed=null and let caller handle as raw */ + } + } + return { status: res.status, body: parsed, rawBody: text } +} + +/** + * Fetch the egress IP list, optionally scoped to a single region. + * + * @param {object} opts - see {@link callService}; adds `region`. + * @param {string} opts.host - ip-list service host. + * @param {string} opts.token - IMS bearer token. + * @param {string} opts.orgId - IMS org id. + * @param {string} [opts.region] - one of VALID_REGIONS. + * @param {Function} [opts.fetchImpl] - fetch override for tests. + * @returns {Promise} response envelope from {@link callService}. + */ +async function getIpList ({ host, token, orgId, region, fetchImpl }) { + const body = { surface: SURFACE } + if (region) body.region = region + return callService({ + host, + path: `${SERVICE_PATH}/get-ip-list`, + body, + token, + orgId, + fetchImpl + }) +} + +/** + * POST a terms acceptance record for the current CLI user. + * + * `acceptanceMode` records whether the user confirmed at an interactive + * prompt ("interactive") or passed `--accept-terms` non-interactively + * ("programmatic"). The server validates this against its allowlist and + * rejects anything else with 400. + * + * @param {object} opts - Request options. + * @param {string} opts.host - Service host. + * @param {string} opts.token - IMS bearer token. + * @param {string} opts.orgId - IMS org id. + * @param {string} opts.contactEmail - Email recorded for change notifications. + * @param {number} opts.termsVersion - Version being accepted. + * @param {'interactive'|'programmatic'} opts.acceptanceMode - How the + * user expressed consent. + * @param {Function} [opts.fetchImpl] - Fetch override; used by tests. + * @returns {Promise} Response envelope from {@link callService}. + */ +async function postAcceptTerms ({ host, token, orgId, contactEmail, termsVersion, acceptanceMode, fetchImpl }) { + return callService({ + host, + path: `${SERVICE_PATH}/accept-terms`, + body: { contactEmail, termsVersion, surface: SURFACE, acceptanceMode }, + token, + orgId, + fetchImpl + }) +} + +/** + * Render the ip-list service response as a terminal-friendly block + * grouped by region. + * + * @param {object} data - Response body from `get-ip-list`. + * @returns {string} Text ready to pass to `this.log()`. + */ +function formatHumanOutput (data) { + const lines = [] + lines.push(chalk.bold('Adobe I/O Runtime egress IPs')) + lines.push(` version: ${data.version}`) + lines.push(` lastUpdated: ${data.lastUpdated}`) + if (data.terms) { + lines.push(` terms: v${data.terms.version} accepted ${data.terms.acceptedAt}`) + } + lines.push('') + const regions = data.regions || {} + const regionKeys = Object.keys(regions).sort() + if (regionKeys.length === 0) { + lines.push(chalk.yellow('(no regions populated yet — the service has not been seeded)')) + return lines.join('\n') + } + const longest = regionKeys.reduce((m, r) => Math.max(m, r.length), 0) + for (const region of regionKeys) { + // Current wire shape is { [region]: string[] }; the legacy + // { cidrs: string[] } envelope is tolerated for forward-compatibility. + const raw = regions[region] + const cidrList = Array.isArray(raw) ? raw : (raw && raw.cidrs) || [] + const cidrs = [...cidrList].sort() + lines.push(chalk.bold(region.toUpperCase().padEnd(longest + 2)) + `(${cidrs.length} CIDR${cidrs.length === 1 ? '' : 's'})`) + for (const cidr of cidrs) { + lines.push(` ${cidr}`) + } + lines.push('') + } + return lines.join('\n').replace(/\n+$/, '') +} + +class IpListGet extends RuntimeBaseCommand { + // This command does not invoke OpenWhisk; it calls the ip-list HTTPS + // service with an IMS bearer token, so it does not call super.run() + // or this.wsk(). + async run () { + const { flags } = await this.parse(IpListGet) + if (flags.region && !VALID_REGIONS.includes(flags.region)) { + this.error(`invalid region "${flags.region}". Expected one of: ${VALID_REGIONS.join(', ')}`, { exit: 1 }) + } + if (flags['accept-terms'] && !flags['contact-email']) { + this.error('--accept-terms requires --contact-email', { exit: 1 }) + } + const orgId = resolveImsOrgId() + if (!orgId) { + this.error( + 'IMS org id not found in aio config.\n\n' + + 'Run one of the following and try again:\n' + + ' aio console org select\n' + + ' aio app use', + { exit: 1 } + ) + } + + try { + await this.runPipeline(flags, orgId) + } catch (err) { + await this.handleError('failed to fetch the egress IP list', err) + } + } + + async runPipeline (flags, orgId) { + const host = resolveHost(flags) + const token = await getAccessToken() + + // Repeat callers with an existing acceptance record for their + // (org, user, surface) tuple receive 200 on the first attempt. + let res = await getIpList({ host, token, orgId, region: flags.region }) + + if (res.status === 403 && res.body && res.body.error === 'TERMS_REQUIRED') { + await this.handleTermsRequired({ flags, res, host, token, orgId }) + // Retry once after recording acceptance. + res = await getIpList({ host, token, orgId, region: flags.region }) + if (res.status === 403 && res.body && res.body.error === 'TERMS_REQUIRED') { + this.error('terms were not accepted; try again with --accept-terms --contact-email you@example.com') + } + } + + if (res.status !== 200) { + const detail = (res.body && (res.body.error || res.body.message)) || res.rawBody || `http ${res.status}` + this.error(`ip-list service returned ${res.status}: ${detail}`) + } + + if (flags.json) { + this.logJSON('', res.body) + } else { + this.log(formatHumanOutput(res.body)) + } + } + + async handleTermsRequired ({ flags, res, host, token, orgId }) { + const { termsText, termsUrl, termsVersion } = res.body + + // Informational output is written to stderr so `--json` consumers + // receive only the JSON payload on stdout, even on the first-use + // path where the terms text and acceptance notice would otherwise + // be interleaved with the response body. + const info = (msg) => process.stderr.write(msg + '\n') + + info('') + info(chalk.yellow.bold('This service requires terms acceptance before first use.')) + if (termsUrl) info(chalk.dim(`Full terms: ${termsUrl}`)) + info('') + if (termsText) { + info(termsText) + info('') + } + + let contactEmail = flags['contact-email'] + let accepted = flags['accept-terms'] + // Captured up front so the value the server records matches the + // path actually taken: `--accept-terms` is always "programmatic", + // interactive prompt confirmations are "interactive". + const acceptanceMode = accepted ? 'programmatic' : 'interactive' + + if (!accepted) { + // Lazy-required so non-interactive runs (`--accept-terms` or + // already-accepted users) do not pay the ESM-import cost. + // inquirer v9+ is ESM-first; when required from a CommonJS + // caller, its public API is exposed on `.default`. + const inquirer = require('inquirer').default + const answers = await inquirer.prompt([ + { name: 'accept', type: 'confirm', message: `Accept terms v${termsVersion}?`, default: false }, + { + name: 'contactEmail', + type: 'input', + message: 'Contact email (for IP-change notifications):', + when: (a) => a.accept && !contactEmail, + validate: (v) => /.+@.+\..+/.test(v) || 'enter a valid email address' + } + ]) + accepted = answers.accept + contactEmail = contactEmail || answers.contactEmail + } + + if (!accepted) { + this.error('terms were not accepted; cannot fetch the IP list') + } + if (!contactEmail) { + this.error('a contact email is required to accept terms') + } + + const acceptRes = await postAcceptTerms({ + host, token, orgId, contactEmail, termsVersion, acceptanceMode + }) + if (acceptRes.status !== 200 && acceptRes.status !== 201) { + const detail = (acceptRes.body && (acceptRes.body.error || acceptRes.body.message)) || + acceptRes.rawBody || `http ${acceptRes.status}` + this.error(`failed to record terms acceptance (${acceptRes.status}): ${detail}`) + } + info(chalk.green(`Terms v${termsVersion} accepted. Fetching the IP list...`)) + } +} + +/* + * This command does not call OpenWhisk, so the OpenWhisk-targeted flags + * from RuntimeBaseCommand (--apihost, --auth, --cert, --key, --insecure) + * are intentionally excluded — they would be silently ignored and noisy + * in --help. Only --debug and --verbose are inherited. + * + * --service-host is hidden because it is an internal escape hatch for + * stage testing, not a customer-supported endpoint override. The + * equivalent AIO_IP_LIST_HOST env var is available for the same purpose. + */ +IpListGet.flags = { + debug: RuntimeBaseCommand.flags.debug, + verbose: RuntimeBaseCommand.flags.verbose, + region: Flags.string({ + description: `restrict output to one region (${VALID_REGIONS.join(', ')})` + }), + 'accept-terms': Flags.boolean({ + description: 'accept the terms non-interactively; requires --contact-email' + }), + 'contact-email': Flags.string({ + description: 'contact email used when accepting terms and subscribing to change notifications' + }), + 'service-host': Flags.string({ + hidden: true, + description: 'override the ip-list service host (escape hatch; also AIO_IP_LIST_HOST env var)' + }), + json: Flags.boolean({ + description: 'output raw JSON instead of a formatted table' + }) +} + +IpListGet.description = 'Fetch the current Adobe I/O Runtime egress IP allowlist.\n' + + 'On first use the service returns the terms of service and the command prompts for acceptance; ' + + 'pass --accept-terms --contact-email to do that non-interactively.' + +IpListGet.aliases = [ + 'rt:ip-list:get' +] + +IpListGet.examples = [ + '$ aio runtime ip-list get', + '$ aio runtime ip-list get --region amer', + '$ aio runtime ip-list get --json', + '$ aio runtime ip-list get --accept-terms --contact-email platform-ops@example.com' +] + +/* exported for unit tests */ +module.exports = IpListGet +module.exports.getIpList = getIpList +module.exports.postAcceptTerms = postAcceptTerms +module.exports.callService = callService +module.exports.resolveHost = resolveHost +module.exports.formatHumanOutput = formatHumanOutput +module.exports.VALID_REGIONS = VALID_REGIONS +module.exports.DEFAULT_SERVICE_HOST = DEFAULT_SERVICE_HOST +module.exports.SERVICE_PATH = SERVICE_PATH diff --git a/src/commands/runtime/ip-list/index.js b/src/commands/runtime/ip-list/index.js new file mode 100644 index 00000000..a704c45f --- /dev/null +++ b/src/commands/runtime/ip-list/index.js @@ -0,0 +1,39 @@ +/* +Copyright 2026 Adobe Inc. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Help } = require('@oclif/core') +const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') + +class IndexCommand extends RuntimeBaseCommand { + async run () { + const help = new Help(this.config) + await help.showHelp(['runtime:ip-list', '--help']) + } +} + +IndexCommand.description = 'Fetch the Adobe I/O Runtime egress IP allowlist' + +// Topic command — delegates to Help and accepts no input, so the +// OpenWhisk-targeted flags inherited from RuntimeBaseCommand are +// excluded from --help. +IndexCommand.flags = {} + +IndexCommand.aliases = [ + 'rt:ip-list' +] + +IndexCommand.examples = [ + '$ aio runtime ip-list get', + '$ aio runtime ip-list --help' +] + +module.exports = IndexCommand diff --git a/test/__fixtures__/deploy/export_yaml.yaml b/test/__fixtures__/deploy/export_yaml.yaml index c867a9a1..10c48063 100644 --- a/test/__fixtures__/deploy/export_yaml.yaml +++ b/test/__fixtures__/deploy/export_yaml.yaml @@ -23,7 +23,7 @@ project: logSize: 10 triggers: meetPerson: - namespace: '53444_51981' + namespace: 53444_51981 inputs: name: Sam place: '' diff --git a/test/__fixtures__/deploy/export_yaml_Sequence.yaml b/test/__fixtures__/deploy/export_yaml_Sequence.yaml index 61769b54..b24b5b08 100644 --- a/test/__fixtures__/deploy/export_yaml_Sequence.yaml +++ b/test/__fixtures__/deploy/export_yaml_Sequence.yaml @@ -8,7 +8,7 @@ project: actions: {} triggers: meetPerson: - namespace: '53444_51981' + namespace: 53444_51981 inputs: name: Sam place: '' diff --git a/test/__fixtures__/deploy/export_yaml_feed.yaml b/test/__fixtures__/deploy/export_yaml_feed.yaml index 7304e57f..75f9062f 100644 --- a/test/__fixtures__/deploy/export_yaml_feed.yaml +++ b/test/__fixtures__/deploy/export_yaml_feed.yaml @@ -23,7 +23,7 @@ project: logSize: 10 triggers: meetPerson: - namespace: '53444_51981' + namespace: 53444_51981 inputs: name: Sam place: '' diff --git a/test/commands/runtime/ip-list/get.test.js b/test/commands/runtime/ip-list/get.test.js new file mode 100644 index 00000000..d0bd17c4 --- /dev/null +++ b/test/commands/runtime/ip-list/get.test.js @@ -0,0 +1,779 @@ +/* +Copyright 2026 Adobe Inc. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +jest.mock('@adobe/aio-lib-core-config') +jest.mock('@adobe/aio-lib-ims', () => ({ + getToken: jest.fn(), + context: { + getCurrent: jest.fn(), + setCli: jest.fn() + } +})) +jest.mock('@adobe/aio-lib-ims/src/context', () => ({ CLI: 'cli' })) +// inquirer v9+ exposes `.prompt` under `.default` when required from +// CommonJS. The mock exports the same jest.fn() under both shapes so +// tests remain agnostic to the inquirer major version. +jest.mock('inquirer', () => { + const prompt = jest.fn() + const mod = { prompt } + mod.default = mod + return mod +}) + +const { stdout, stderr } = require('stdout-stderr') +const config = require('@adobe/aio-lib-core-config') +const { getToken, context } = require('@adobe/aio-lib-ims') +const inquirer = require('inquirer') + +const TheCommand = require('../../../../src/commands/runtime/ip-list/get') +const IndexCommand = require('../../../../src/commands/runtime/ip-list/index') +const RuntimeBaseCommand = require('../../../../src/RuntimeBaseCommand') + +/** + * Builds a minimal fetch-like Response shape that satisfies the command's + * usage of `res.status` and `res.text()`. + * + * @param {number} status - HTTP status to simulate. + * @param {any} body - Object serialized as the JSON response body, a + * string to send raw, or undefined for an empty body. + * @returns {{status: number, text: () => Promise}} fetch-like stub. + */ +function fetchResponse (status, body) { + const text = body === undefined ? '' : (typeof body === 'string' ? body : JSON.stringify(body)) + return { status, text: async () => text } +} + +/** + * Instantiate the command with parsed argv and a minimal oclif config. + * + * @param {string[]} argv - CLI args to pass to the command. + * @returns {TheCommand} Ready-to-run command instance. + */ +function makeCommand (argv = []) { + return new TheCommand(argv, { + runHook: async () => ({ successes: [], failures: [] }), + bin: 'aio', + userAgent: 'test/0.0.0', + findCommand: () => null, + pjson: { oclif: {} } + }) +} + +// Mirrors the current wire shape returned by `get-ip-list`: each region +// maps to a flat array of CIDR strings. +const IP_LIST_OK = { + regions: { + amer: ['44.207.149.158/32', '44.208.197.195/32'], + emea: ['52.18.22.1/32'] + }, + version: 3, + lastUpdated: '2026-04-19T08:00:00Z', + terms: { version: 1, acceptedAt: '2026-04-18T10:00:00Z' } +} + +const TERMS_REQUIRED_BODY = { + error: 'TERMS_REQUIRED', + termsUrl: 'https://example.com/terms', + termsText: 'By using this service you agree to the terms of service.', + termsVersion: 1 +} + +let origFetch + +beforeEach(() => { + jest.clearAllMocks() + stdout.start() + stderr.start() + + config.get.mockImplementation((key) => { + if (key === 'project.org.ims_org_id') return 'BA3E111222@AdobeOrg' + return undefined + }) + context.getCurrent.mockResolvedValue(null) + context.setCli.mockResolvedValue() + getToken.mockResolvedValue('fake-token') + + // Re-install a fresh jest.fn() so tests can assert call counts even + // when they never enter the interactive branch. + inquirer.prompt = jest.fn() + + origFetch = global.fetch + global.fetch = jest.fn() +}) + +afterEach(() => { + stdout.stop() + stderr.stop() + global.fetch = origFetch + delete process.env.AIO_IP_LIST_HOST +}) + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof RuntimeBaseCommand).toBeTruthy() +}) + +test('description is set', () => { + expect(TheCommand.description).toBeDefined() + expect(TheCommand.description.length).toBeGreaterThan(0) +}) + +test('aliases include rt:ip-list:get', () => { + expect(TheCommand.aliases).toEqual(expect.arrayContaining(['rt:ip-list:get'])) +}) + +test('examples are non-empty', () => { + expect(TheCommand.examples).toBeDefined() + expect(TheCommand.examples.length).toBeGreaterThan(0) +}) + +test('exposes only the shared logging flags from RuntimeBaseCommand', () => { + // Inherits --debug and --verbose only; OpenWhisk-targeted flags + // (--apihost, --auth, --cert, --key, --insecure) are excluded because + // this command does not call OpenWhisk. + expect(TheCommand.flags.debug).toBe(RuntimeBaseCommand.flags.debug) + expect(TheCommand.flags.verbose).toBe(RuntimeBaseCommand.flags.verbose) + expect(TheCommand.flags.apihost).toBeUndefined() + expect(TheCommand.flags.auth).toBeUndefined() + expect(TheCommand.flags.insecure).toBeUndefined() +}) + +test('--service-host is hidden from public help', () => { + // Internal escape hatch for stage testing; not a supported endpoint + // override, so it is hidden from --help. + expect(TheCommand.flags['service-host'].hidden).toBe(true) +}) + +test('index command is an oclif help wrapper that extends RuntimeBaseCommand', () => { + expect(typeof IndexCommand).toEqual('function') + expect(IndexCommand.prototype instanceof RuntimeBaseCommand).toBeTruthy() + expect(IndexCommand.description).toBeDefined() +}) + +describe('resolveHost', () => { + test('defaults to the hard-coded service host', () => { + expect(TheCommand.resolveHost({})).toBe(TheCommand.DEFAULT_SERVICE_HOST) + }) + test('respects AIO_IP_LIST_HOST', () => { + process.env.AIO_IP_LIST_HOST = 'example.adobeioruntime.net' + expect(TheCommand.resolveHost({})).toBe('example.adobeioruntime.net') + }) + test('--service-host wins over AIO_IP_LIST_HOST and the default', () => { + process.env.AIO_IP_LIST_HOST = 'env-host.adobeioruntime.net' + expect(TheCommand.resolveHost({ 'service-host': 'flag-host.adobeioruntime.net' })).toBe('flag-host.adobeioruntime.net') + }) +}) + +describe('callService', () => { + test('POSTs JSON with token + imsOrgId merged into the body', async () => { + const fetchImpl = jest.fn().mockResolvedValue(fetchResponse(200, { ok: true })) + const result = await TheCommand.callService({ + host: 'host.adobeioruntime.net', + path: '/api/v1/web/ip-list/accept-terms', + token: 'abc', + orgId: 'BA3E@AdobeOrg', + body: { contactEmail: 'a@b.com' }, + fetchImpl + }) + expect(result.status).toBe(200) + expect(result.body).toEqual({ ok: true }) + expect(fetchImpl).toHaveBeenCalledWith( + 'https://host.adobeioruntime.net/api/v1/web/ip-list/accept-terms', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ) + const sent = JSON.parse(fetchImpl.mock.calls[0][1].body) + expect(sent).toEqual({ + contactEmail: 'a@b.com', + token: 'abc', + imsOrgId: 'BA3E@AdobeOrg' + }) + }) + + test('does NOT send Authorization / x-gw-ims-org-id headers', async () => { + // The token and imsOrgId travel in the JSON body so the service + // can validate the caller in-action rather than at the gateway. + const fetchImpl = jest.fn().mockResolvedValue(fetchResponse(200, { ok: true })) + await TheCommand.callService({ + host: 'h.adobeioruntime.net', + path: '/p', + token: 't', + orgId: 'o', + body: {}, + fetchImpl + }) + const sentHeaders = fetchImpl.mock.calls[0][1].headers + expect(sentHeaders.Authorization).toBeUndefined() + expect(sentHeaders['x-gw-ims-org-id']).toBeUndefined() + }) + + test('strips protocol / trailing slashes from host', async () => { + const fetchImpl = jest.fn().mockResolvedValue(fetchResponse(200, {})) + await TheCommand.callService({ + host: 'https://host.adobeioruntime.net///', + path: '/api/v1/web/ip-list/get-ip-list', + token: 't', + orgId: 'o', + body: { surface: 'cli' }, + fetchImpl + }) + expect(fetchImpl.mock.calls[0][0]).toBe('https://host.adobeioruntime.net/api/v1/web/ip-list/get-ip-list') + }) + + test('tolerates non-JSON bodies by returning the raw text', async () => { + const fetchImpl = jest.fn().mockResolvedValue(fetchResponse(503, '503')) + const result = await TheCommand.callService({ + host: 'h', path: '/p', token: 't', orgId: 'o', body: {}, fetchImpl + }) + expect(result.status).toBe(503) + expect(result.body).toBeNull() + expect(result.rawBody).toBe('503') + }) +}) + +describe('formatHumanOutput', () => { + test('groups CIDRs by region sorted alphabetically', () => { + const out = TheCommand.formatHumanOutput(IP_LIST_OK) + expect(out).toMatch(/version:\s+3/) + expect(out).toMatch(/lastUpdated:\s+2026-04-19/) + expect(out).toMatch(/AMER/) + expect(out).toMatch(/EMEA/) + // amer appears before emea alphabetically + expect(out.indexOf('AMER')).toBeLessThan(out.indexOf('EMEA')) + expect(out).toContain('44.207.149.158/32') + expect(out).toContain('44.208.197.195/32') + expect(out).toContain('52.18.22.1/32') + }) + + test('handles a freshly-deployed (empty) service gracefully', () => { + const out = TheCommand.formatHumanOutput({ regions: {}, version: 0, lastUpdated: null }) + expect(out).toMatch(/no regions populated/) + }) + + test('tolerates the legacy { cidrs: [...] } region envelope', () => { + // Forward-compat: a re-enveloped future wire shape must still render. + const out = TheCommand.formatHumanOutput({ + regions: { amer: { cidrs: ['10.0.0.1/32'] } }, + version: 1, + lastUpdated: '2026-04-21T00:00:00Z' + }) + expect(out).toContain('10.0.0.1/32') + }) +}) + +describe('run() — happy path', () => { + test('returns IPs without needing terms acceptance', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand([]) + await cmd.run() + expect(stdout.output).toContain('AMER') + expect(stdout.output).toContain('44.207.149.158/32') + // No accept-terms call is made when the user already has acceptance on file. + expect(global.fetch).toHaveBeenCalledTimes(1) + const call = global.fetch.mock.calls[0] + expect(call[0]).toMatch(/\/get-ip-list$/) + expect(call[1].method).toBe('POST') + const body = JSON.parse(call[1].body) + expect(body).toMatchObject({ + surface: 'cli', + token: 'fake-token', + imsOrgId: 'BA3E111222@AdobeOrg' + }) + expect(body.region).toBeUndefined() + }) + + test('--json emits a parseable JSON document', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--json']) + await cmd.run() + const parsed = JSON.parse(stdout.output) + expect(parsed).toEqual(IP_LIST_OK) + }) + + test('--region is forwarded to the service', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--region', 'amer']) + await cmd.run() + const body = JSON.parse(global.fetch.mock.calls[0][1].body) + expect(body.region).toBe('amer') + expect(body.surface).toBe('cli') + }) + + test('--service-host overrides the default', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--service-host', 'custom.adobeioruntime.net']) + await cmd.run() + expect(global.fetch.mock.calls[0][0]).toMatch(/^https:\/\/custom\.adobeioruntime\.net\//) + }) + + test('sends token + imsOrgId in every request body', async () => { + // Every outbound POST must carry the IMS token and org id in the + // body so the service can validate the caller in-action. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await cmd.run() + for (const call of global.fetch.mock.calls) { + const body = JSON.parse(call[1].body) + expect(body.token).toBe('fake-token') + expect(body.imsOrgId).toBe('BA3E111222@AdobeOrg') + } + }) +}) + +describe('run() — input validation', () => { + test('rejects an unknown region before making any HTTP call', async () => { + const cmd = makeCommand(['--region', 'mars']) + await expect(cmd.run()).rejects.toThrow(/invalid region "mars"/) + expect(global.fetch).not.toHaveBeenCalled() + }) + + test('rejects --accept-terms without --contact-email', async () => { + const cmd = makeCommand(['--accept-terms']) + await expect(cmd.run()).rejects.toThrow(/--accept-terms requires --contact-email/) + expect(global.fetch).not.toHaveBeenCalled() + }) + + test('errors out if no IMS org id is configured', async () => { + // Locks the exact multi-line wording so a future copy edit cannot + // silently regress the customer-facing first-use error. + config.get.mockReturnValue(undefined) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow( + 'IMS org id not found in aio config.\n\n' + + 'Run one of the following and try again:\n' + + ' aio console org select\n' + + ' aio app use' + ) + expect(global.fetch).not.toHaveBeenCalled() + }) + + test('falls back to console.org.code when project.org.ims_org_id is not set', async () => { + // After `aio console org select` without a subsequent `aio app use`, + // the IMS org id is stored at console.org.code rather than + // project.org.ims_org_id; the command must resolve it from there. + config.get.mockImplementation((key) => { + if (key === 'project.org.ims_org_id') return undefined + if (key === 'console.org.code') return 'C74F69D7594880280A495D09@AdobeOrg' + return undefined + }) + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand([]) + await cmd.run() + const sentBody = JSON.parse(global.fetch.mock.calls[0][1].body) + expect(sentBody.imsOrgId).toBe('C74F69D7594880280A495D09@AdobeOrg') + }) + + test('prefers project.org.ims_org_id over console.org.code when both are set', async () => { + // An explicit `aio app use` binding (project-local) takes precedence + // over the global `aio console org select` binding. + config.get.mockImplementation((key) => { + if (key === 'project.org.ims_org_id') return 'PROJECT111@AdobeOrg' + if (key === 'console.org.code') return 'CONSOLE222@AdobeOrg' + return undefined + }) + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand([]) + await cmd.run() + const sentBody = JSON.parse(global.fetch.mock.calls[0][1].body) + expect(sentBody.imsOrgId).toBe('PROJECT111@AdobeOrg') + }) +}) + +describe('run() — terms acceptance flow', () => { + test('accepts terms non-interactively with --accept-terms + --contact-email, then retries the GET', async () => { + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) // initial GET + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) // POST accept-terms + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) // retry GET + + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await cmd.run() + + expect(global.fetch).toHaveBeenCalledTimes(3) + + // Second call is the POST to accept-terms with the right body + surface=cli + // plus the token/imsOrgId the service needs to validate the caller. + const acceptCall = global.fetch.mock.calls[1] + expect(acceptCall[0]).toMatch(/\/accept-terms$/) + expect(acceptCall[1].method).toBe('POST') + expect(JSON.parse(acceptCall[1].body)).toEqual({ + contactEmail: 'ops@example.com', + termsVersion: 1, + surface: 'cli', + acceptanceMode: 'programmatic', + token: 'fake-token', + imsOrgId: 'BA3E111222@AdobeOrg' + }) + + // The human-readable IP list is printed on stdout after acceptance. + expect(stdout.output).toContain('AMER') + // Informational messages (terms text and acceptance notice) are + // written to stderr so --json consumers receive clean stdout. + expect(stderr.output).toMatch(/Terms v1 accepted/) + expect(stderr.output).toMatch(/agree to the terms/) + + // inquirer is not invoked when non-interactive flags are supplied. + expect(inquirer.prompt).not.toHaveBeenCalled() + }) + + test('`--json` first-use produces parseable JSON (terms text on stderr only)', async () => { + // With --json + --accept-terms on first use, informational output + // must remain on stderr so stdout stays a valid JSON document for + // scripted consumers (e.g. `aio runtime ip-list get --json | jq`). + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + + const cmd = makeCommand(['--json', '--accept-terms', '--contact-email', 'ops@example.com']) + await cmd.run() + + // stdout must be valid JSON, nothing else. + const parsed = JSON.parse(stdout.output) + expect(parsed).toEqual(IP_LIST_OK) + // terms text + acceptance line landed on stderr, not stdout. + expect(stderr.output).toMatch(/agree to the terms/) + expect(stderr.output).toMatch(/Terms v1 accepted/) + }) + + test('prompts interactively when --accept-terms is not passed', async () => { + inquirer.prompt = jest.fn().mockResolvedValue({ accept: true, contactEmail: 'ops@example.com' }) + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + + const cmd = makeCommand([]) + await cmd.run() + + expect(inquirer.prompt).toHaveBeenCalledTimes(1) + const acceptBody = JSON.parse(global.fetch.mock.calls[1][1].body) + expect(acceptBody.contactEmail).toBe('ops@example.com') + // Prompt-driven acceptance is recorded as "interactive" so the + // server can distinguish it from flag-driven runs. + expect(acceptBody.acceptanceMode).toBe('interactive') + expect(acceptBody.surface).toBe('cli') + }) + + test('bails out if the user declines at the interactive prompt', async () => { + inquirer.prompt = jest.fn().mockResolvedValue({ accept: false }) + global.fetch.mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow() + // The accept-terms POST is never sent on a declined prompt. + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + test('reports a helpful error when accept-terms POST itself fails', async () => { + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(500, { error: 'database offline' })) + + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await expect(cmd.run()).rejects.toThrow() + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + + test('surfaces a useful error if the service still returns TERMS_REQUIRED after acceptance', async () => { + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await expect(cmd.run()).rejects.toThrow() + }) +}) + +describe('IMS context selection', () => { + test('uses the active IMS context name when one is set (non-CLI)', async () => { + // When an IMS context is already active (for example via + // `aio auth login`), the command must request a token for that + // context rather than forcing the bare CLI context. + context.getCurrent.mockResolvedValue('user-context') + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + + const cmd = makeCommand([]) + await cmd.run() + + expect(getToken).toHaveBeenCalledWith('user-context') + // setCli runs only on the no-current-context fallback path. + expect(context.setCli).not.toHaveBeenCalled() + }) + + test('falls back to setCli when no current context exists', async () => { + // When no IMS context is active, the command falls back to the + // bare CLI context with `cli.bare-output: true`. + context.getCurrent.mockResolvedValue(null) + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + + const cmd = makeCommand([]) + await cmd.run() + + expect(context.setCli).toHaveBeenCalledWith({ 'cli.bare-output': true }, false) + expect(getToken).toHaveBeenCalledWith('cli') + }) +}) + +describe('interactive terms prompt config', () => { + test('email prompt validate + when callbacks exercise the regex and gating', async () => { + // The prompt config builds `validate` and `when` callbacks that + // inquirer invokes against user input. The mock invokes them + // directly so the closure variables match what inquirer would see. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + + inquirer.prompt = jest.fn().mockImplementation(async (questions) => { + const emailQ = questions.find((q) => q.name === 'contactEmail') + // validate(): rejects malformed input, accepts well-formed. + expect(emailQ.validate('not-an-email')).toBe('enter a valid email address') + expect(emailQ.validate('user@example.com')).toBe(true) + // when(): asks for the email iff `accept` is true and no + // --contact-email was supplied on the command line. + expect(emailQ.when({ accept: true })).toBe(true) + expect(emailQ.when({ accept: false })).toBe(false) + return { accept: true, contactEmail: 'ops@example.com' } + }) + + const cmd = makeCommand([]) + await cmd.run() + }) + + test('email prompt is skipped when --contact-email is already supplied', async () => { + // when() must return false so inquirer does not re-prompt for an + // email that was already provided on the command line. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + + inquirer.prompt = jest.fn().mockImplementation(async (questions) => { + const emailQ = questions.find((q) => q.name === 'contactEmail') + expect(emailQ.when({ accept: true })).toBe(false) + return { accept: true } + }) + + const cmd = makeCommand(['--contact-email', 'preset@example.com']) + await cmd.run() + }) + + test('errors if the user accepts at the prompt but provides no contact email', async () => { + // If the prompt returns accept=true with an empty contactEmail, + // the command exits before sending a malformed accept-terms request. + inquirer.prompt = jest.fn().mockResolvedValue({ accept: true }) + global.fetch.mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow() + // The accept-terms POST is never sent when the email is missing. + expect(global.fetch).toHaveBeenCalledTimes(1) + }) +}) + +describe('IndexCommand', () => { + test('run() invokes Help.showHelp for the ip-list command group', async () => { + // `aio runtime ip-list` with no subcommand delegates to oclif Help; + // spy on Help.prototype.showHelp to confirm the wiring. + const oclif = require('@oclif/core') + const showHelpSpy = jest.spyOn(oclif.Help.prototype, 'showHelp').mockResolvedValue() + + const cmd = new IndexCommand([], { + runHook: async () => ({ successes: [], failures: [] }), + bin: 'aio', + userAgent: 'test/0.0.0', + findCommand: () => null, + pjson: { oclif: {} } + }) + await cmd.run() + expect(showHelpSpy).toHaveBeenCalledWith(['runtime:ip-list', '--help']) + showHelpSpy.mockRestore() + }) + + test('exposes non-empty examples', () => { + expect(IndexCommand.examples).toBeDefined() + expect(IndexCommand.examples.length).toBeGreaterThan(0) + }) + + test('overrides flags to {} so the topic --help does not list OpenWhisk flags', () => { + // The topic command accepts no input, so OpenWhisk-targeted flags + // inherited from RuntimeBaseCommand must not appear in --help. + expect(IndexCommand.flags).toEqual({}) + for (const owFlag of ['apihost', 'auth', 'cert', 'key', 'insecure']) { + expect(IndexCommand.flags).not.toHaveProperty(owFlag) + } + }) +}) + +describe('customer-visible help text', () => { + test('IpListGet description does not reference auth mechanism in --help', () => { + // The authentication mechanism is an implementation detail that + // does not belong in the customer-facing description. + expect(TheCommand.description).toMatch(/Adobe I\/O Runtime egress IP allowlist/) + expect(TheCommand.description).not.toMatch(/IMS-authenticated/i) + expect(TheCommand.description).not.toMatch(/OAuth/i) + expect(TheCommand.description).not.toMatch(/JWT/i) + }) + + test('IpListGet flags do not expose RuntimeBaseCommand OpenWhisk flags', () => { + // --apihost / --auth / --cert / --key / --insecure are silently + // ignored by this command and must not appear in --help. + for (const owFlag of ['apihost', 'auth', 'cert', 'key', 'insecure']) { + expect(TheCommand.flags).not.toHaveProperty(owFlag) + } + // --service-host is the internal escape hatch: present in flags + // but hidden from --help output. + expect(TheCommand.flags['service-host']).toBeDefined() + expect(TheCommand.flags['service-host'].hidden).toBe(true) + }) +}) + +describe('branch-coverage edge cases', () => { + test('callService tolerates an undefined body', async () => { + // Exercises the `body || {}` short-circuit when callers omit body. + const fetchImpl = jest.fn().mockResolvedValue(fetchResponse(200, { ok: true })) + await TheCommand.callService({ + host: 'h', path: '/p', token: 't', orgId: 'o', fetchImpl + }) + const sent = JSON.parse(fetchImpl.mock.calls[0][1].body) + expect(sent).toEqual({ token: 't', imsOrgId: 'o' }) + }) + + test('callService surfaces an empty response body cleanly', async () => { + // For an empty body (e.g. 204 No Content), `body` must be null and + // `rawBody` must be the empty string — never undefined. + const fetchImpl = jest.fn().mockResolvedValue(fetchResponse(204, undefined)) + const r = await TheCommand.callService({ + host: 'h', path: '/p', token: 't', orgId: 'o', body: {}, fetchImpl + }) + expect(r.status).toBe(204) + expect(r.body).toBeNull() + expect(r.rawBody).toBe('') + }) + + test('formatHumanOutput tolerates a response with no `regions` key', () => { + // `data.regions || {}` short-circuit when the server omits the key. + const out = TheCommand.formatHumanOutput({ version: 0, lastUpdated: null }) + expect(out).toMatch(/no regions populated/) + }) + + test('formatHumanOutput tolerates a falsy `raw` value for a region', () => { + // A null region value (e.g. `{ regions: { amer: null } }`) must + // not throw; the region renders as "(0 CIDRs)". + const out = TheCommand.formatHumanOutput({ + regions: { amer: null }, + version: 1, + lastUpdated: '2026-04-21T00:00:00Z' + }) + expect(out).toMatch(/AMER/) + expect(out).toMatch(/0 CIDRs/) + }) + + test('error detail falls back to "http " when the server returns no body', async () => { + // Covers `|| `http ${res.status}`` in the get-ip-list error path. + global.fetch.mockResolvedValueOnce(fetchResponse(500, undefined)) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow(/500/) + }) + + test('accept-terms error detail falls back to rawBody when JSON body is missing', async () => { + // Covers the `|| acceptRes.rawBody` fallback when the failure + // response from accept-terms isn't JSON-parseable. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(502, 'bad gateway')) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await expect(cmd.run()).rejects.toThrow() + }) + + test('accept-terms error detail falls back to "http " with no body or rawBody', async () => { + // Covers the final `|| `http ${acceptRes.status}`` branch. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(500, undefined)) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await expect(cmd.run()).rejects.toThrow() + }) + + test('get-ip-list error detail uses body.message when no body.error is present', async () => { + // Covers the `res.body.message` branch of the detail-resolution chain. + global.fetch.mockResolvedValueOnce(fetchResponse(500, { message: 'oops' })) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow(/oops/) + }) + + test('accept-terms error detail uses body.message when no body.error is present', async () => { + // Covers the matching `acceptRes.body.message` branch. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(500, { message: 'db offline' })) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await expect(cmd.run()).rejects.toThrow(/db offline/) + }) + + test('accept-terms accepts a 201 Created response as success (not just 200)', async () => { + // The success guard treats 200 and 201 as equivalent; this + // exercises the 201 branch. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, TERMS_REQUIRED_BODY)) + .mockResolvedValueOnce(fetchResponse(201, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await cmd.run() + expect(global.fetch).toHaveBeenCalledTimes(3) + }) + + test('terms prompt omits the "Full terms" link when the server returns no termsUrl', async () => { + // Covers the `if (termsUrl)` falsy branch in handleTermsRequired. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, { ...TERMS_REQUIRED_BODY, termsUrl: undefined })) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await cmd.run() + expect(stderr.output).not.toMatch(/Full terms/) + }) + + test('terms prompt omits the body block when the server returns no termsText', async () => { + // Covers the `if (termsText)` falsy branch in handleTermsRequired. + global.fetch + .mockResolvedValueOnce(fetchResponse(403, { ...TERMS_REQUIRED_BODY, termsText: undefined })) + .mockResolvedValueOnce(fetchResponse(200, { ok: true })) + .mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--accept-terms', '--contact-email', 'ops@example.com']) + await cmd.run() + expect(stderr.output).not.toMatch(/agree to the terms/) + }) +}) + +describe('run() — server errors', () => { + test('surfaces a 503 raw HTML body (CloudFront-style) with a readable message', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(503, '503 Service Unavailable')) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow() + }) + + test('surfaces a JSON error.message from the server', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(500, { error: 'boom' })) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow() + }) +})