diff --git a/__tests__/bin/vip-defensive-mode-configure.js b/__tests__/bin/vip-defensive-mode-configure.js new file mode 100644 index 000000000..d0181eb2a --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-configure.js @@ -0,0 +1,176 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import { defensiveModeConfigureCommand } from '../../src/bin/vip-defensive-mode-configure'; +import command from '../../src/lib/cli/command'; +import { updateDefensiveModeConfig } from '../../src/lib/defensive-mode/api'; +import { trackEvent } from '../../src/lib/tracker'; + +function mockExit() { + throw 'EXIT'; +} +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( console, 'error' ).mockImplementation( () => {} ); +jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/defensive-mode/api', () => ( { + updateDefensiveModeConfig: jest.fn( () => + Promise.resolve( { success: true, message: 'configured' } ) + ), + appQuery: 'mock-app-query', +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +jest.mock( '../../src/lib/envvar/input', () => ( { + confirm: jest.fn( () => Promise.resolve( true ) ), +} ) ); + +function baseOpts() { + return { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'develop' }, + skipConfirmation: true, + }; +} + +describe( 'vip defensive-mode configure', () => { + it( 'registers as a command', () => { + expect( command ).toHaveBeenCalled(); + } ); +} ); + +describe( 'defensiveModeConfigureCommand', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'applies full input when all flags are supplied', async () => { + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: '1', + connectionThresholdAbsolute: '1000', + connectionThresholdPercentage: '50', + } ); + expect( updateDefensiveModeConfig ).toHaveBeenCalledWith( { + appId: 7, + envId: 9, + enabled: true, + challengeType: 1, + connectionThresholdAbsolute: 1000, + connectionThresholdPercentage: 50, + } ); + } ); + + it( 'errors when required flags missing in non-interactive mode', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeConfig ).not.toHaveBeenCalled(); + } ); + + it( 'rejects non-boolean enabled values', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'maybe', + challengeType: '1', + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + } ); + + it( 'rejects non-integer challenge-type', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: 'oops', + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + } ); + + it( 'tracks success', async () => { + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'false', + challengeType: '1', + } ); + expect( trackEvent ).toHaveBeenCalledWith( + 'defensive_mode_configure_command_success', + expect.any( Object ) + ); + } ); + + it( 'logs the proposed configuration before mutating', async () => { + const consoleSpy = jest.spyOn( console, 'log' ); + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: '2', + } ); + const allArgs = consoleSpy.mock.calls.flat().filter( arg => typeof arg === 'string' ); + const settingsTable = allArgs.find( arg => arg.includes( 'Challenge type' ) ); + expect( settingsTable ).toBeDefined(); + expect( settingsTable ).toContain( 'Enabled' ); + expect( settingsTable ).toContain( 'true' ); + expect( settingsTable ).toContain( '2' ); + expect( settingsTable ).toContain( '(not specified)' ); + } ); + + it( 'rejects bare threshold flags (boolean true)', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + enabled: 'true', + challengeType: '1', + connectionThresholdAbsolute: true, + nonInteractive: true, + } ) + ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeConfig ).not.toHaveBeenCalled(); + } ); + + it( 'refuses production mutation in non-interactive mode without --skip-confirmation', async () => { + await expect( + defensiveModeConfigureCommand( [], { + ...baseOpts(), + env: { id: 9, type: 'production' }, + skipConfirmation: false, + nonInteractive: true, + enabled: 'false', + challengeType: '1', + } ) + ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeConfig ).not.toHaveBeenCalled(); + } ); + + it( 'allows production mutation in non-interactive mode with --skip-confirmation', async () => { + await defensiveModeConfigureCommand( [], { + ...baseOpts(), + env: { id: 9, type: 'production' }, + skipConfirmation: true, + nonInteractive: true, + enabled: 'false', + challengeType: '1', + } ); + expect( updateDefensiveModeConfig ).toHaveBeenCalledWith( + expect.objectContaining( { envId: 9, enabled: false, challengeType: 1 } ) + ); + } ); +} ); diff --git a/__tests__/bin/vip-defensive-mode-disable.js b/__tests__/bin/vip-defensive-mode-disable.js new file mode 100644 index 000000000..263c5d36d --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-disable.js @@ -0,0 +1,78 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import { defensiveModeDisableCommand } from '../../src/bin/vip-defensive-mode-disable'; +import command from '../../src/lib/cli/command'; +import { updateDefensiveModeStatus } from '../../src/lib/defensive-mode/api'; +import { trackEvent } from '../../src/lib/tracker'; + +function mockExit() { + throw 'EXIT'; +} +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( console, 'error' ).mockImplementation( () => {} ); +jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/defensive-mode/api', () => ( { + updateDefensiveModeStatus: jest.fn( () => + Promise.resolve( { success: true, message: 'disabled' } ) + ), + appQuery: 'mock-app-query', +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +describe( 'vip defensive-mode disable', () => { + it( 'registers as a command', () => { + expect( command ).toHaveBeenCalled(); + } ); +} ); + +describe( 'defensiveModeDisableCommand', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'calls updateDefensiveModeStatus with enabled=false', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'develop' }, + skipConfirmation: true, + }; + await defensiveModeDisableCommand( [], opts ); + expect( updateDefensiveModeStatus ).toHaveBeenCalledWith( { + appId: 7, + envId: 9, + enabled: false, + } ); + expect( trackEvent ).toHaveBeenCalledWith( + 'defensive_mode_disable_command_success', + expect.any( Object ) + ); + } ); + + it( 'exits with error on production without skip-confirmation in non-interactive mode', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'production' }, + skipConfirmation: false, + nonInteractive: true, + }; + await expect( defensiveModeDisableCommand( [], opts ) ).rejects.toBe( 'EXIT' ); + expect( updateDefensiveModeStatus ).not.toHaveBeenCalled(); + expect( trackEvent ).toHaveBeenCalledWith( + 'defensive_mode_disable_command_cancelled', + expect.any( Object ) + ); + } ); +} ); diff --git a/__tests__/bin/vip-defensive-mode-enable.js b/__tests__/bin/vip-defensive-mode-enable.js new file mode 100644 index 000000000..31800b089 --- /dev/null +++ b/__tests__/bin/vip-defensive-mode-enable.js @@ -0,0 +1,85 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import { defensiveModeEnableCommand } from '../../src/bin/vip-defensive-mode-enable'; +import command from '../../src/lib/cli/command'; +import { updateDefensiveModeStatus } from '../../src/lib/defensive-mode/api'; +import { trackEvent } from '../../src/lib/tracker'; + +function mockExit() { + throw 'EXIT'; +} +jest.spyOn( console, 'log' ).mockImplementation( () => {} ); +jest.spyOn( console, 'error' ).mockImplementation( () => {} ); +jest.spyOn( process, 'exit' ).mockImplementation( mockExit ); + +jest.mock( '../../src/lib/cli/command', () => { + const commandMock = { + argv: () => commandMock, + examples: () => commandMock, + option: () => commandMock, + }; + return jest.fn( () => commandMock ); +} ); + +jest.mock( '../../src/lib/defensive-mode/api', () => ( { + updateDefensiveModeStatus: jest.fn( () => + Promise.resolve( { success: true, message: 'enabled' } ) + ), + appQuery: 'mock-app-query', +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +const mockUpdate = updateDefensiveModeStatus; +const mockTrack = trackEvent; + +describe( 'vip defensive-mode enable', () => { + it( 'registers as a command', () => { + expect( command ).toHaveBeenCalled(); + } ); +} ); + +describe( 'defensiveModeEnableCommand', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'calls updateDefensiveModeStatus with enabled=true', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'develop' }, + skipConfirmation: true, + }; + await defensiveModeEnableCommand( [], opts ); + expect( mockUpdate ).toHaveBeenCalledWith( { + appId: 7, + envId: 9, + enabled: true, + } ); + expect( mockTrack ).toHaveBeenCalledWith( + 'defensive_mode_enable_command_execute', + expect.any( Object ) + ); + expect( mockTrack ).toHaveBeenCalledWith( + 'defensive_mode_enable_command_success', + expect.any( Object ) + ); + } ); + + it( 'exits with error on production without skip-confirmation in non-interactive mode', async () => { + const opts = { + app: { id: 7, name: 'demo', organization: { id: 1, salesforceId: 'X' } }, + env: { id: 9, type: 'production' }, + skipConfirmation: false, + nonInteractive: true, + }; + await expect( defensiveModeEnableCommand( [], opts ) ).rejects.toBe( 'EXIT' ); + expect( mockUpdate ).not.toHaveBeenCalled(); + expect( mockTrack ).toHaveBeenCalledWith( + 'defensive_mode_enable_command_cancelled', + expect.any( Object ) + ); + } ); +} ); diff --git a/__tests__/lib/defensive-mode/api.test.ts b/__tests__/lib/defensive-mode/api.test.ts new file mode 100644 index 000000000..3c051b4f8 --- /dev/null +++ b/__tests__/lib/defensive-mode/api.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import * as apiModule from '../../../src/lib/api'; +import { + updateDefensiveModeStatus, + updateDefensiveModeConfig, +} from '../../../src/lib/defensive-mode/api'; + +jest.mock( '../../../src/lib/api' ); + +const mockedAPI = apiModule as unknown as { default: jest.Mock }; + +beforeEach( () => { + mockedAPI.default = jest.fn().mockReturnValue( { + mutate: jest.fn( () => + Promise.resolve( { + data: { + updateDefensiveModeStatus: { success: true, message: 'ok' }, + updateDefensiveModeConfig: { success: true, message: 'ok' }, + }, + } ) + ), + } ); +} ); + +describe( 'updateDefensiveModeStatus', () => { + it( 'sends appId, envId, enabled', async () => { + await updateDefensiveModeStatus( { appId: 1, envId: 2, enabled: true } ); + const client = mockedAPI.default.mock.results[ 0 ].value as { + mutate: jest.Mock; + }; + const variables = ( + client.mutate.mock.calls[ 0 ][ 0 ] as { + variables: Record< string, unknown >; + } + ).variables; + expect( variables ).toEqual( { + input: { id: 1, environmentId: 2, enabled: true }, + } ); + } ); +} ); + +describe( 'updateDefensiveModeConfig', () => { + it( 'sends the full config input', async () => { + await updateDefensiveModeConfig( { + appId: 1, + envId: 2, + enabled: true, + challengeType: 1, + connectionThresholdAbsolute: 1000, + connectionThresholdPercentage: 50, + } ); + const client = mockedAPI.default.mock.results[ 0 ].value as { + mutate: jest.Mock; + }; + const variables = ( + client.mutate.mock.calls[ 0 ][ 0 ] as { + variables: Record< string, unknown >; + } + ).variables; + expect( variables ).toEqual( { + input: { + id: 1, + environmentId: 2, + enabled: true, + challengeType: 1, + connectionThresholdAbsolute: 1000, + connectionThresholdPercentage: 50, + }, + } ); + } ); + + it( 'omits optional thresholds when not provided', async () => { + await updateDefensiveModeConfig( { + appId: 1, + envId: 2, + enabled: false, + challengeType: 1, + } ); + const client = mockedAPI.default.mock.results[ 0 ].value as { + mutate: jest.Mock; + }; + const variables = ( + client.mutate.mock.calls[ 0 ][ 0 ] as { + variables: { input: Record< string, unknown > }; + } + ).variables; + expect( variables.input ).not.toHaveProperty( 'connectionThresholdAbsolute' ); + expect( variables.input ).not.toHaveProperty( 'connectionThresholdPercentage' ); + } ); +} ); diff --git a/__tests__/lib/logout.test.ts b/__tests__/lib/logout.test.ts new file mode 100644 index 000000000..c8bb47d57 --- /dev/null +++ b/__tests__/lib/logout.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import logout from '../../src/lib/logout'; +import tokenCache from '../../src/lib/rechallenge/token-cache'; +import Token from '../../src/lib/token'; +import { trackEvent } from '../../src/lib/tracker'; + +jest.mock( '../../src/lib/api/http', () => ( { + __esModule: true, + default: jest.fn( () => Promise.resolve( { ok: true } ) ), +} ) ); + +jest.mock( '../../src/lib/token', () => ( { + __esModule: true, + default: { + purge: jest.fn( () => Promise.resolve( true ) ), + }, +} ) ); + +jest.mock( '../../src/lib/rechallenge/token-cache', () => ( { + __esModule: true, + default: { + get: jest.fn(), + set: jest.fn(), + clearScope: jest.fn(), + clearAll: jest.fn( () => Promise.resolve() ), + }, +} ) ); + +jest.mock( '../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +describe( 'logout', () => { + it( 'purges primary token, clears elevated-token cache, and emits telemetry', async () => { + await logout(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect( Token.purge ).toHaveBeenCalledTimes( 1 ); + expect( tokenCache.clearAll ).toHaveBeenCalledTimes( 1 ); + expect( trackEvent ).toHaveBeenCalledWith( 'logout_command_execute' ); + } ); +} ); diff --git a/__tests__/lib/rechallenge/client.test.ts b/__tests__/lib/rechallenge/client.test.ts new file mode 100644 index 000000000..b36e09c41 --- /dev/null +++ b/__tests__/lib/rechallenge/client.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import http from '../../../src/lib/api/http'; +import * as client from '../../../src/lib/rechallenge/client'; +import { RechallengeHttpError } from '../../../src/lib/rechallenge/errors'; + +type Response = Awaited< ReturnType< typeof http > >; + +jest.mock( '../../../src/lib/api/http' ); +const mockHttp = http as unknown as jest.Mock; + +function jsonResponse( status: number, body: unknown ) { + return Promise.resolve( { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve( body ), + text: () => Promise.resolve( JSON.stringify( body ) ), + } as unknown as Response ); +} + +describe( 'rechallenge client.createSession', () => { + beforeEach( () => { + mockHttp.mockReset(); + } ); + + it( 'POSTs the create-session path with clientType and requestedOperation', async () => { + mockHttp.mockReturnValueOnce( + jsonResponse( 201, { + challengeId: 'rch_abc', + status: 'pending', + verificationUrl: 'https://example.com/verify', + pollIntervalSeconds: 2, + expiresAt: new Date( Date.now() + 900_000 ).toISOString(), + } ) + ); + + const session = await client.createSession( { + path: '/rechallenge/v2/sessions', + requestedOperation: 'updateDefensiveModeStatus', + } ); + + expect( mockHttp ).toHaveBeenCalledTimes( 1 ); + const [ path, options ] = mockHttp.mock.calls[ 0 ] as [ string, Record< string, unknown > ]; + expect( path ).toBe( '/rechallenge/v2/sessions' ); + expect( options.method ).toBe( 'POST' ); + const headers = options.headers as Record< string, string >; + expect( headers[ 'Idempotency-Key' ] ).toMatch( /^[a-f0-9-]{36}$/ ); + expect( options.body ).toEqual( { + clientType: 'cli', + requestedOperation: 'updateDefensiveModeStatus', + } ); + expect( session.challengeId ).toBe( 'rch_abc' ); + } ); + + it( 'throws RechallengeHttpError on non-2xx', async () => { + mockHttp.mockReturnValueOnce( jsonResponse( 500, { message: 'boom' } ) ); + await expect( + client.createSession( { + path: '/rechallenge/v2/sessions', + requestedOperation: 'updateDefensiveModeStatus', + } ) + ).rejects.toBeInstanceOf( RechallengeHttpError ); + } ); +} ); + +describe( 'rechallenge client.getSessionStatus', () => { + beforeEach( () => { + mockHttp.mockReset(); + } ); + + it( 'GETs the status template with challengeId substituted', async () => { + mockHttp.mockReturnValueOnce( + jsonResponse( 200, { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date().toISOString(), + pollIntervalSeconds: 2, + provider: 'passkeys', + } ) + ); + const status = await client.getSessionStatus( { + template: '/rechallenge/v2/sessions/{challengeId}', + challengeId: 'rch_abc', + } ); + expect( mockHttp ).toHaveBeenCalledWith( + '/rechallenge/v2/sessions/rch_abc', + expect.objectContaining( { method: 'GET' } ) + ); + expect( status.status ).toBe( 'verified' ); + } ); + + it( 'throws RechallengeHttpError on non-2xx', async () => { + mockHttp.mockReturnValueOnce( jsonResponse( 404, { message: 'not found' } ) ); + await expect( + client.getSessionStatus( { + template: '/rechallenge/v2/sessions/{challengeId}', + challengeId: 'rch_abc', + scope: 'updateDefensiveModeStatus', + } ) + ).rejects.toBeInstanceOf( RechallengeHttpError ); + } ); +} ); + +describe( 'rechallenge client.exchange', () => { + beforeEach( () => { + mockHttp.mockReset(); + } ); + + it( 'POSTs the exchange template and returns elevatedToken', async () => { + mockHttp.mockReturnValueOnce( + jsonResponse( 200, { + elevatedToken: { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', + }, + } ) + ); + const exchange = await client.exchange( { + template: '/rechallenge/v2/sessions/{challengeId}/exchange', + challengeId: 'rch_abc', + } ); + expect( mockHttp ).toHaveBeenCalledWith( + '/rechallenge/v2/sessions/rch_abc/exchange', + expect.objectContaining( { method: 'POST' } ) + ); + expect( exchange.elevatedToken.token ).toBe( 'jwt.payload.sig' ); + } ); + + it( 'throws RechallengeHttpError with bodyText on non-2xx', async () => { + mockHttp.mockReturnValueOnce( jsonResponse( 401, { message: 'unauthorized' } ) ); + const promise = client.exchange( { + template: '/rechallenge/v2/sessions/{challengeId}/exchange', + challengeId: 'rch_abc', + scope: 'updateDefensiveModeStatus', + } ); + await expect( promise ).rejects.toBeInstanceOf( RechallengeHttpError ); + await expect( promise ).rejects.toMatchObject( { + statusCode: 401, + bodyText: expect.stringContaining( 'unauthorized' ), + } ); + } ); +} ); diff --git a/__tests__/lib/rechallenge/flow.test.ts b/__tests__/lib/rechallenge/flow.test.ts new file mode 100644 index 000000000..261878d2b --- /dev/null +++ b/__tests__/lib/rechallenge/flow.test.ts @@ -0,0 +1,311 @@ +import { afterEach, describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import * as clientModule from '../../../src/lib/rechallenge/client'; +import { + RechallengeAbortedError, + RechallengeTerminalError, + RechallengeUnsupportedVersionError, +} from '../../../src/lib/rechallenge/errors'; +import { isInteractiveContext, runRechallenge } from '../../../src/lib/rechallenge/flow'; +import * as openBrowserModule from '../../../src/lib/rechallenge/open-browser'; +import tokenCache from '../../../src/lib/rechallenge/token-cache'; + +import type { RechallengeExtension } from '../../../src/lib/rechallenge/types'; + +jest.mock( '../../../src/lib/rechallenge/client' ); +jest.mock( '../../../src/lib/rechallenge/token-cache', () => ( { + __esModule: true, + default: { + get: jest.fn(), + set: jest.fn( () => Promise.resolve() ), + clearScope: jest.fn(), + clearAll: jest.fn(), + }, +} ) ); +jest.mock( '../../../src/lib/rechallenge/open-browser', () => ( { + openBrowser: jest.fn( () => Promise.resolve() ), +} ) ); +jest.mock( '../../../src/lib/tracker', () => ( { + trackEvent: jest.fn( () => Promise.resolve() ), +} ) ); + +const mockCreate = clientModule.createSession as jest.MockedFunction< + typeof clientModule.createSession +>; +const mockGetStatus = clientModule.getSessionStatus as jest.MockedFunction< + typeof clientModule.getSessionStatus +>; +const mockExchange = clientModule.exchange as jest.MockedFunction< typeof clientModule.exchange >; +const mockSet = tokenCache.set as unknown as jest.Mock; +const mockOpenBrowser = openBrowserModule.openBrowser as jest.Mock; + +function rechallenge(): RechallengeExtension { + return { + version: 'v2', + createSessionPath: '/rechallenge/v2/sessions', + statusPathTemplate: '/rechallenge/v2/sessions/{challengeId}', + exchangePathTemplate: '/rechallenge/v2/sessions/{challengeId}/exchange', + elevatedHeaderName: 'x-elevated-token', + }; +} + +beforeEach( () => { + jest.clearAllMocks(); + mockCreate.mockResolvedValue( { + challengeId: 'rch_abc', + status: 'pending', + verificationUrl: 'https://example.com/verify', + pollIntervalSeconds: 0, // floored to MIN_POLL_INTERVAL_SECONDS; tests drive fake timers + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + } ); + mockExchange.mockResolvedValue( { + elevatedToken: { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', + }, + } ); +} ); + +describe( 'runRechallenge', () => { + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'rejects v1 with RechallengeUnsupportedVersionError', async () => { + await expect( + runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: { ...rechallenge(), version: 'v1' }, + interactive: false, + } ) + ).rejects.toBeInstanceOf( RechallengeUnsupportedVersionError ); + } ); + + it( 'floors an unusable poll interval instead of tight-looping or producing NaN', async () => { + // Server returns NaN: without the guard this would make the interval NaN + // (Math.max returns NaN) and 0 would tight-loop. The floor must apply. + mockCreate.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'pending', + verificationUrl: 'https://example.com/verify', + pollIntervalSeconds: Number.NaN, + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + } ); + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ); + + jest.useFakeTimers(); + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ); + + // Just below the 2s floor: the first status poll must not have fired yet. + await jest.advanceTimersByTimeAsync( 1999 ); + expect( mockGetStatus ).not.toHaveBeenCalled(); + + // Crossing the floor triggers exactly one poll, which resolves the flow. + await jest.advanceTimersByTimeAsync( 1 ); + await pending; + expect( mockGetStatus ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'polls until verified then exchanges and caches the token', async () => { + mockGetStatus + .mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'pending', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ) + .mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + provider: 'passkeys', + } ); + + jest.useFakeTimers(); + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ); + await jest.runAllTimersAsync(); + const token = await pending; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { trackEvent } = require( '../../../src/lib/tracker' ) as { trackEvent: jest.Mock }; + expect( token.token ).toBe( 'jwt.payload.sig' ); + expect( mockGetStatus ).toHaveBeenCalledTimes( 2 ); + expect( mockExchange ).toHaveBeenCalledTimes( 1 ); + expect( mockSet ).toHaveBeenCalledWith( + 'updateDefensiveModeStatus', + expect.objectContaining( { token: 'jwt.payload.sig' } ) + ); + expect( trackEvent ).toHaveBeenCalledWith( + 'rechallenge_verified', + expect.objectContaining( { scope: 'updateDefensiveModeStatus' } ) + ); + expect( trackEvent ).toHaveBeenCalledWith( + 'rechallenge_exchanged', + expect.objectContaining( { scope: 'updateDefensiveModeStatus' } ) + ); + // verified fires before exchanged + const calls = trackEvent.mock.calls.map( ( [ name ] ) => name ); + expect( calls.indexOf( 'rechallenge_verified' ) ).toBeLessThan( + calls.indexOf( 'rechallenge_exchanged' ) + ); + } ); + + it( 'throws RechallengeTerminalError on non-verified terminal states', async () => { + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'expired', + expiresAt: new Date( Date.now() - 1 ).toISOString(), + pollIntervalSeconds: 0, + statusReason: { code: 'expired', message: 'session expired' }, + } ); + + jest.useFakeTimers(); + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ); + await Promise.all( [ + expect( pending ).rejects.toBeInstanceOf( RechallengeTerminalError ), + jest.runAllTimersAsync(), + ] ); + } ); + + it( 'aborts when the abort signal fires', async () => { + const ac = new AbortController(); + mockGetStatus.mockImplementation( + () => + new Promise( resolve => { + setTimeout( + () => + resolve( { + challengeId: 'rch_abc', + status: 'pending', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ), + 5 + ); + } ) + ); + + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + signal: ac.signal, + } ); + setTimeout( () => ac.abort(), 10 ); + + await expect( pending ).rejects.toBeInstanceOf( RechallengeAbortedError ); + } ); + + it( 'does not call open() when interactive=false', async () => { + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ); + jest.useFakeTimers(); + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: false, + } ); + await jest.runAllTimersAsync(); + await pending; + expect( mockOpenBrowser ).not.toHaveBeenCalled(); + } ); + + it( 'calls open() with verificationUrl when interactive=true', async () => { + mockGetStatus.mockResolvedValueOnce( { + challengeId: 'rch_abc', + status: 'verified', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + pollIntervalSeconds: 0, + } ); + jest.useFakeTimers(); + const pending = runRechallenge( { + requestedOperation: 'updateDefensiveModeStatus', + rechallenge: rechallenge(), + interactive: true, + } ); + await jest.runAllTimersAsync(); + await pending; + expect( mockOpenBrowser ).toHaveBeenCalledWith( 'https://example.com/verify' ); + } ); +} ); + +describe( 'isInteractiveContext', () => { + const originalEnv = process.env.VIP_NON_INTERACTIVE; + const originalIsTTY = process.stdout.isTTY; + + afterEach( () => { + process.env.VIP_NON_INTERACTIVE = originalEnv; + Object.defineProperty( process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + } ); + } ); + + it( 'returns false when VIP_NON_INTERACTIVE=1', () => { + process.env.VIP_NON_INTERACTIVE = '1'; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( false ); + } ); + + it( 'returns true for non-"1" values of VIP_NON_INTERACTIVE', () => { + process.env.VIP_NON_INTERACTIVE = '0'; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( true ); + } ); + + it( 'returns false when --non-interactive is in argv', () => { + delete process.env.VIP_NON_INTERACTIVE; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [ '--non-interactive' ] ) ).toBe( false ); + } ); + + it( 'returns false when stdout is not a TTY', () => { + delete process.env.VIP_NON_INTERACTIVE; + Object.defineProperty( process.stdout, 'isTTY', { + value: false, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( false ); + } ); + + it( 'returns true when TTY, no flag, no env var', () => { + delete process.env.VIP_NON_INTERACTIVE; + Object.defineProperty( process.stdout, 'isTTY', { + value: true, + configurable: true, + } ); + expect( isInteractiveContext( [] ) ).toBe( true ); + } ); +} ); diff --git a/__tests__/lib/rechallenge/link.test.ts b/__tests__/lib/rechallenge/link.test.ts new file mode 100644 index 000000000..b3dac3fbb --- /dev/null +++ b/__tests__/lib/rechallenge/link.test.ts @@ -0,0 +1,199 @@ +import { ApolloLink, Observable, type ApolloClient } from '@apollo/client/core'; +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import gql from 'graphql-tag'; + +import * as flowModule from '../../../src/lib/rechallenge/flow'; +import createRechallengeLink from '../../../src/lib/rechallenge/link'; +import tokenCache from '../../../src/lib/rechallenge/token-cache'; + +import type { RunRechallengeOptions } from '../../../src/lib/rechallenge/flow'; +import type { ElevatedToken } from '../../../src/lib/rechallenge/types'; + +jest.mock( '../../../src/lib/rechallenge/flow', () => ( { + runRechallenge: jest.fn(), + isInteractiveContext: () => false, +} ) ); +jest.mock( '../../../src/lib/rechallenge/token-cache', () => ( { + __esModule: true, + default: { + get: jest.fn(), + set: jest.fn( () => Promise.resolve() ), + clearScope: jest.fn(), + clearAll: jest.fn(), + }, +} ) ); + +const runRechallenge = flowModule.runRechallenge as jest.MockedFunction< + ( opts: RunRechallengeOptions ) => Promise< ElevatedToken > +>; +const tokenGet = tokenCache.get as jest.MockedFunction< + ( scope: string ) => Promise< ElevatedToken | null > +>; + +const MUTATION = gql` + mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput) { + updateDefensiveModeStatus(input: $input) { + success + message + } + } +`; + +const QUERY = gql` + query Foo { + foo + } +`; + +const ELEVATED_TOKEN: ElevatedToken = { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', +}; + +function elevatedRequiredResult(): ApolloLink.Result { + return { + data: null, + errors: [ + { + message: 'Elevated permission required', + extensions: { + code: 'elevated-permission-required', + rechallenge: { + version: 'v2', + createSessionPath: '/rechallenge/v2/sessions', + statusPathTemplate: '/rechallenge/v2/sessions/{challengeId}', + exchangePathTemplate: '/rechallenge/v2/sessions/{challengeId}/exchange', + elevatedHeaderName: 'x-elevated-token', + }, + }, + }, + ], + } as unknown as ApolloLink.Result; +} + +function successResult(): ApolloLink.Result { + return { data: { updateDefensiveModeStatus: { success: true, message: 'ok' } } }; +} + +function makeDownstream( responses: ApolloLink.Result[] ) { + const calls: { headers: Record< string, string > }[] = []; + const link = new ApolloLink( operation => { + const ctx = operation.getContext() as { headers?: Record< string, string > }; + calls.push( { headers: { ...( ctx.headers ?? {} ) } } ); + const result = responses.shift(); + return new Observable< ApolloLink.Result >( observer => { + if ( result ) { + observer.next( result ); + observer.complete(); + } else { + observer.error( new Error( 'no more queued responses' ) ); + } + } ); + } ); + return { link, calls }; +} + +// Apollo v4 requires a `client` in the execute context; use a null stand-in for unit tests. +const EXEC_CTX = { client: null as unknown as ApolloClient }; + +function executeLink( + link: ApolloLink, + request: Parameters< typeof ApolloLink.execute >[ 1 ] +): Promise< ApolloLink.Result > { + return new Promise< ApolloLink.Result >( ( resolve, reject ) => { + ApolloLink.execute( link, request, EXEC_CTX ).subscribe( { + next: resolve, + error: reject, + } ); + } ); +} + +beforeEach( () => { + jest.clearAllMocks(); + tokenGet.mockResolvedValue( null ); +} ); + +describe( 'rechallengeLink', () => { + it( 'passes queries through untouched', async () => { + const { link: downstream, calls } = makeDownstream( [ successResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: QUERY } ); + expect( result ).toEqual( successResult() ); + expect( calls ).toHaveLength( 1 ); + expect( calls[ 0 ].headers[ 'x-elevated-token' ] ).toBeUndefined(); + expect( runRechallenge ).not.toHaveBeenCalled(); + } ); + + it( 'attaches cached elevated token pre-flight when available', async () => { + tokenGet.mockResolvedValueOnce( ELEVATED_TOKEN ); + const { link: downstream, calls } = makeDownstream( [ successResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + await executeLink( link, { query: MUTATION } ); + expect( calls[ 0 ].headers[ 'x-elevated-token' ] ).toBe( ELEVATED_TOKEN.token ); + expect( runRechallenge ).not.toHaveBeenCalled(); + } ); + + it( 'on elevated-permission-required runs flow and retries with header', async () => { + runRechallenge.mockResolvedValueOnce( ELEVATED_TOKEN ); + const { link: downstream, calls } = makeDownstream( [ + elevatedRequiredResult(), + successResult(), + ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: MUTATION } ); + expect( result ).toEqual( successResult() ); + expect( calls ).toHaveLength( 2 ); + expect( calls[ 0 ].headers[ 'x-elevated-token' ] ).toBeUndefined(); + expect( calls[ 1 ].headers[ 'x-elevated-token' ] ).toBe( ELEVATED_TOKEN.token ); + expect( runRechallenge ).toHaveBeenCalledWith( + expect.objectContaining( { + requestedOperation: 'updateDefensiveModeStatus', + } ) + ); + } ); + + it( 'propagates the original error when the flow fails', async () => { + runRechallenge.mockRejectedValueOnce( new Error( 'flow boom' ) ); + const { link: downstream } = makeDownstream( [ elevatedRequiredResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: MUTATION } ); + expect( result.errors?.[ 0 ].extensions?.code ).toBe( 'elevated-permission-required' ); + } ); + + it( 'aborts the in-flight rechallenge flow when the operation is unsubscribed', async () => { + let capturedSignal: AbortSignal | undefined; + runRechallenge.mockImplementationOnce( opts => { + capturedSignal = opts.signal; + // Never resolves: the flow is still polling when we tear down. + return new Promise< ElevatedToken >( () => {} ); + } ); + const { link: downstream } = makeDownstream( [ elevatedRequiredResult() ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + + const subscription = ApolloLink.execute( link, { query: MUTATION }, EXEC_CTX ).subscribe( { + next: () => {}, + error: () => {}, + } ); + + // Let preflight + first forward + the rechallenge dispatch settle. + await new Promise( resolve => setTimeout( resolve, 0 ) ); + expect( capturedSignal ).toBeDefined(); + expect( capturedSignal?.aborted ).toBe( false ); + + subscription.unsubscribe(); + expect( capturedSignal?.aborted ).toBe( true ); + } ); + + it( 'passes the second elevated-permission-required upstream without retrying again', async () => { + runRechallenge.mockResolvedValueOnce( ELEVATED_TOKEN ); + const { link: downstream } = makeDownstream( [ + elevatedRequiredResult(), + elevatedRequiredResult(), + ] ); + const link = ApolloLink.from( [ createRechallengeLink(), downstream ] ); + const result = await executeLink( link, { query: MUTATION } ); + expect( result.errors?.[ 0 ].extensions?.code ).toBe( 'elevated-permission-required' ); + expect( runRechallenge ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/__tests__/lib/rechallenge/token-cache.test.ts b/__tests__/lib/rechallenge/token-cache.test.ts new file mode 100644 index 000000000..82f64857a --- /dev/null +++ b/__tests__/lib/rechallenge/token-cache.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +import keychain from '../../../src/lib/keychain'; +import tokenCache from '../../../src/lib/rechallenge/token-cache'; + +import type { ElevatedToken } from '../../../src/lib/rechallenge/types'; + +jest.mock( '../../../src/lib/keychain', () => { + const store = new Map< string, string >(); + return { + __esModule: true, + default: { + getPassword: jest.fn( ( service: string ) => + Promise.resolve( store.get( service ) ?? null ) + ), + setPassword: jest.fn( ( service: string, password: string ) => { + store.set( service, password ); + return Promise.resolve( true ); + } ), + deletePassword: jest.fn( ( service: string ) => { + const had = store.delete( service ); + return Promise.resolve( had ); + } ), + __store: store, + }, + }; +} ); + +function makeToken( overrides: Partial< ElevatedToken > = {} ): ElevatedToken { + return { + token: 'jwt.payload.sig', + expiresAt: new Date( Date.now() + 60_000 ).toISOString(), + purpose: 'validate-elevated-permissions', + ...overrides, + }; +} + +describe( 'rechallenge token cache', () => { + beforeEach( async () => { + await tokenCache.clearAll(); + tokenCache._resetInMemoryForTests(); + jest.clearAllMocks(); + } ); + + it( 'returns null when no token has been stored for a scope', async () => { + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toBeNull(); + } ); + + it( 'stores and retrieves a token by scope', async () => { + const token = makeToken(); + await tokenCache.set( 'updateDefensiveModeStatus', token ); + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toEqual( token ); + } ); + + it( 'keeps tokens isolated by scope', async () => { + const a = makeToken( { token: 'A' } ); + const b = makeToken( { token: 'B' } ); + await tokenCache.set( 'updateDefensiveModeStatus', a ); + await tokenCache.set( 'updateDefensiveModeConfig', b ); + expect( ( await tokenCache.get( 'updateDefensiveModeStatus' ) )?.token ).toBe( 'A' ); + expect( ( await tokenCache.get( 'updateDefensiveModeConfig' ) )?.token ).toBe( 'B' ); + } ); + + it( 'returns null and self-evicts when token is expired', async () => { + const expired = makeToken( { + expiresAt: new Date( Date.now() - 1_000 ).toISOString(), + } ); + await tokenCache.set( 'updateDefensiveModeStatus', expired ); + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toBeNull(); + // Eviction writes through to keychain so the expired entry can't reappear. + // eslint-disable-next-line @typescript-eslint/unbound-method + expect( keychain.deletePassword ).toHaveBeenCalled(); + } ); + + it( 'clearAll removes every scope', async () => { + await tokenCache.set( 'a', makeToken() ); + await tokenCache.set( 'b', makeToken() ); + await tokenCache.clearAll(); + expect( await tokenCache.get( 'a' ) ).toBeNull(); + expect( await tokenCache.get( 'b' ) ).toBeNull(); + } ); + + it( 'clearScope removes only the requested scope', async () => { + await tokenCache.set( 'a', makeToken( { token: 'A' } ) ); + await tokenCache.set( 'b', makeToken( { token: 'B' } ) ); + await tokenCache.clearScope( 'a' ); + expect( await tokenCache.get( 'a' ) ).toBeNull(); + expect( ( await tokenCache.get( 'b' ) )?.token ).toBe( 'B' ); + } ); + + it( 'resets and purges keychain when stored blob is malformed JSON', async () => { + // Force a corrupt blob to land in the mock store. We need the + // keychain mock to return invalid JSON on the next read. + const keychainMock = keychain as unknown as { + getPassword: jest.Mock< ( service: string ) => Promise< string | null > >; + deletePassword: jest.Mock< ( service: string ) => Promise< boolean > >; + }; + keychainMock.getPassword.mockResolvedValueOnce( 'not-valid-json{' ); + tokenCache._resetInMemoryForTests(); + + expect( await tokenCache.get( 'updateDefensiveModeStatus' ) ).toBeNull(); + expect( keychainMock.deletePassword ).toHaveBeenCalled(); + } ); +} ); diff --git a/package.json b/package.json index 0ac529418..7511ba453 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "vip-config-software-update": "dist/bin/vip-config-software-update.js", "vip-db": "dist/bin/vip-db.js", "vip-db-phpmyadmin": "dist/bin/vip-db-phpmyadmin.js", + "vip-defensive-mode": "dist/bin/vip-defensive-mode.js", + "vip-defensive-mode-configure": "dist/bin/vip-defensive-mode-configure.js", + "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js", + "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js", "vip-dev-env": "dist/bin/vip-dev-env.js", "vip-dev-env-create": "dist/bin/vip-dev-env-create.js", "vip-dev-env-update": "dist/bin/vip-dev-env-update.js", diff --git a/src/bin/vip-defensive-mode-configure.js b/src/bin/vip-defensive-mode-configure.js new file mode 100644 index 000000000..2581d42fb --- /dev/null +++ b/src/bin/vip-defensive-mode-configure.js @@ -0,0 +1,302 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; +import { prompt } from 'enquirer'; + +import command from '../lib/cli/command'; +import { formatEnvironment, table } from '../lib/cli/format'; +import { appQuery, updateDefensiveModeConfig } from '../lib/defensive-mode/api'; +import { + guardProductionMutation, + isInteractive, + reportMutationResult, +} from '../lib/defensive-mode/cli-helpers'; +import { confirm } from '../lib/envvar/input'; +import { trackEvent } from '../lib/tracker'; + +const baseUsage = 'vip defensive-mode configure'; +const exampleUsage = 'vip @example-app.production defensive-mode configure'; + +const examples = [ + { + usage: `${ exampleUsage } --enabled=true --challenge-type=1`, + description: 'Update defensive mode configuration non-interactively (minimal required flags).', + }, + { + usage: `${ exampleUsage } --enabled=true --challenge-type=2 --connection-threshold-absolute=5000 --connection-threshold-percentage=80`, + description: 'Update with explicit thresholds.', + }, +]; + +function parseBoolean( raw ) { + if ( raw === true || raw === false ) { + return raw; + } + if ( typeof raw !== 'string' ) { + return null; + } + const normalized = raw.trim().toLowerCase(); + if ( [ 'true', 'yes', '1', 'on', 'enable', 'enabled' ].includes( normalized ) ) { + return true; + } + if ( [ 'false', 'no', '0', 'off', 'disable', 'disabled' ].includes( normalized ) ) { + return false; + } + return null; +} + +function parsePositiveInt( raw ) { + // A bare flag (e.g. `--challenge-type` with no value) arrives as boolean true, + // which Number() would silently coerce to 1. + if ( raw === undefined || raw === null || typeof raw === 'boolean' ) { + return null; + } + if ( typeof raw === 'string' && raw.trim() === '' ) { + return null; + } + const num = Number( raw ); + if ( ! Number.isInteger( num ) || num < 0 ) { + return null; + } + return num; +} + +function validateFlags( opt ) { + const errors = []; + + const enabled = opt.enabled === undefined ? null : parseBoolean( opt.enabled ); + if ( opt.enabled !== undefined && enabled === null ) { + errors.push( `Invalid value for --enabled: ${ opt.enabled }. Expected true or false.` ); + } + + const challengeType = + opt.challengeType === undefined ? null : parsePositiveInt( opt.challengeType ); + if ( opt.challengeType !== undefined && challengeType === null ) { + errors.push( + `Invalid value for --challenge-type: ${ opt.challengeType }. Expected a non-negative integer.` + ); + } + + const absolute = + opt.connectionThresholdAbsolute === undefined + ? undefined + : parsePositiveInt( opt.connectionThresholdAbsolute ); + if ( opt.connectionThresholdAbsolute !== undefined && absolute === null ) { + errors.push( + `Invalid value for --connection-threshold-absolute: ${ opt.connectionThresholdAbsolute }. Expected a non-negative integer.` + ); + } + + const percentage = + opt.connectionThresholdPercentage === undefined + ? undefined + : parsePositiveInt( opt.connectionThresholdPercentage ); + if ( opt.connectionThresholdPercentage !== undefined && percentage === null ) { + errors.push( + `Invalid value for --connection-threshold-percentage: ${ opt.connectionThresholdPercentage }. Expected a non-negative integer.` + ); + } + + return { enabled, challengeType, absolute, percentage, errors }; +} + +function formatSettingValue( value ) { + return value === undefined || value === null ? '-' : String( value ); +} + +function buildSettingRows( currentConfig, { enabled, challengeType, absolute, percentage } ) { + return [ + { + setting: 'Enabled', + current: formatSettingValue( currentConfig?.enabled ), + proposed: formatSettingValue( enabled ), + }, + { + setting: 'Challenge type', + current: formatSettingValue( currentConfig?.challengeType ), + proposed: formatSettingValue( challengeType ), + }, + { + setting: 'Connection threshold (absolute)', + current: formatSettingValue( currentConfig?.connectionThresholdAbsolute ), + proposed: absolute === undefined ? '(not specified)' : formatSettingValue( absolute ), + }, + { + setting: 'Connection threshold (percentage)', + current: formatSettingValue( currentConfig?.connectionThresholdPercentage ), + proposed: percentage === undefined ? '(not specified)' : formatSettingValue( percentage ), + }, + ]; +} + +async function resolveRequiredViaPrompt( missing, enabled, challengeType ) { + const answers = await prompt( + missing.map( flag => + flag === '--enabled' + ? { + type: 'confirm', + name: 'enabled', + message: 'Enable defensive mode?', + } + : { + type: 'input', + name: 'challengeType', + message: 'Challenge type (integer):', + } + ) + ); + + let resolvedEnabled = enabled; + let resolvedChallengeType = challengeType; + + if ( resolvedEnabled === null && 'enabled' in answers ) { + resolvedEnabled = Boolean( answers.enabled ); + } + if ( resolvedChallengeType === null && 'challengeType' in answers ) { + resolvedChallengeType = parsePositiveInt( answers.challengeType ); + if ( resolvedChallengeType === null ) { + console.error( chalk.red( 'Challenge type must be a non-negative integer.' ) ); + process.exit( 1 ); + } + } + + return { enabled: resolvedEnabled, challengeType: resolvedChallengeType }; +} + +export async function defensiveModeConfigureCommand( _args, opt ) { + const interactive = isInteractive( opt ); + const trackingParams = { + app_id: opt.app.id, + command: baseUsage, + env_id: opt.env.id, + org_id: opt.app.organization.id, + org_sfid: opt.app.organization.salesforceId, + interactive, + skip_confirm: Boolean( opt.skipConfirmation ), + }; + + await trackEvent( 'defensive_mode_configure_command_execute', trackingParams ); + + const { + enabled: rawEnabled, + challengeType: rawChallengeType, + absolute, + percentage, + errors, + } = validateFlags( opt ); + + if ( errors.length > 0 ) { + errors.forEach( msg => console.error( chalk.red( msg ) ) ); + process.exit( 1 ); + } + + const missing = []; + if ( rawEnabled === null ) { + missing.push( '--enabled' ); + } + if ( rawChallengeType === null ) { + missing.push( '--challenge-type' ); + } + + let enabled = rawEnabled; + let challengeType = rawChallengeType; + + if ( missing.length > 0 ) { + if ( ! interactive ) { + console.error( + chalk.red( `Missing required flags in non-interactive mode: ${ missing.join( ', ' ) }` ) + ); + console.error( + 'Re-run with all required flags, or remove --non-interactive and run on a TTY.' + ); + await trackEvent( 'defensive_mode_configure_command_error', { + ...trackingParams, + error: 'missing-required-flags', + } ); + process.exit( 1 ); + } + + ( { enabled, challengeType } = await resolveRequiredViaPrompt( + missing, + enabled, + challengeType + ) ); + } + + const input = { + appId: opt.app.id, + envId: opt.env.id, + enabled, + challengeType, + }; + if ( absolute !== undefined ) { + input.connectionThresholdAbsolute = absolute; + } + if ( percentage !== undefined ) { + input.connectionThresholdPercentage = percentage; + } + + const currentConfig = opt.env.defensiveMode?.config?.effective ?? null; + const settingRows = buildSettingRows( currentConfig, { + enabled, + challengeType, + absolute, + percentage, + } ); + console.log( + `Defensive mode configuration for ${ chalk.bold( opt.app.name ) } (${ formatEnvironment( + opt.env.type + ) }):` + ); + console.log( table( settingRows ) ); + + // Production mutations require confirmation. In non-interactive contexts this + // hard-errors unless --skip-confirmation is passed, so unattended/CI runs fail + // fast rather than silently mutating production — matching enable/disable. + await guardProductionMutation( + opt, + 'configure', + trackingParams, + confirm, + trackEvent, + formatEnvironment + ); + + const result = await updateDefensiveModeConfig( input ); + + await reportMutationResult( + result, + trackingParams, + 'configure', + opt.app.name, + opt.env.type, + 'configuration updated', + 'update defensive mode config', + trackEvent + ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage: baseUsage, +} ) + .option( 'enabled', 'Whether defensive mode should be enabled (true|false). Required.' ) + .option( 'challenge-type', 'Challenge type integer. Required.' ) + .option( + 'connection-threshold-absolute', + 'Absolute connection threshold that triggers defensive mode.' + ) + .option( + 'connection-threshold-percentage', + 'Connection threshold percentage that triggers defensive mode.' + ) + .option( + 'non-interactive', + 'Disable prompts and browser-open; fail fast if a required flag is missing.', + false + ) + .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .examples( examples ) + .argv( process.argv, defensiveModeConfigureCommand ); diff --git a/src/bin/vip-defensive-mode-disable.js b/src/bin/vip-defensive-mode-disable.js new file mode 100644 index 000000000..a396e4cec --- /dev/null +++ b/src/bin/vip-defensive-mode-disable.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; +import { formatEnvironment } from '../lib/cli/format'; +import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; +import { guardProductionMutation, reportMutationResult } from '../lib/defensive-mode/cli-helpers'; +import { confirm } from '../lib/envvar/input'; +import { trackEvent } from '../lib/tracker'; + +const baseUsage = 'vip defensive-mode disable'; +const exampleUsage = 'vip @example-app.production defensive-mode disable'; + +const examples = [ + { + usage: exampleUsage, + description: 'Disable defensive mode for the environment.', + }, +]; + +export async function defensiveModeDisableCommand( _args, opt ) { + const trackingParams = { + app_id: opt.app.id, + command: baseUsage, + env_id: opt.env.id, + org_id: opt.app.organization.id, + org_sfid: opt.app.organization.salesforceId, + skip_confirm: Boolean( opt.skipConfirmation ), + }; + + await trackEvent( 'defensive_mode_disable_command_execute', trackingParams ); + + await guardProductionMutation( + opt, + 'disable', + trackingParams, + confirm, + trackEvent, + formatEnvironment + ); + + const result = await updateDefensiveModeStatus( { + appId: opt.app.id, + envId: opt.env.id, + enabled: false, + } ); + + await reportMutationResult( + result, + trackingParams, + 'disable', + opt.app.name, + opt.env.type, + 'disabled', + 'disable defensive mode', + trackEvent + ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage: baseUsage, +} ) + .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .option( + 'non-interactive', + 'Disable prompts; error if a production mutation is attempted without --skip-confirmation.', + false + ) + .examples( examples ) + .argv( process.argv, defensiveModeDisableCommand ); diff --git a/src/bin/vip-defensive-mode-enable.js b/src/bin/vip-defensive-mode-enable.js new file mode 100644 index 000000000..2a8eb5fd2 --- /dev/null +++ b/src/bin/vip-defensive-mode-enable.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; +import { formatEnvironment } from '../lib/cli/format'; +import { appQuery, updateDefensiveModeStatus } from '../lib/defensive-mode/api'; +import { guardProductionMutation, reportMutationResult } from '../lib/defensive-mode/cli-helpers'; +import { confirm } from '../lib/envvar/input'; +import { trackEvent } from '../lib/tracker'; + +const baseUsage = 'vip defensive-mode enable'; +const exampleUsage = 'vip @example-app.production defensive-mode enable'; + +const examples = [ + { + usage: exampleUsage, + description: 'Enable defensive mode for the environment (interactive).', + }, + { + usage: `${ exampleUsage } --skip-confirmation`, + description: 'Enable defensive mode without the production confirmation prompt.', + }, +]; + +export async function defensiveModeEnableCommand( _args, opt ) { + const trackingParams = { + app_id: opt.app.id, + command: baseUsage, + env_id: opt.env.id, + org_id: opt.app.organization.id, + org_sfid: opt.app.organization.salesforceId, + skip_confirm: Boolean( opt.skipConfirmation ), + }; + + await trackEvent( 'defensive_mode_enable_command_execute', trackingParams ); + + await guardProductionMutation( + opt, + 'enable', + trackingParams, + confirm, + trackEvent, + formatEnvironment + ); + + const result = await updateDefensiveModeStatus( { + appId: opt.app.id, + envId: opt.env.id, + enabled: true, + } ); + + await reportMutationResult( + result, + trackingParams, + 'enable', + opt.app.name, + opt.env.type, + 'enabled', + 'enable defensive mode', + trackEvent + ); +} + +command( { + appContext: true, + appQuery, + envContext: true, + usage: baseUsage, +} ) + .option( 'skip-confirmation', 'Skip the confirmation prompt for production envs.', false ) + .option( + 'non-interactive', + 'Disable prompts; error if a production mutation is attempted without --skip-confirmation.', + false + ) + .examples( examples ) + .argv( process.argv, defensiveModeEnableCommand ); diff --git a/src/bin/vip-defensive-mode.js b/src/bin/vip-defensive-mode.js new file mode 100644 index 000000000..32c5334b6 --- /dev/null +++ b/src/bin/vip-defensive-mode.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import command from '../lib/cli/command'; + +const usage = 'vip defensive-mode'; +const exampleUsage = 'vip @example-app.production defensive-mode'; + +const examples = [ + { + usage: `${ exampleUsage } enable`, + description: 'Enable defensive mode for the environment.', + }, + { + usage: `${ exampleUsage } disable`, + description: 'Disable defensive mode for the environment.', + }, + { + usage: `${ exampleUsage } configure --enabled=true --challenge-type=1`, + description: 'Update the defensive mode configuration non-interactively.', + }, +]; + +command( { + requiredArgs: 1, + usage, +} ) + .command( 'enable', 'Enable defensive mode (step-up auth required).' ) + .command( 'disable', 'Disable defensive mode (step-up auth required).' ) + .command( 'configure', 'Update the defensive mode configuration (step-up auth required).' ) + .examples( examples ) + .argv( process.argv ); diff --git a/src/bin/vip.js b/src/bin/vip.js index ee61a4c91..713f7eaa8 100755 --- a/src/bin/vip.js +++ b/src/bin/vip.js @@ -14,6 +14,7 @@ import { resolveInternalBinFromArgv, isSeaRuntime, } from '../lib/cli/sea-dispatch'; +import tokenCache from '../lib/rechallenge/token-cache'; import Token from '../lib/token'; import { aliasUser, trackEvent } from '../lib/tracker'; @@ -73,6 +74,7 @@ const runCmd = async function () { ) .command( 'slowlogs', 'Retrieve MySQL slow query logs from an environment.' ) .command( 'db', "Access an environment's database." ) + .command( 'defensive-mode', 'Manage VIP defensive mode for an environment.' ) .command( 'sync', 'Sync the database from production to a non-production environment.' ) .command( 'whoami', 'Retrieve details about the current authenticated VIP-CLI user.' ) .command( 'wp', 'Execute a WP-CLI command against an environment.' ); @@ -169,6 +171,10 @@ async function runLoginFlow() { throw err; } + // Elevated tokens are keyed by API host + scope, not by user identity. Drop any + // cached elevation from a previous login so it cannot carry across identities. + await tokenCache.clearAll(); + // De-anonymize user for tracking await aliasUser( token.id ); diff --git a/src/lib/api.ts b/src/lib/api.ts index a5e41e9c6..aebb91621 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,13 +12,13 @@ import chalk from 'chalk'; import debugLib from 'debug'; import { Kind, OperationTypeNode } from 'graphql'; +import { API_URL } from './api/constants'; import http from './api/http'; -// Config -export const PRODUCTION_API_HOST = 'https://api.wpvip.com'; - -export const API_HOST = process.env.API_HOST || PRODUCTION_API_HOST; // NOSONAR -export const API_URL = `${ API_HOST }/graphql`; +// Config — re-exported from ./api/constants so modules in the rechallenge tree +// can import them without pulling in the full api.ts graph (which would create +// a circular dependency via the rechallenge link). +export { API_HOST, API_URL, PRODUCTION_API_HOST } from './api/constants'; let globalGraphQLErrorHandlingEnabled = true; @@ -144,8 +144,20 @@ export default function API( { attempts: shouldRetryRequest, } ); + // Lazy-require the rechallenge link to avoid a circular-dependency issue in + // Jest tests. Importing at module top-level would cause rechallenge/client.ts + // to be loaded during jest.setupMocks.js (via apiConfig → feature-flags → + // api.ts → link.ts → client.ts), preventing jest.mock('../api/http') from + // intercepting the http reference captured inside client.ts. A require() + // call inside the function body is resolved after all mocks are registered. + + type RechallengeLinkModule = typeof import('./rechallenge/link'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const linkMod = require( './rechallenge/link' ) as RechallengeLinkModule; + const createRechallengeLink = linkMod.default; + return new ApolloClient( { - link: ApolloLink.from( [ errorLink, retryLink, httpLink ] ), + link: ApolloLink.from( [ errorLink, createRechallengeLink(), retryLink, httpLink ] ), cache: new InMemoryCache( { typePolicies: { WPSite: { diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts new file mode 100644 index 000000000..3043a89d4 --- /dev/null +++ b/src/lib/api/constants.ts @@ -0,0 +1,3 @@ +export const PRODUCTION_API_HOST = 'https://api.wpvip.com'; +export const API_HOST = process.env.API_HOST || PRODUCTION_API_HOST; // NOSONAR +export const API_URL = `${ API_HOST }/graphql`; diff --git a/src/lib/api/feature-flags.ts b/src/lib/api/feature-flags.ts index 6351accbd..bc1b9a6b7 100644 --- a/src/lib/api/feature-flags.ts +++ b/src/lib/api/feature-flags.ts @@ -5,7 +5,20 @@ import API from '../../lib/api'; import type { IsVipQuery, IsVipQueryVariables } from './feature-flags.generated'; -const api: ApolloClient = API( { silenceAuthErrors: true } ); +// Lazy-initialize the API client so that this module can be imported during the +// rechallenge module chain without triggering a circular-dependency crash. The +// cycle that existed before this change: +// api.ts → rechallenge/link.ts → flow.ts → tracker.ts → tracks.ts +// → cli/apiConfig.ts → api/feature-flags.ts → api.ts +// By deferring construction to the first call we ensure api.ts is fully +// evaluated before API() is invoked. +let api: ApolloClient | null = null; +function getApi(): ApolloClient { + if ( ! api ) { + api = API( { silenceAuthErrors: true } ); + } + return api; +} const isVipQuery = gql` query isVIP { @@ -16,7 +29,7 @@ const isVipQuery = gql` `; export function get(): Promise< ApolloClient.QueryResult< IsVipQuery > > { - return api.query< IsVipQuery, IsVipQueryVariables >( { + return getApi().query< IsVipQuery, IsVipQueryVariables >( { query: isVipQuery, fetchPolicy: 'cache-first', } ); diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index 28f46aa74..88c00e07f 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -8,7 +8,7 @@ import { type Response, } from 'undici'; -import { API_HOST } from '../../lib/api'; +import { API_HOST } from './constants'; import env from '../../lib/env'; import { createProxyDispatcher } from '../../lib/http/proxy-dispatcher'; import Token from '../../lib/token'; diff --git a/src/lib/cli/internal-bin-loader.js b/src/lib/cli/internal-bin-loader.js index 964075518..6acda2269 100644 --- a/src/lib/cli/internal-bin-loader.js +++ b/src/lib/cli/internal-bin-loader.js @@ -22,6 +22,10 @@ const internalBinLoaders = { 'vip-config-software-update': () => import( '../../bin/vip-config-software-update' ), 'vip-db': () => import( '../../bin/vip-db' ), 'vip-db-phpmyadmin': () => import( '../../bin/vip-db-phpmyadmin' ), + 'vip-defensive-mode': () => import( '../../bin/vip-defensive-mode' ), + 'vip-defensive-mode-configure': () => import( '../../bin/vip-defensive-mode-configure' ), + 'vip-defensive-mode-disable': () => import( '../../bin/vip-defensive-mode-disable' ), + 'vip-defensive-mode-enable': () => import( '../../bin/vip-defensive-mode-enable' ), 'vip-dev-env': () => import( '../../bin/vip-dev-env' ), 'vip-dev-env-create': () => import( '../../bin/vip-dev-env-create' ), 'vip-dev-env-destroy': () => import( '../../bin/vip-dev-env-destroy' ), diff --git a/src/lib/defensive-mode/api.ts b/src/lib/defensive-mode/api.ts new file mode 100644 index 000000000..6d4ee4d8d --- /dev/null +++ b/src/lib/defensive-mode/api.ts @@ -0,0 +1,129 @@ +import gql from 'graphql-tag'; + +import API from '../api'; + +export const appQuery = ` + id + name + typeId + environments { + id + appId + name + primaryDomain { + name + } + type + defensiveMode { + config { + effective { + enabled + challengeType + connectionThresholdAbsolute + connectionThresholdPercentage + disableAtEpoch + keepEnabledUnderThresholdForSeconds + maxRequestRate + priorityBypass + } + stored { + enabled + challengeType + connectionThresholdAbsolute + connectionThresholdPercentage + } + } + } + } + organization { + id + name + } +`; + +const STATUS_MUTATION = gql` + mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput) { + updateDefensiveModeStatus(input: $input) { + success + message + } + } +`; + +const CONFIG_MUTATION = gql` + mutation UpdateDefensiveModeConfig($input: AppEnvironmentDefensiveModeConfigInput) { + updateDefensiveModeConfig(input: $input) { + success + message + } + } +`; + +export interface UpdateStatusInput { + appId: number; + envId: number; + enabled: boolean; +} + +export interface UpdateConfigInput { + appId: number; + envId: number; + enabled: boolean; + challengeType: number; + connectionThresholdAbsolute?: number; + connectionThresholdPercentage?: number; +} + +export async function updateDefensiveModeStatus( + input: UpdateStatusInput +): Promise< { success: boolean; message: string } > { + const api = API(); + const result = await api.mutate( { + mutation: STATUS_MUTATION, + variables: { + input: { id: input.appId, environmentId: input.envId, enabled: input.enabled }, + }, + } ); + if ( ! result.data ) { + throw new Error( + 'updateDefensiveModeStatus returned no data; the API may have rejected the request.' + ); + } + return ( + result.data as { + updateDefensiveModeStatus: { success: boolean; message: string }; + } + ).updateDefensiveModeStatus; +} + +export async function updateDefensiveModeConfig( + input: UpdateConfigInput +): Promise< { success: boolean; message: string } > { + const api = API(); + const mutationInput: Record< string, unknown > = { + id: input.appId, + environmentId: input.envId, + enabled: input.enabled, + challengeType: input.challengeType, + }; + if ( input.connectionThresholdAbsolute !== undefined ) { + mutationInput.connectionThresholdAbsolute = input.connectionThresholdAbsolute; + } + if ( input.connectionThresholdPercentage !== undefined ) { + mutationInput.connectionThresholdPercentage = input.connectionThresholdPercentage; + } + const result = await api.mutate( { + mutation: CONFIG_MUTATION, + variables: { input: mutationInput }, + } ); + if ( ! result.data ) { + throw new Error( + 'updateDefensiveModeConfig returned no data; the API may have rejected the request.' + ); + } + return ( + result.data as { + updateDefensiveModeConfig: { success: boolean; message: string }; + } + ).updateDefensiveModeConfig; +} diff --git a/src/lib/defensive-mode/cli-helpers.ts b/src/lib/defensive-mode/cli-helpers.ts new file mode 100644 index 000000000..2923affa8 --- /dev/null +++ b/src/lib/defensive-mode/cli-helpers.ts @@ -0,0 +1,91 @@ +import chalk from 'chalk'; + +export function isInteractive( opt: { nonInteractive?: boolean } ): boolean { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) { + return false; + } + if ( opt.nonInteractive ) { + return false; + } + return Boolean( process.stdout.isTTY ); +} + +export interface ProductionGuardOptions { + app: { name: string }; + env: { type: string }; + skipConfirmation?: boolean; + nonInteractive?: boolean; +} + +function capitalize( s: string ): string { + return s.charAt( 0 ).toUpperCase() + s.slice( 1 ); +} + +/** + * Guards production mutations that require confirmation. Returns true if the + * command should proceed. In non-interactive contexts without + * --skip-confirmation it emits an error and calls process.exit(1) directly. + * If the user declines the interactive prompt it calls process.exit() directly. + */ +export async function guardProductionMutation( + opt: ProductionGuardOptions, + action: 'enable' | 'disable' | 'configure', + trackingParams: Record< string, unknown >, + confirmFn: ( message: string ) => Promise< boolean >, + trackEventFn: ( event: string, props: Record< string, unknown > ) => Promise< void >, + formatEnvironment: ( type: string ) => string +): Promise< boolean > { + if ( opt.skipConfirmation || opt.env.type !== 'production' ) { + return true; + } + if ( ! isInteractive( opt ) ) { + console.error( + chalk.red( + `Refusing to ${ action } defensive mode on production without confirmation. ` + + 'Pass --skip-confirmation to proceed non-interactively.' + ) + ); + await trackEventFn( `defensive_mode_${ action }_command_cancelled`, trackingParams ); + process.exit( 1 ); + } + const yes = await confirmFn( + `${ capitalize( action ) } defensive mode on ${ formatEnvironment( opt.env.type ) } for ${ + opt.app.name + }?` + ); + if ( ! yes ) { + await trackEventFn( `defensive_mode_${ action }_command_cancelled`, trackingParams ); + console.log( 'Command cancelled' ); + process.exit(); + } + return true; +} + +/** + * Handles success/failure reporting, telemetry, and log output after a + * defensive-mode mutation. Exits the process on failure. + */ +export async function reportMutationResult( + result: { success: boolean; message: string }, + trackingParams: Record< string, unknown >, + action: 'enable' | 'disable' | 'configure', + appName: string, + envType: string, + successVerb: string, + failureVerb: string, + trackEventFn: ( event: string, props: Record< string, unknown > ) => Promise< void > +): Promise< void > { + if ( ! result.success ) { + await trackEventFn( `defensive_mode_${ action }_command_error`, { + ...trackingParams, + error: result.message, + } ); + console.error( chalk.red( `Failed to ${ failureVerb }: ${ result.message }` ) ); + process.exit( 1 ); + } + await trackEventFn( `defensive_mode_${ action }_command_success`, trackingParams ); + console.log( + chalk.green( '✓' ), + `Defensive mode ${ successVerb } for ${ appName }.${ envType } — ${ result.message }` + ); +} diff --git a/src/lib/logout.ts b/src/lib/logout.ts index 26dfd217e..9a84fdad6 100644 --- a/src/lib/logout.ts +++ b/src/lib/logout.ts @@ -1,4 +1,5 @@ import http from '../lib/api/http'; +import tokenCache from '../lib/rechallenge/token-cache'; import Token from '../lib/token'; import { trackEvent } from '../lib/tracker'; @@ -6,6 +7,7 @@ export default async (): Promise< void > => { await http( '/logout', { method: 'post' } ); await Token.purge(); + await tokenCache.clearAll(); await trackEvent( 'logout_command_execute' ); }; diff --git a/src/lib/rechallenge/client.ts b/src/lib/rechallenge/client.ts new file mode 100644 index 000000000..90bd42551 --- /dev/null +++ b/src/lib/rechallenge/client.ts @@ -0,0 +1,69 @@ +import debugLib from 'debug'; +import { randomUUID } from 'node:crypto'; + +import { RechallengeHttpError } from './errors'; +import { CLIENT_TYPE } from './types'; +import http from '../api/http'; + +import type { + ElevatedTokenExchangeResponse, + RechallengeSession, + RechallengeSessionStatus, +} from './types'; + +// Derived from http() rather than imported from a fetch library, so this module +// keeps compiling across the node-fetch -> undici migration (trunk #2837). +type HttpResponse = Awaited< ReturnType< typeof http > >; + +const debug = debugLib( '@automattic/vip:rechallenge:client' ); + +function fillTemplate( template: string, challengeId: string ): string { + return template.replaceAll( '{challengeId}', encodeURIComponent( challengeId ) ); +} + +async function parseOrThrow< T >( response: HttpResponse, scope: string ): Promise< T > { + if ( ! response.ok ) { + const text = await response.text(); + throw new RechallengeHttpError( response.status, text, scope ); + } + return ( await response.json() ) as T; +} + +export async function createSession( opts: { + path: string; + requestedOperation: string; +} ): Promise< RechallengeSession > { + debug( 'createSession scope=%s', opts.requestedOperation ); + const response = await http( opts.path, { + method: 'POST', + headers: { + // New UUID per call — intent is a fresh session per invocation, not request deduplication. + 'Idempotency-Key': randomUUID(), + }, + body: { + clientType: CLIENT_TYPE, + requestedOperation: opts.requestedOperation, + }, + } ); + return parseOrThrow< RechallengeSession >( response, opts.requestedOperation ); +} + +export async function getSessionStatus( opts: { + template: string; + challengeId: string; + scope?: string; +} ): Promise< RechallengeSessionStatus > { + const path = fillTemplate( opts.template, opts.challengeId ); + const response = await http( path, { method: 'GET' } ); + return parseOrThrow< RechallengeSessionStatus >( response, opts.scope ?? '' ); +} + +export async function exchange( opts: { + template: string; + challengeId: string; + scope?: string; +} ): Promise< ElevatedTokenExchangeResponse > { + const path = fillTemplate( opts.template, opts.challengeId ); + const response = await http( path, { method: 'POST' } ); + return parseOrThrow< ElevatedTokenExchangeResponse >( response, opts.scope ?? '' ); +} diff --git a/src/lib/rechallenge/errors.ts b/src/lib/rechallenge/errors.ts new file mode 100644 index 000000000..644dfe0c9 --- /dev/null +++ b/src/lib/rechallenge/errors.ts @@ -0,0 +1,54 @@ +import { RECHALLENGE_VERSION } from './types'; + +import type { RechallengeStatus } from './types'; + +export class RechallengeError extends Error { + public readonly scope: string; + constructor( message: string, scope: string ) { + super( message ); + this.name = 'RechallengeError'; + this.scope = scope; + } +} + +export class RechallengeUnsupportedVersionError extends RechallengeError { + constructor( version: string, scope: string ) { + super( + `Server requested rechallenge version "${ version }" but this CLI only supports ${ RECHALLENGE_VERSION }. Update vip-cli.`, + scope + ); + this.name = 'RechallengeUnsupportedVersionError'; + } +} + +export class RechallengeTerminalError extends RechallengeError { + public readonly status: RechallengeStatus; + constructor( status: RechallengeStatus, scope: string, detail?: string ) { + super( + `Step-up verification did not complete (status=${ status })${ + detail ? `: ${ detail }` : '' + }.`, + scope + ); + this.name = 'RechallengeTerminalError'; + this.status = status; + } +} + +export class RechallengeAbortedError extends RechallengeError { + constructor( scope: string ) { + super( 'Step-up verification was cancelled.', scope ); + this.name = 'RechallengeAbortedError'; + } +} + +export class RechallengeHttpError extends RechallengeError { + public readonly statusCode: number; + public readonly bodyText: string; + constructor( statusCode: number, bodyText: string, scope: string ) { + super( `Step-up verification request failed (HTTP ${ statusCode }): ${ bodyText }`, scope ); + this.name = 'RechallengeHttpError'; + this.statusCode = statusCode; + this.bodyText = bodyText; + } +} diff --git a/src/lib/rechallenge/flow.ts b/src/lib/rechallenge/flow.ts new file mode 100644 index 000000000..516281334 --- /dev/null +++ b/src/lib/rechallenge/flow.ts @@ -0,0 +1,160 @@ +import chalk from 'chalk'; +import debugLib from 'debug'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { trackEvent } from '../tracker'; +import * as client from './client'; +import { + RechallengeAbortedError, + RechallengeTerminalError, + RechallengeUnsupportedVersionError, +} from './errors'; +import { openBrowser } from './open-browser'; +import tokenCache from './token-cache'; +import { CLIENT_TYPE, RECHALLENGE_VERSION } from './types'; + +import type { ElevatedToken, RechallengeExtension, RechallengeStatus } from './types'; + +const debug = debugLib( '@automattic/vip:rechallenge:flow' ); + +// Floor for the server-provided poll interval. Guards against a missing/0/NaN +// value (which would otherwise produce a tight status-poll loop) and clamps +// implausibly small values so a misbehaving server cannot make us hammer the API. +const MIN_POLL_INTERVAL_SECONDS = 2; + +const TERMINAL: ReadonlySet< RechallengeStatus > = new Set( [ + 'verified', + 'expired', + 'failed', + 'cancelled', +] ); + +export interface RunRechallengeOptions { + requestedOperation: string; + rechallenge: RechallengeExtension; + interactive: boolean; + signal?: AbortSignal; +} + +export async function runRechallenge( opts: RunRechallengeOptions ): Promise< ElevatedToken > { + const { requestedOperation, rechallenge, interactive, signal } = opts; + + if ( rechallenge.version !== RECHALLENGE_VERSION ) { + throw new RechallengeUnsupportedVersionError( rechallenge.version, requestedOperation ); + } + + await trackEvent( 'rechallenge_required', { + scope: requestedOperation, + clientType: CLIENT_TYPE, + } ); + + const session = await client.createSession( { + path: rechallenge.createSessionPath, + requestedOperation, + } ); + await trackEvent( 'rechallenge_session_created', { scope: requestedOperation } ); + + const verificationUrl = session.verificationUrl; + const expiresIso = session.expiresAt; + if ( interactive ) { + await openBrowser( verificationUrl ); + console.warn( + chalk.yellow( '⚠' ), + `Step-up verification required for ${ chalk.bold( requestedOperation ) }.` + ); + console.warn( ` Opened ${ chalk.cyan( verificationUrl ) }` ); + console.warn( + ` If your browser did not open, copy and paste the URL above. Expires at ${ expiresIso }.` + ); + } else { + console.warn( + `Step-up verification required for ${ requestedOperation }. ` + + `Complete it at: ${ verificationUrl } (expires at ${ expiresIso }).` + ); + } + + const requestedInterval = Number( session.pollIntervalSeconds ); + const interval = + Math.max( + Number.isFinite( requestedInterval ) ? requestedInterval : MIN_POLL_INTERVAL_SECONDS, + MIN_POLL_INTERVAL_SECONDS + ) * 1000; + const deadline = Date.parse( session.expiresAt ); + if ( Number.isNaN( deadline ) ) { + throw new RechallengeTerminalError( + 'expired', + requestedOperation, + 'server returned unparseable expiresAt' + ); + } + + /* eslint-disable no-await-in-loop -- polling loop; each iteration must complete before the next */ + while ( true ) { + if ( signal?.aborted ) { + throw new RechallengeAbortedError( requestedOperation ); + } + + try { + await sleep( interval, undefined, { signal } ); + } catch { + throw new RechallengeAbortedError( requestedOperation ); + } + + if ( ! Number.isNaN( deadline ) && Date.now() > deadline ) { + throw new RechallengeTerminalError( + 'expired', + requestedOperation, + 'session window elapsed before completion' + ); + } + + const status = await client.getSessionStatus( { + template: rechallenge.statusPathTemplate, + challengeId: session.challengeId, + scope: requestedOperation, + } ); + + if ( ! TERMINAL.has( status.status ) ) { + debug( 'still %s; polling again', status.status ); + continue; + } + + if ( status.status === 'verified' ) { + await trackEvent( 'rechallenge_verified', { + scope: requestedOperation, + provider: status.provider ?? 'unknown', + } ); + const { elevatedToken } = await client.exchange( { + template: rechallenge.exchangePathTemplate, + challengeId: session.challengeId, + scope: requestedOperation, + } ); + await trackEvent( 'rechallenge_exchanged', { scope: requestedOperation } ); + await tokenCache.set( requestedOperation, { + ...elevatedToken, + headerName: rechallenge.elevatedHeaderName, + } ); + return elevatedToken; + } + + await trackEvent( `rechallenge_${ status.status }`, { + scope: requestedOperation, + } ); + throw new RechallengeTerminalError( + status.status, + requestedOperation, + status.statusReason?.message + ); + } + /* eslint-enable no-await-in-loop */ +} + +export function isInteractiveContext( argvOrFlags: string[] = process.argv ): boolean { + if ( process.env.VIP_NON_INTERACTIVE === '1' ) { + return false; + } + if ( argvOrFlags.includes( '--non-interactive' ) ) { + return false; + } + return Boolean( process.stdout.isTTY ); +} diff --git a/src/lib/rechallenge/index.ts b/src/lib/rechallenge/index.ts new file mode 100644 index 000000000..d0bb94656 --- /dev/null +++ b/src/lib/rechallenge/index.ts @@ -0,0 +1,4 @@ +export { default as rechallengeLink } from './link'; +export { default as tokenCache } from './token-cache'; +export * from './types'; +export * from './errors'; diff --git a/src/lib/rechallenge/link.ts b/src/lib/rechallenge/link.ts new file mode 100644 index 000000000..2f3909932 --- /dev/null +++ b/src/lib/rechallenge/link.ts @@ -0,0 +1,165 @@ +import { ApolloLink, Observable } from '@apollo/client/core'; +import debugLib from 'debug'; +import { Kind, OperationTypeNode } from 'graphql'; + +import { isInteractiveContext, runRechallenge } from './flow'; +import tokenCache from './token-cache'; +import { ELEVATED_PERMISSION_ERROR_CODE } from './types'; + +import type { ElevatedToken, RechallengeExtension } from './types'; +import type { DocumentNode, FieldNode, OperationDefinitionNode } from 'graphql'; + +const debug = debugLib( '@automattic/vip:rechallenge:link' ); + +function operationDefinition( doc: DocumentNode ): OperationDefinitionNode | undefined { + return doc.definitions.find( + ( def ): def is OperationDefinitionNode => def.kind === Kind.OPERATION_DEFINITION + ); +} + +function isMutation( doc: DocumentNode ): boolean { + return operationDefinition( doc )?.operation === OperationTypeNode.MUTATION; +} + +function primaryMutationFieldName( doc: DocumentNode ): string | null { + const op = operationDefinition( doc ); + if ( ! op || op.operation !== OperationTypeNode.MUTATION ) { + return null; + } + const first = op.selectionSet.selections.find( + ( sel ): sel is FieldNode => sel.kind === Kind.FIELD + ); + return first?.name.value ?? null; +} + +interface ElevatedPermissionPayload { + rechallenge: RechallengeExtension; +} + +function extractElevatedPermission( result: ApolloLink.Result ): ElevatedPermissionPayload | null { + const errors = result.errors ?? []; + for ( const err of errors ) { + const ext = ( err.extensions ?? {} ) as Record< string, unknown >; + if ( ext.code !== ELEVATED_PERMISSION_ERROR_CODE ) { + continue; + } + const rechallenge = ext.rechallenge as RechallengeExtension | undefined; + if ( + rechallenge && + typeof rechallenge.createSessionPath === 'string' && + typeof rechallenge.statusPathTemplate === 'string' && + typeof rechallenge.exchangePathTemplate === 'string' && + typeof rechallenge.elevatedHeaderName === 'string' + ) { + return { rechallenge }; + } + } + return null; +} + +function attachElevatedHeader( + operation: ApolloLink.Operation, + headerName: string, + token: ElevatedToken +): void { + const ctx = operation.getContext() as { + headers?: Record< string, string >; + }; + const headers = { ...( ctx.headers ?? {} ) }; + headers[ headerName ] = token.token; + operation.setContext( { ...ctx, headers } ); +} + +const DEFAULT_HEADER = 'x-elevated-token'; + +export default function createRechallengeLink(): ApolloLink { + return new ApolloLink( ( operation, forward ) => { + const scope = primaryMutationFieldName( operation.query ); + const eligible = isMutation( operation.query ) && Boolean( scope ); + + return new Observable< ApolloLink.Result >( observer => { + let retrying = false; + let cancelled = false; + let firstSub: { unsubscribe(): void } | null = null; + let retrySub: { unsubscribe(): void } | null = null; + const abortController = new AbortController(); + + const preflight = async () => { + if ( ! eligible || ! scope ) { + return; + } + const cached = await tokenCache.get( scope ); + if ( cached ) { + attachElevatedHeader( operation, cached.headerName || DEFAULT_HEADER, cached ); + } + }; + + void preflight() + .catch( err => debug( 'preflight error: %o', err ) ) + .then( () => { + if ( cancelled || observer.closed ) { + return; + } + firstSub = forward( operation ).subscribe( { + next: result => { + if ( retrying || ! eligible || ! scope ) { + observer.next( result ); + return; + } + const elevated = extractElevatedPermission( result ); + if ( ! elevated ) { + observer.next( result ); + return; + } + + retrying = true; + const headerName = elevated.rechallenge.elevatedHeaderName || DEFAULT_HEADER; + + void runRechallenge( { + requestedOperation: scope, + rechallenge: elevated.rechallenge, + interactive: isInteractiveContext(), + signal: abortController.signal, + } ) + .then( token => { + if ( cancelled || observer.closed ) { + return; + } + attachElevatedHeader( operation, headerName, token ); + retrySub = forward( operation ).subscribe( { + next: res => observer.next( res ), + error: err => observer.error( err ), + complete: () => observer.complete(), + } ); + } ) + .catch( err => { + debug( 'rechallenge flow failed: %o', err ); + if ( cancelled || observer.closed ) { + return; + } + // Surface the original elevated-permission error to upstream + // so errorLink and consumers see it. + observer.next( result ); + observer.complete(); + } ); + }, + error: err => observer.error( err ), + complete: () => { + if ( ! retrying ) { + observer.complete(); + } + }, + } ); + } ); + + return () => { + cancelled = true; + // Abort any in-flight rechallenge polling so the loop, its tracking + // events, and the token-cache write stop instead of running to expiry. + abortController.abort(); + firstSub?.unsubscribe(); + retrySub?.unsubscribe(); + }; + } ); + } ); +} diff --git a/src/lib/rechallenge/open-browser.ts b/src/lib/rechallenge/open-browser.ts new file mode 100644 index 000000000..00382221d --- /dev/null +++ b/src/lib/rechallenge/open-browser.ts @@ -0,0 +1,11 @@ +import debugLib from 'debug'; + +const debug = debugLib( '@automattic/vip:rechallenge:open-browser' ); + +/** Opens a URL in the default browser. Wraps the ESM-only `open` package. */ +export async function openBrowser( url: string ): Promise< void > { + const { default: open } = await import( 'open' ); + await open( url, { wait: false } ).catch( ( err: unknown ) => { + debug( 'open() failed: %o', err ); + } ); +} diff --git a/src/lib/rechallenge/token-cache.ts b/src/lib/rechallenge/token-cache.ts new file mode 100644 index 000000000..57bd357eb --- /dev/null +++ b/src/lib/rechallenge/token-cache.ts @@ -0,0 +1,110 @@ +import debugLib from 'debug'; + +import { API_HOST, PRODUCTION_API_HOST } from '../api/constants'; +import keychain from '../keychain'; + +import type { ElevatedToken } from './types'; + +const debug = debugLib( '@automattic/vip:rechallenge:cache' ); +// Storage strategy: a single keychain entry holds a JSON map { [scope]: ElevatedToken }. +// The vip-cli Keychain interface (src/lib/keychain/keychain.ts) is service-only — there +// is no separate account argument — so per-scope entries under the keytar model would +// require a different keying scheme. The single-blob approach also keeps clearAll() cheap. +// This is marked subject-to-change in the spec pending security review. +const BASE_SERVICE = 'vip-go-cli:elevated'; + +function serviceName(): string { + if ( API_HOST === PRODUCTION_API_HOST ) { + return BASE_SERVICE; + } + const sanitized = API_HOST.replace( /[^a-z0-9]/gi, '-' ); + return `${ BASE_SERVICE }:${ sanitized }`; +} + +type Blob = Record< string, ElevatedToken >; + +let inMemory: Blob | null = null; + +async function read(): Promise< Blob > { + if ( inMemory ) { + return inMemory; + } + const raw = await keychain.getPassword( serviceName() ); + if ( ! raw ) { + inMemory = {}; + return inMemory; + } + try { + const parsed = JSON.parse( raw ) as Blob; + inMemory = typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch ( err ) { + debug( 'Failed to parse elevated token blob; resetting (%o)', err ); + inMemory = {}; + await keychain.deletePassword( serviceName() ); + } + return inMemory; +} + +async function write( blob: Blob ): Promise< void > { + inMemory = blob; + if ( Object.keys( blob ).length === 0 ) { + await keychain.deletePassword( serviceName() ); + return; + } + await keychain.setPassword( serviceName(), JSON.stringify( blob ) ); +} + +function isExpired( token: ElevatedToken ): boolean { + const exp = Date.parse( token.expiresAt ); + if ( Number.isNaN( exp ) ) { + return true; + } + // Treat tokens within the next 5 seconds as effectively expired. + return Date.now() >= exp - 5_000; +} + +async function get( scope: string ): Promise< ElevatedToken | null > { + const blob = await read(); + const token = blob[ scope ]; + if ( ! token ) { + return null; + } + if ( isExpired( token ) ) { + debug( 'Cached elevated token for %s is expired; evicting', scope ); + const { [ scope ]: _evicted, ...rest } = blob; + await write( rest ); + return null; + } + return token; +} + +async function set( scope: string, token: ElevatedToken ): Promise< void > { + const blob = await read(); + blob[ scope ] = token; + await write( blob ); +} + +async function clearScope( scope: string ): Promise< void > { + const blob = await read(); + if ( scope in blob ) { + const { [ scope ]: _removed, ...rest } = blob; + await write( rest ); + } +} + +async function clearAll(): Promise< void > { + inMemory = {}; + await keychain.deletePassword( serviceName() ); +} + +function _resetInMemoryForTests(): void { + inMemory = null; +} + +export default { + get, + set, + clearScope, + clearAll, + _resetInMemoryForTests, +}; diff --git a/src/lib/rechallenge/types.ts b/src/lib/rechallenge/types.ts new file mode 100644 index 000000000..bdf3b6f12 --- /dev/null +++ b/src/lib/rechallenge/types.ts @@ -0,0 +1,46 @@ +export const ELEVATED_PERMISSION_ERROR_CODE = 'elevated-permission-required'; +export const RECHALLENGE_VERSION = 'v2'; +export const CLIENT_TYPE = 'cli'; + +export type RechallengeStatus = 'pending' | 'verified' | 'expired' | 'failed' | 'cancelled'; + +/** Shape of `errors[0].extensions.rechallenge` from the API. */ +export interface RechallengeExtension { + version: string; + createSessionPath: string; + statusPathTemplate: string; + exchangePathTemplate: string; + elevatedHeaderName: string; +} + +/** Response from POST {createSessionPath}. */ +export interface RechallengeSession { + challengeId: string; + status: RechallengeStatus; + verificationUrl: string; + pollIntervalSeconds: number; + expiresAt: string; // ISO-8601 +} + +/** Response from GET {statusPathTemplate}. */ +export interface RechallengeSessionStatus { + challengeId: string; + status: RechallengeStatus; + expiresAt: string; + verifiedAt?: string; + provider?: 'passkeys' | 'totp' | 'sso-saml' | 'unknown'; + pollIntervalSeconds: number; + statusReason?: { code: string; message: string }; +} + +/** Response from POST {exchangePathTemplate}. */ +export interface ElevatedTokenExchangeResponse { + elevatedToken: ElevatedToken; +} + +export interface ElevatedToken { + token: string; + expiresAt: string; // ISO-8601 + purpose: string; + headerName?: string; +} diff --git a/src/lib/token.ts b/src/lib/token.ts index fa7c47199..8ee2dd88d 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -1,7 +1,7 @@ import { jwtDecode } from 'jwt-decode'; import { randomUUID } from 'node:crypto'; -import { API_HOST, PRODUCTION_API_HOST } from './api'; +import { API_HOST, PRODUCTION_API_HOST } from './api/constants'; import keychain from './keychain'; interface Payload {