diff --git a/src/commands/runtime/ip-list/get.js b/src/commands/runtime/ip-list/get.js index 9a809b47..80312155 100644 --- a/src/commands/runtime/ip-list/get.js +++ b/src/commands/runtime/ip-list/get.js @@ -60,28 +60,33 @@ async function getAccessToken () { return getToken(contextName) } +const ORG_ID_KEYS = ['project.org.ims_org_id', 'console.org.code', 'ims.org_id'] + /** - * 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. + * Resolve the caller's IMS org id from aio config, returning the value + * along with the key and scope it was read from so error paths can + * route remediation to the right config surface. + * + * Key precedence: + * - `project.org.ims_org_id` — written by `aio app use` to both global + * config and the project's local `.aio` file; scope is probed + * explicitly since the key name alone is ambiguous. + * - `console.org.code` — written by `aio console org select` (global). + * - `ims.org_id` — legacy, retained for back-compat. * - * 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. + * Scope precedence matches aio-lib-core-config's merge order: + * env > local > global. * - * @returns {string|null} IMS org id in `...@AdobeOrg` form, or null. + * @returns {{orgId: string|null, key: string|null, scope: 'local'|'global'|'env'|null}} */ function resolveImsOrgId () { - return config.get('project.org.ims_org_id') || - config.get('console.org.code') || - config.get('ims.org_id') || - null + for (const key of ORG_ID_KEYS) { + for (const scope of ['env', 'local', 'global']) { + const value = config.get(key, scope) + if (value) return { orgId: value, key, scope } + } + } + return { orgId: null, key: null, scope: null } } /** @@ -187,6 +192,48 @@ async function postAcceptTerms ({ host, token, orgId, contactEmail, termsVersion }) } +/** + * Build the error message shown when the service returns 403 because the IMS token does not grant access to the org id we sent. The + * remediation depends on where the org id came from: if it was read from the local .aio config, the user needs to update that config + * (e.g. by re-running `aio app use`). But if it came from the global config or env, the user needs to save their saved org selection (e.g. by re-running `aio console org select`). + * + * @param {object} opts + * @param {string} opts.orgId - org id rejected by the service. + * @param {string} opts.orgIdKey - aio config key it was read from. + * @param {'local'|'global'|'env'} opts.orgIdScope - source surface. + * @returns {string} multi-line message for this.error(). + */ +function formatStaleOrgError ({ orgId, orgIdKey, orgIdScope }) { + if (orgIdScope === 'local') { + return ( + "Unable to fetch Runtime egress IPs for this project's Adobe org.\n\n" + + "This project's local .aio is configured to use:\n" + + ` ${orgId}\n` + + ` (from '${orgIdKey}')\n\n` + + 'but your current Adobe I/O login does not appear to have access to that org.\n\n' + + 'To see the orgs available to your current login, run:\n' + + ' aio console org list\n\n' + + `Then either log in as an account with access to ${orgId}, ` + + 'or re-bind this project to an accessible org with:\n' + + ' aio app use' + ) + } + // global, env, or unknown — all read as a "saved" CLI selection. + return ( + 'Unable to fetch Runtime egress IPs for the saved Adobe org.\n\n' + + 'The CLI is using the saved org:\n' + + ` ${orgId}\n` + + ` (from '${orgIdKey}'${orgIdScope === 'env' ? ' env var' : ''})\n\n` + + 'but your current Adobe I/O login does not appear to have access to it.\n\n' + + 'To see the orgs available to your current login, run:\n' + + ' aio console org list\n\n' + + 'Then update the saved org selection:\n' + + ' aio console org select\n\n' + + 'After that, retry:\n' + + ' aio runtime ip-list get' + ) +} + /** * Render the ip-list service response as a terminal-friendly block * grouped by region. @@ -231,13 +278,15 @@ class IpListGet extends RuntimeBaseCommand { // or this.wsk(). async run () { const { flags } = await this.parse(IpListGet) - if (flags.region && !VALID_REGIONS.includes(flags.region)) { + const region = flags.region && flags.region.toLowerCase() + if (region && !VALID_REGIONS.includes(region)) { this.error(`invalid region "${flags.region}". Expected one of: ${VALID_REGIONS.join(', ')}`, { exit: 1 }) } + flags.region = region if (flags['accept-terms'] && !flags['contact-email']) { this.error('--accept-terms requires --contact-email', { exit: 1 }) } - const orgId = resolveImsOrgId() + const { orgId, key: orgIdKey, scope: orgIdScope } = resolveImsOrgId() if (!orgId) { this.error( 'IMS org id not found in aio config.\n\n' + @@ -249,13 +298,15 @@ class IpListGet extends RuntimeBaseCommand { } try { - await this.runPipeline(flags, orgId) + await this.runPipeline(flags, orgId, orgIdKey, orgIdScope) } catch (err) { + // re-throw them as-is so the customer sees only the friendly message, no prefix or stack trace + if (err && err.oclif) throw err await this.handleError('failed to fetch the egress IP list', err) } } - async runPipeline (flags, orgId) { + async runPipeline (flags, orgId, orgIdKey, orgIdScope) { const host = resolveHost(flags) const token = await getAccessToken() @@ -272,6 +323,11 @@ class IpListGet extends RuntimeBaseCommand { } } + if (res.status === 403 && res.body && /does not grant access/i.test(res.body.error || '')) { + // The token is valid, but the resolved org is not accessible to the current login + this.error(formatStaleOrgError({ orgId, orgIdKey, orgIdScope }), { exit: 1 }) + } + 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}`) diff --git a/test/commands/runtime/ip-list/get.test.js b/test/commands/runtime/ip-list/get.test.js index d0bd17c4..82aa72e5 100644 --- a/test/commands/runtime/ip-list/get.test.js +++ b/test/commands/runtime/ip-list/get.test.js @@ -94,8 +94,12 @@ beforeEach(() => { stdout.start() stderr.start() - config.get.mockImplementation((key) => { - if (key === 'project.org.ims_org_id') return 'BA3E111222@AdobeOrg' + // Default binding: project.org.ims_org_id in global scope. Tests that + // need a local-.aio or env binding override this in-place. + config.get.mockImplementation((key, scope) => { + if (key === 'project.org.ims_org_id' && (scope === undefined || scope === 'global')) { + return 'BA3E111222@AdobeOrg' + } return undefined }) context.getCurrent.mockResolvedValue(null) @@ -312,6 +316,22 @@ describe('run() — happy path', () => { expect(body.surface).toBe('cli') }) + test('--region accepts uppercase and forwards lowercase', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--region', 'AUS']) + await cmd.run() + const body = JSON.parse(global.fetch.mock.calls[0][1].body) + expect(body.region).toBe('aus') + }) + + test('--region accepts mixed case and forwards lowercase', async () => { + global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) + const cmd = makeCommand(['--region', 'Aus']) + await cmd.run() + const body = JSON.parse(global.fetch.mock.calls[0][1].body) + expect(body.region).toBe('aus') + }) + test('--service-host overrides the default', async () => { global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) const cmd = makeCommand(['--service-host', 'custom.adobeioruntime.net']) @@ -343,6 +363,12 @@ describe('run() — input validation', () => { expect(global.fetch).not.toHaveBeenCalled() }) + test('unknown region error preserves the user-supplied casing', 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/) @@ -367,9 +393,11 @@ describe('run() — input validation', () => { // 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) => { + config.get.mockImplementation((key, scope) => { if (key === 'project.org.ims_org_id') return undefined - if (key === 'console.org.code') return 'C74F69D7594880280A495D09@AdobeOrg' + if (key === 'console.org.code' && (scope === undefined || scope === 'global')) { + return 'C74F69D7594880280A495D09@AdobeOrg' + } return undefined }) global.fetch.mockResolvedValueOnce(fetchResponse(200, IP_LIST_OK)) @@ -382,7 +410,8 @@ describe('run() — input validation', () => { 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) => { + config.get.mockImplementation((key, scope) => { + if (scope !== undefined && scope !== 'global') return undefined if (key === 'project.org.ims_org_id') return 'PROJECT111@AdobeOrg' if (key === 'console.org.code') return 'CONSOLE222@AdobeOrg' return undefined @@ -395,6 +424,133 @@ describe('run() — input validation', () => { }) }) +describe('run() — stale org id (403 token/org mismatch)', () => { + // Service envelope: 403 with `{error: "token does not grant access to org X@AdobeOrg"}`. + // Remediation routes by the source scope of the resolved org id: + // global/env → "saved org" + `aio console org select`; local → "this + // project" + `aio app use`. + + test('global-config scope surfaces the "saved org" remediation', async () => { + config.get.mockImplementation((key, scope) => { + if (scope !== undefined && scope !== 'global') return undefined + if (key === 'console.org.code') return 'STALE111@AdobeOrg' + return undefined + }) + global.fetch.mockResolvedValueOnce(fetchResponse(403, { + error: 'token does not grant access to org STALE111@AdobeOrg' + })) + let caught + try { await makeCommand([]).run() } catch (e) { caught = e } + expect(caught).toBeDefined() + expect(caught.message).toMatch(/saved Adobe org/) + expect(caught.message).toMatch(/STALE111@AdobeOrg/) + expect(caught.message).toMatch(/console\.org\.code/) + expect(caught.message).toMatch(/aio console org list/) + expect(caught.message).toMatch(/aio console org select/) + // `aio app use` is the local-binding remediation; it must not + // appear on the global-scope path. + expect(caught.message).not.toMatch(/aio app use/) + }) + + test('local .aio scope surfaces the project-binding remediation', async () => { + config.get.mockImplementation((key, scope) => { + if (key === 'project.org.ims_org_id' && (scope === undefined || scope === 'local')) { + return 'PROJBOUND@AdobeOrg' + } + return undefined + }) + global.fetch.mockResolvedValueOnce(fetchResponse(403, { + error: 'token does not grant access to org PROJBOUND@AdobeOrg' + })) + let caught + try { await makeCommand([]).run() } catch (e) { caught = e } + expect(caught).toBeDefined() + expect(caught.message).toMatch(/this project's Adobe org/) + expect(caught.message).toMatch(/PROJBOUND@AdobeOrg/) + expect(caught.message).toMatch(/project\.org\.ims_org_id/) + expect(caught.message).toMatch(/aio app use/) + expect(caught.message).toMatch(/aio console org list/) + // `aio console org select` would mutate the global selection + // without fixing the local .aio binding that resolved here. + expect(caught.message).not.toMatch(/aio console org select/) + }) + + test('env-var scope surfaces the "saved org" remediation and labels the source as env', async () => { + config.get.mockImplementation((key, scope) => { + if (key === 'project.org.ims_org_id' && scope === 'env') return 'ENVORG@AdobeOrg' + return undefined + }) + global.fetch.mockResolvedValueOnce(fetchResponse(403, { + error: 'token does not grant access to org ENVORG@AdobeOrg' + })) + let caught + try { await makeCommand([]).run() } catch (e) { caught = e } + expect(caught).toBeDefined() + expect(caught.message).toMatch(/saved Adobe org/) + expect(caught.message).toMatch(/env var/) + }) + + test('non-matching 403 body falls through to the generic error', async () => { + // Unrelated 403s (auth failures, etc.) must not be misclassified. + // TERMS_REQUIRED is matched earlier and covered separately. + global.fetch.mockResolvedValueOnce(fetchResponse(403, { error: 'something else' })) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow(/ip-list service returned 403: something else/) + }) + + test('403 with a non-JSON body falls through to the generic error', async () => { + // A 403 from an upstream proxy may arrive as HTML rather than JSON; + // res.body is null and the stale-org matcher must short-circuit + // instead of throwing on the missing field. + global.fetch.mockResolvedValueOnce(fetchResponse(403, 'forbidden')) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow(/ip-list service returned 403/) + }) + + test('403 with a JSON body missing `error` falls through to the generic error', async () => { + // res.body is present but does not carry an `error` field, so the + // matcher exercises the `res.body.error || ''` fallback and short-circuits. + global.fetch.mockResolvedValueOnce(fetchResponse(403, { message: 'forbidden' })) + const cmd = makeCommand([]) + await expect(cmd.run()).rejects.toThrow(/ip-list service returned 403: forbidden/) + }) + + test('unexpected (non-CLIError) exceptions are wrapped by handleError', async () => { + // Inverse of the regression test below: a plain Error from inside + // runPipeline (e.g. IMS unreachable) should be wrapped with the + // "failed to fetch the egress IP list:" prefix so the user gets + // context rather than a bare stack trace. + getToken.mockRejectedValueOnce(new Error('IMS unreachable')) + let caught + try { await makeCommand([]).run() } catch (e) { caught = e } + expect(caught).toBeDefined() + expect(caught.message).toMatch(/failed to fetch the egress IP list/) + expect(caught.message).toMatch(/IMS unreachable/) + }) + + test('friendly stale-org error is not re-wrapped by the outer error handler', async () => { + // RuntimeBaseCommand#handleError prefixes inner errors with + // "failed to fetch the egress IP list:" and appends a + // "--verbose flag for more information" hint. That wrapping is + // appropriate for unexpected exceptions, not for messages the + // command crafted on purpose; the stale-org message must reach + // the user verbatim. + config.get.mockImplementation((key, scope) => { + if (scope !== undefined && scope !== 'global') return undefined + if (key === 'console.org.code') return 'STALE111@AdobeOrg' + return undefined + }) + global.fetch.mockResolvedValueOnce(fetchResponse(403, { + error: 'token does not grant access to org STALE111@AdobeOrg' + })) + let caught + try { await makeCommand([]).run() } catch (e) { caught = e } + expect(caught).toBeDefined() + expect(caught.message).not.toMatch(/failed to fetch the egress IP list/) + expect(caught.message).not.toMatch(/--verbose flag for more information/) + }) +}) + describe('run() — terms acceptance flow', () => { test('accepts terms non-interactively with --accept-terms + --contact-email, then retries the GET', async () => { global.fetch