Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 77 additions & 21 deletions src/commands/runtime/ip-list/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,28 +60,33 @@
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}}

Check warning on line 80 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (20.x, windows-latest)

Missing JSDoc @returns description

Check warning on line 80 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (22.x, windows-latest)

Missing JSDoc @returns description

Check warning on line 80 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (20.x, ubuntu-latest)

Missing JSDoc @returns description

Check warning on line 80 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (24.x, ubuntu-latest)

Missing JSDoc @returns description

Check warning on line 80 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (22.x, ubuntu-latest)

Missing JSDoc @returns description

Check warning on line 80 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (24.x, windows-latest)

Missing JSDoc @returns description
*/
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 }
}

/**
Expand Down Expand Up @@ -187,6 +192,48 @@
})
}

/**
* 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

Check warning on line 200 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (20.x, windows-latest)

Missing JSDoc @param "opts" description

Check warning on line 200 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (22.x, windows-latest)

Missing JSDoc @param "opts" description

Check warning on line 200 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (20.x, ubuntu-latest)

Missing JSDoc @param "opts" description

Check warning on line 200 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (24.x, ubuntu-latest)

Missing JSDoc @param "opts" description

Check warning on line 200 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (22.x, ubuntu-latest)

Missing JSDoc @param "opts" description

Check warning on line 200 in src/commands/runtime/ip-list/get.js

View workflow job for this annotation

GitHub Actions / build / build (24.x, windows-latest)

Missing JSDoc @param "opts" description
* @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.
Expand Down Expand Up @@ -231,13 +278,15 @@
// 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' +
Expand All @@ -249,13 +298,15 @@
}

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()

Expand All @@ -272,6 +323,11 @@
}
}

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}`)
Expand Down
166 changes: 161 additions & 5 deletions test/commands/runtime/ip-list/get.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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/)
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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, '<html>forbidden</html>'))
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
Expand Down
Loading