diff --git a/README.md b/README.md index 078d5d42..154651c9 100644 --- a/README.md +++ b/README.md @@ -687,6 +687,7 @@ SIP trunks connect a PBX to WhatsApp calls. They are part of Calls and need no enrollment. Commands require a server API key. ```shell +polymorfa sip-trunk endpoint polymorfa sip-trunk list --project 018f0000-0000-7000-8000-000000000002 polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 polymorfa sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json @@ -704,6 +705,18 @@ passwords never appear in the command line. `create` refuses a body that names exits with code 4 when the trunk changed; `delete` exits with code 4 while a session routes calls to the trunk. +`endpoint` prints the host your PBX sends SIP to, each transport with its port +and whether it requires SRTP, and the UDP port range call audio uses. The +address is the same for every trunk. Allow SIP and RTP in both directions +between your PBX and the addresses the host resolves to. When the environment +does not host SIP, `endpoint` prints `This environment has no SIP address.` +and exits with code 0; `--json` returns the `sip_not_hosted` status with a null +host and RTP range and no transports. `create` and `rotate-credentials` also +print the password before looking up the SIP address in human output. The +optional address lookup stops after five seconds without retrying and respects +a shorter `--timeout`. If it fails +or times out, the command keeps the password on screen and exits successfully. + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 26fc13c7..91ce75f7 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -3,7 +3,10 @@ - **Status:** Implemented against Calls contract revision 1, unpublished - **Reviewed:** 2026-09-17 - **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` - subpath) `0.1.0-dev.20260922102711` from npm. The research was done against + subpath) `0.1.0-dev.20260922115749` from npm, published from `polymorfa/sdks` + commit `8bc00778b3b3aaf05b09369c3f7840eb24cf6a56` by + [workflow run 35724232746](https://github.com/polymorfa/sdks/actions/runs/35724232746). + The research was done against `polymorfa/sdks` commit `94bfd09825817f842434e206e6a77adb5cc0c414`; the npm build also reports a `claim` activity when the placed call is answered. @@ -51,6 +54,7 @@ calls | `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | | `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | | `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | +| `sip-trunk endpoint` | `GET /platform/sip/endpoint` | `sipTrunks.endpoint` | | `sip-trunk list` | `GET /platform/sip-trunks` | `sipTrunks.list` | | `sip-trunk view` | `GET /platform/sip-trunks/{id}` | `sipTrunks.retrieve` | | `sip-trunk create` | `POST /platform/sip-trunks` | `sipTrunks.create` | @@ -60,6 +64,14 @@ calls | `calls watch` | Lifecycle socket | `CallsClient` events | | `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` | +`sip-trunk endpoint` reads the address with `sipTrunks.endpoint` and checks +the response shape before printing it, so a malformed answer fails instead of +printing a partial address. The human output of `sip-trunk create` and +`sip-trunk rotate-credentials` prints the one-time credentials before looking up +this address. That optional lookup has a five-second deadline and no retries; +a timeout omits the appendix and leaves the successful credential operation's +exit status unchanged. JSON output does not perform the optional lookup. + The CLI uses only the neutral public calling surface. It does not expose offers, candidates, session descriptions, or media framing, and it imports no internal SDK module. @@ -132,5 +144,8 @@ internal SDK module. - `calls watch` does not report outbound calls placed by other participants. - `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is published to npm only as dev prereleases; the CLI pins exactly - `0.1.0-dev.20260922102711`. The CLI cannot release until a stable + `0.1.0-dev.20260922115749`. The CLI cannot release until a stable `@polymorfa/sdk` is published with a compatible semver range. + +The optional SIP address appendix respects a shorter global `--timeout`; its +five-second abort signal is an upper bound, not a per-request timeout override. diff --git a/package-lock.json b/package-lock.json index f71d41a6..df915eac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260922102711", + "@polymorfa/sdk": "0.1.0-dev.20260922115749", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260922102711", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260922102711.tgz", - "integrity": "sha512-m2cnvInBZr4HZKslZewikM48mQIcREHuHJ3oF+/My/DN7pscwquTTkN6RM0mIQfxV+848hzsJZYoIqWxTgvqQA==", + "version": "0.1.0-dev.20260922115749", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260922115749.tgz", + "integrity": "sha512-ezfryhK10r6W84CUR3OFNHz1riChzWraSjKQuOZEE/pn+Okx9qMHhc34BDimorSeMWQnUW3pQKew12b2xrWyCw==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index b16ea713..d34b4732 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260922102711", + "@polymorfa/sdk": "0.1.0-dev.20260922115749", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/sip-trunk/create.ts b/src/commands/sip-trunk/create.ts index f0eeb1d8..4c3fbd2e 100644 --- a/src/commands/sip-trunk/create.ts +++ b/src/commands/sip-trunk/create.ts @@ -6,11 +6,13 @@ import { createSipTrunk, renderSipTrunkCreated, requireSipTrunkProject, + sipEndpointAppendix, + trySipEndpoint, } from '../../services/sip-trunks.js' export default class SipTrunkCreate extends BaseCommand { static override description = - 'Create a SIP trunk from one JSON object with name, direction, and the outbound and inbound sections the direction needs. Keep passwords in the input file or stdin, not in arguments. A trunk that accepts calls from your PBX returns its password once. Requires a server API key.' + 'Create a SIP trunk from one JSON object with name, direction, and the outbound and inbound sections the direction needs. Keep passwords in the input file or stdin, not in arguments. A trunk that accepts calls from your PBX returns its password once, followed by the SIP address your PBX sends calls to. Requires a server API key.' static override examples = [ '<%= config.bin %> sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json', @@ -27,11 +29,21 @@ export default class SipTrunkCreate extends BaseCommand { async run(): Promise { const {flags} = await this.parse(SipTrunkCreate) + const client = await this.platformClient(flags) const result = await createSipTrunk( - await this.platformClient(flags), + client, requireSipTrunkProject(flags.project), await readJsonInput(flags.input), ) + // Print the one-time password before anything else can fail or hang. this.writeValue(result, renderSipTrunkCreated(result.data), flags) + if ( + result.data.inboundCredentials !== undefined && + this.outputMode(flags) === 'human' + ) { + const endpoint = await trySipEndpoint(client) + if (endpoint !== undefined) + process.stdout.write(sipEndpointAppendix(endpoint)) + } } } diff --git a/src/commands/sip-trunk/endpoint.ts b/src/commands/sip-trunk/endpoint.ts new file mode 100644 index 00000000..dc040a0a --- /dev/null +++ b/src/commands/sip-trunk/endpoint.ts @@ -0,0 +1,20 @@ +import {BaseCommand} from '../../base-command.js' +import {getSipEndpoint, renderSipEndpoint} from '../../services/sip-trunks.js' + +export default class SipTrunkEndpoint extends BaseCommand { + static override description = + 'Show the host and ports your PBX sends SIP to, whether each transport requires SRTP, and the UDP port range call audio uses. The address is the same for every trunk. When this environment does not host SIP, the command says so and prints no address. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> sip-trunk endpoint', + '<%= config.bin %> sip-trunk endpoint --json', + ] + + static override summary = 'Show the SIP address for your PBX.' + + async run(): Promise { + const {flags} = await this.parse(SipTrunkEndpoint) + const result = await getSipEndpoint(await this.platformClient(flags)) + this.writeValue(result, renderSipEndpoint(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/rotate-credentials.ts b/src/commands/sip-trunk/rotate-credentials.ts index d7148f2e..42ecc564 100644 --- a/src/commands/sip-trunk/rotate-credentials.ts +++ b/src/commands/sip-trunk/rotate-credentials.ts @@ -4,6 +4,8 @@ import {BaseCommand} from '../../base-command.js' import { renderSipTrunkCredentials, rotateSipTrunkCredentials, + sipEndpointAppendix, + trySipEndpoint, } from '../../services/sip-trunks.js' export default class SipTrunkRotateCredentials extends BaseCommand { @@ -12,7 +14,7 @@ export default class SipTrunkRotateCredentials extends BaseCommand { } static override description = - 'Replace the password your PBX uses to place calls through the trunk and print it once. The previous password stops working within about a minute. Requires a server API key.' + 'Replace the password your PBX uses to place calls through the trunk and print it once with the SIP address your PBX sends calls to. The previous password stops working within about a minute. Requires a server API key.' static override flags = { yes: Flags.boolean({ @@ -25,10 +27,14 @@ export default class SipTrunkRotateCredentials extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(SipTrunkRotateCredentials) - const result = await rotateSipTrunkCredentials( - await this.platformClient(flags), - args.trunk, - ) + const client = await this.platformClient(flags) + const result = await rotateSipTrunkCredentials(client, args.trunk) + // Print the one-time password before anything else can fail or hang. this.writeValue(result, renderSipTrunkCredentials(result.data), flags) + if (this.outputMode(flags) === 'human') { + const endpoint = await trySipEndpoint(client) + if (endpoint !== undefined) + process.stdout.write(sipEndpointAppendix(endpoint)) + } } } diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index df752da7..6cee3c09 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -1,12 +1,16 @@ import { PolymorfaConfigurationError, + PolymorfaServerError, PolymorfaValidationError, type Client as PlatformClient, type CreateSipTrunkInput, + type RequestOptions, type ResponseMetadata, type SipTrunk, type SipTrunkCreated, type SipTrunkCredentials, + type SipEndpoint, + type SipEndpointTransport, type SipTrunkDeleted, } from '@polymorfa/sdk' @@ -121,6 +125,56 @@ export async function rotateSipTrunkCredentials( return result(response.data, response.metadata) } +export type { + SipEndpoint, + SipEndpointHosted, + SipEndpointNotHosted, + SipEndpointRtp, + SipEndpointTransport, +} from '@polymorfa/sdk' + +export type SipEndpointTransportName = SipEndpointTransport['transport'] + +/** + * The SIP address of the environment, from `GET /platform/sip/endpoint`. + * `sip_not_hosted` is a normal answer, not a failure. The shape is checked + * before it is rendered, so a malformed answer fails instead of printing a + * partial address. + */ +export async function getSipEndpoint( + client: PlatformClient, + options: RequestOptions = {}, +): Promise> { + const response = await client.sipTrunks.endpoint(options) + return result( + decodeSipEndpoint(response.data, response.metadata), + response.metadata, + ) +} + +/** + * The SIP address to print next to new inbound credentials. A failure here + * must never hide a password that can be shown only once, so it yields + * undefined instead of throwing. + */ +export async function trySipEndpoint( + client: PlatformClient, +): Promise { + try { + return ( + await getSipEndpoint(client, { + maxNetworkRetries: 0, + signal: AbortSignal.timeout(SIP_ENDPOINT_APPENDIX_TIMEOUT_MS), + }) + ).data + } catch { + return undefined + } +} + +/** The appendix is optional, so its lookup never keeps the command waiting long. */ +const SIP_ENDPOINT_APPENDIX_TIMEOUT_MS = 5_000 + // ── Rendering ────────────────────────────────────────────────────────────── export function renderSipTrunks(trunks: readonly SipTrunk[]): string { @@ -193,6 +247,37 @@ export function renderSipTrunkCredentials( ].join('\n') } +export function renderSipEndpoint(endpoint: SipEndpoint): string { + if (endpoint.status !== 'hosted') { + return 'This environment has no SIP address.' + } + const lines = [`Host\t${endpoint.host}`] + for (const transport of endpoint.transports) { + lines.push( + `Transport\t${transport.transport.toUpperCase()} ${transport.port}, SRTP ${ + transport.srtp === 'required' ? 'required' : 'not supported' + }`, + ) + } + lines.push( + `RTP\t${endpoint.rtp.protocol.toUpperCase()} ${endpoint.rtp.portMin}-${endpoint.rtp.portMax}`, + ) + lines.push( + `Allow SIP and RTP in both directions between your PBX and the addresses ${endpoint.host} resolves to.`, + ) + return lines.join('\n') +} + +/** + * Human output that follows one-time credentials: a blank line and the SIP + * address the PBX sends calls to. Commands print the credentials first and + * only then look the address up, so a slow or interrupted lookup can never + * cost the only copy of a password. + */ +export function sipEndpointAppendix(endpoint: SipEndpoint): string { + return `\nSIP address\n${renderSipEndpoint(endpoint)}\n` +} + export function renderSipTrunkDeleted(deleted: SipTrunkDeleted): string { return `Deleted SIP trunk ${deleted.id}.` } @@ -214,6 +299,69 @@ function validateTrunkId(trunkId: string): string { return trimmed } +const SIP_TRANSPORTS: ReadonlySet = new Set(['udp', 'tcp', 'tls']) + +function decodeSipEndpoint( + data: unknown, + metadata: ResponseMetadata, +): SipEndpoint { + if (isSipEndpoint(data)) return data + throw new PolymorfaServerError( + 'The Polymorfa API returned an invalid SIP address response.', + { + code: 'invalid_response', + details: data, + metadata, + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + ) +} + +function isSipEndpoint(value: unknown): value is SipEndpoint { + if (!isRecord(value) || !Array.isArray(value.transports)) return false + if (value.status === 'sip_not_hosted') { + return ( + value.host === null && value.rtp === null && value.transports.length === 0 + ) + } + if (value.status !== 'hosted') return false + if (typeof value.host !== 'string' || value.host.length === 0) return false + const rtp = value.rtp + if ( + !isRecord(rtp) || + rtp.protocol !== 'udp' || + !isPort(rtp.portMin) || + !isPort(rtp.portMax) || + rtp.portMin > rtp.portMax + ) { + return false + } + if (value.transports.length === 0) return false + return value.transports.every( + (transport) => + isRecord(transport) && + typeof transport.transport === 'string' && + SIP_TRANSPORTS.has(transport.transport) && + isPort(transport.port) && + (transport.srtp === 'required' || transport.srtp === 'not_supported'), + ) +} + +function isPort(value: unknown): value is number { + return ( + Number.isInteger(value) && + (value as number) > 0 && + (value as number) <= 65535 + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function objectInput(input: unknown, label: string): Record { if (typeof input !== 'object' || input === null || Array.isArray(input)) { throw new PolymorfaValidationError( diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index 1a97db2b..2390e32e 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -1,7 +1,11 @@ import {runCommand} from '@oclif/test' +import {spawn} from 'node:child_process' import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {createServer} from 'node:http' +import type {AddressInfo} from 'node:net' import {tmpdir} from 'node:os' import {join} from 'node:path' +import {fileURLToPath} from 'node:url' import {afterEach, describe, expect, test} from 'vitest' import { @@ -43,6 +47,24 @@ const trunk = { updatedAt: '2026-09-17T10:00:00.000Z', } +const hostedEndpoint = { + status: 'hosted', + host: 'sip.example.test', + transports: [ + {transport: 'udp', port: 5060, srtp: 'not_supported'}, + {transport: 'tcp', port: 5060, srtp: 'not_supported'}, + {transport: 'tls', port: 5061, srtp: 'required'}, + ], + rtp: {protocol: 'udp', portMin: 20000, portMax: 20999}, +} + +const notHostedEndpoint = { + status: 'sip_not_hosted', + host: null, + transports: [], + rtp: null, +} + describe('sip-trunk commands', () => { let server: TestHttpServer | undefined let directory: string | undefined @@ -83,20 +105,23 @@ describe('sip-trunk commands', () => { }) test('creates a trunk from a file and prints the one-time password', async () => { - server = await startHttpServer({ - body: { - data: { - inboundCredentials: { - password: 'one-time-secret', - realm: 'sip.example.test', - username: 'pmfa_abc', + server = await startHttpSequenceServer([ + { + body: { + data: { + inboundCredentials: { + password: 'one-time-secret', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + trunk, }, - trunk, + success: true, }, - success: true, + status: 201, }, - status: 201, - }) + {body: {data: hostedEndpoint, success: true}}, + ]) directory = await mkdtemp(join(tmpdir(), 'polymorfa-sip-')) const input = join(directory, 'trunk.json') const body = { @@ -126,12 +151,305 @@ describe('sip-trunk commands', () => { expect(created.error).toBeUndefined() expect(created.stdout).toContain('Password\tone-time-secret') expect(created.stdout).toContain('cannot be shown again') + expect(created.stdout).toContain( + 'SIP address\nHost\tsip.example.test\nTransport\tUDP 5060, SRTP not supported', + ) expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ ...body, projectId: PROJECT, }) expect(exitCode(refused.error)).toBe(2) - expect(server.requests).toHaveLength(1) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /platform/sip-trunks', + 'GET /platform/sip/endpoint', + ]) + }) + + test('shows the SIP address of the environment', async () => { + server = await startHttpSequenceServer([ + {body: {data: hostedEndpoint, success: true}}, + {body: {data: hostedEndpoint, success: true}}, + {body: {data: notHostedEndpoint, success: true}}, + ]) + + const hosted = await apiKey(() => runCommand('sip-trunk endpoint')) + const json = await apiKey(() => runCommand('sip-trunk endpoint --json')) + const notHosted = await apiKey(() => runCommand('sip-trunk endpoint')) + + expect(hosted.error).toBeUndefined() + expect(hosted.stdout).toBe( + [ + 'Host\tsip.example.test', + 'Transport\tUDP 5060, SRTP not supported', + 'Transport\tTCP 5060, SRTP not supported', + 'Transport\tTLS 5061, SRTP required', + 'RTP\tUDP 20000-20999', + 'Allow SIP and RTP in both directions between your PBX and the addresses sip.example.test resolves to.', + '', + ].join('\n'), + ) + expect(json.error).toBeUndefined() + expect((JSON.parse(json.stdout) as {data: unknown}).data).toEqual( + hostedEndpoint, + ) + expect(notHosted.error).toBeUndefined() + expect(notHosted.stdout).toBe('This environment has no SIP address.\n') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sip/endpoint', + 'GET /platform/sip/endpoint', + 'GET /platform/sip/endpoint', + ]) + }) + + test('reports sip_not_hosted as data in JSON output', async () => { + server = await startHttpServer({ + body: {data: notHostedEndpoint, success: true}, + }) + + const json = await apiKey(() => runCommand('sip-trunk endpoint --json')) + + expect(json.error).toBeUndefined() + expect((JSON.parse(json.stdout) as {data: unknown}).data).toEqual( + notHostedEndpoint, + ) + }) + + test('fails on an API error or an invalid SIP address response', async () => { + server = await startHttpSequenceServer([ + { + body: { + error: { + code: 'permission_denied', + message: 'The credential does not have access to this resource.', + type: 'permission_error', + }, + success: false, + }, + status: 403, + }, + { + body: { + data: {...notHostedEndpoint, host: 'made-up.example.test'}, + success: true, + }, + }, + ]) + + const denied = await apiKey(() => runCommand('sip-trunk endpoint')) + const invalid = await apiKey(() => runCommand('sip-trunk endpoint')) + + expect(exitCode(denied.error)).toBe(3) + expect(denied.stderr).toContain('does not have access') + expect(denied.stdout).toBe('') + expect(invalid.error).toBeDefined() + expect(invalid.stdout).not.toContain('made-up.example.test') + }) + + test('refuses a hosted address without transports or an RTP range', async () => { + server = await startHttpSequenceServer([ + { + body: {data: {...hostedEndpoint, rtp: null}, success: true}, + status: 200, + }, + { + body: {data: {...hostedEndpoint, transports: []}, success: true}, + status: 200, + }, + ]) + + const withoutRtp = await apiKey(() => runCommand('sip-trunk endpoint')) + const withoutTransports = await apiKey(() => + runCommand('sip-trunk endpoint'), + ) + + expect(withoutRtp.error).toBeDefined() + expect(withoutRtp.stdout).not.toContain('sip.example.test') + expect(withoutTransports.error).toBeDefined() + expect(withoutTransports.stdout).not.toContain('sip.example.test') + }) + + test.each([ + ['rotate-credentials', 'rotated-first', undefined], + ['create', 'created-first', undefined], + ['rotate-credentials', 'rotated-fast', 500], + ['create', 'created-fast', 500], + ] as const)( + '%s prints %s before a hanging address lookup, respecting timeout %s', + async (command, password, timeoutMs) => { + const credentials = { + password, + realm: 'sip.example.test', + username: 'pmfa_abc', + } + let endpointRequests = 0 + let endpointStartedAt = 0 + // Answers the trunk request, then never answers the address lookup. + const hanging = createServer((request, reply) => { + if (request.url?.startsWith('/platform/sip/endpoint')) { + endpointRequests += 1 + endpointStartedAt ||= performance.now() + return + } + reply.writeHead(command === 'create' ? 201 : 200, { + 'content-type': 'application/json', + }) + reply.end( + JSON.stringify({ + data: + command === 'create' + ? {trunk, inboundCredentials: credentials} + : credentials, + success: true, + }), + ) + }) + await new Promise((resolve) => + hanging.listen(0, '127.0.0.1', resolve), + ) + const {port} = hanging.address() as AddressInfo + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-sip-order-')) + const input = join(directory, 'trunk.json') + await writeFile(input, JSON.stringify({name: 'PBX', direction: 'both'})) + const args = + command === 'create' + ? ['sip-trunk', 'create', '--project', PROJECT, '--input', input] + : ['sip-trunk', 'rotate-credentials', TRUNK_ID, '--yes'] + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../../bin/dev.js', import.meta.url)), + ...args, + ...(timeoutMs === undefined ? [] : ['--timeout', String(timeoutMs)]), + ], + { + env: { + ...process.env, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: `http://127.0.0.1:${port}`, + POLYMORFA_CLIENT_TOKEN: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const exited = new Promise<{ + code: number | null + signal: NodeJS.Signals | null + }>((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({code, signal})) + }) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += String(chunk) + }) + let timer: NodeJS.Timeout | undefined + let exitTimer: NodeJS.Timeout | undefined + try { + const printed = new Promise((resolve) => { + child.stdout.on('data', (chunk: Buffer) => { + stdout += String(chunk) + if (stdout.includes(password)) resolve(true) + }) + timer = setTimeout(() => resolve(false), 20_000) + }) + + // The password is on screen before the lookup ends, so interrupting + // the command now cannot lose it. + expect(await printed, stderr).toBe(true) + expect(child.exitCode).toBeNull() + expect(stdout).not.toContain('SIP address') + const outcome = await Promise.race([ + exited, + new Promise((_, reject) => { + exitTimer = setTimeout( + () => + reject( + new Error( + 'Optional address lookup did not finish within eight seconds', + ), + ), + 8_000, + ) + }), + ]) + expect(outcome, stderr).toEqual({code: 0, signal: null}) + expect(endpointRequests).toBe(1) + const lookupMs = performance.now() - endpointStartedAt + expect(lookupMs).toBeGreaterThanOrEqual( + timeoutMs === undefined ? 4_000 : 100, + ) + expect(lookupMs).toBeLessThan(timeoutMs === undefined ? 8_000 : 2_000) + expect(stdout).toContain(password) + expect(stdout).not.toContain('SIP address') + } finally { + clearTimeout(timer) + clearTimeout(exitTimer) + if (child.exitCode === null && child.signalCode === null) child.kill() + await exited + hanging.closeAllConnections() + await new Promise((resolve) => hanging.close(() => resolve())) + await rm(directory, {force: true, recursive: true}) + } + }, + 30_000, + ) + + test('still prints rotated credentials when the SIP address is unavailable', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + password: 'rotated', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + success: true, + }, + }, + { + body: { + error: {code: 'permission_denied', message: 'Denied.'}, + success: false, + }, + status: 403, + }, + { + body: { + data: { + password: 'rotated-again', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + success: true, + }, + }, + {body: {data: notHostedEndpoint, success: true}}, + ]) + + const failed = await apiKey(() => + runCommand(`sip-trunk rotate-credentials ${TRUNK_ID} --yes`), + ) + const notHosted = await apiKey(() => + runCommand(`sip-trunk rotate-credentials ${TRUNK_ID} --yes`), + ) + + expect(failed.error).toBeUndefined() + expect(failed.stdout).toContain('Password\trotated\n') + expect(failed.stdout).not.toContain('SIP address') + expect(notHosted.error).toBeUndefined() + expect(notHosted.stdout).toContain('Password\trotated-again') + expect(notHosted.stdout).toContain( + 'SIP address\nThis environment has no SIP address.', + ) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `POST /platform/sip-trunks/${TRUNK_ID}/credentials`, + 'GET /platform/sip/endpoint', + `POST /platform/sip-trunks/${TRUNK_ID}/credentials`, + 'GET /platform/sip/endpoint', + ]) }) test('disables a trunk with a revision guard and reports conflicts', async () => {