diff --git a/cli/commands/auth/logout.ts b/cli/commands/auth/logout.ts new file mode 100644 index 000000000..40a86ddb6 --- /dev/null +++ b/cli/commands/auth/logout.ts @@ -0,0 +1,55 @@ +import { __ } from '@wordpress/i18n'; +import { AuthCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { revokeAuthToken } from 'cli/lib/api'; +import { + readAppdata, + saveAppdata, + lockAppdata, + unlockAppdata, + getAuthToken, +} from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand(): Promise< void > { + const logger = new Logger< LoggerAction >(); + + logger.reportStart( LoggerAction.LOGOUT, __( 'Logging out…' ) ); + let token: Awaited< ReturnType< typeof getAuthToken > >; + + try { + token = await getAuthToken(); + } catch ( error ) { + logger.reportSuccess( __( 'Already logged out' ) ); + return; + } + + try { + await revokeAuthToken( token.accessToken ); + + await lockAppdata(); + const userData = await readAppdata(); + delete userData.authToken; + await saveAppdata( userData ); + + logger.reportSuccess( __( 'Successfully logged out' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to log out' ), error ) ); + } + } finally { + await unlockAppdata(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'logout', + describe: __( 'Log out and clear WordPress.com authentication' ), + handler: async () => { + await runCommand(); + }, + } ); +}; diff --git a/cli/commands/auth/tests/logout.test.ts b/cli/commands/auth/tests/logout.test.ts new file mode 100644 index 000000000..5d7f71de9 --- /dev/null +++ b/cli/commands/auth/tests/logout.test.ts @@ -0,0 +1,109 @@ +import { revokeAuthToken } from 'cli/lib/api'; +import { + getAuthToken, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata' ); +jest.mock( 'cli/logger' ); +jest.mock( 'cli/lib/api' ); + +describe( 'Auth Logout Command', () => { + const mockAppdata = { + authToken: { + accessToken: 'existing-token', + id: 999, + email: 'existing@example.com', + displayName: 'Existing User', + expiresIn: 1209600, + expirationTime: Date.now() + 1209600000, + }, + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + ( getAuthToken as jest.Mock ).mockResolvedValue( mockAppdata.authToken ); + ( revokeAuthToken as jest.Mock ).mockResolvedValue( undefined ); + ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( readAppdata as jest.Mock ).mockResolvedValue( JSON.parse( JSON.stringify( mockAppdata ) ) ); + ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + it( 'should complete the logout process successfully', async () => { + const { runCommand } = await import( '../logout' ); + await runCommand(); + + expect( getAuthToken ).toHaveBeenCalled(); + expect( revokeAuthToken ).toHaveBeenCalled(); + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); + expect( saveAppdata ).toHaveBeenCalledWith( {} ); + expect( unlockAppdata ).toHaveBeenCalled(); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Successfully logged out' ); + } ); + + it( 'should report an error if revoking the token fails', async () => { + ( revokeAuthToken as jest.Mock ).mockRejectedValue( new Error( 'Failed to revoke token' ) ); + + const { runCommand } = await import( '../logout' ); + await runCommand(); + + expect( getAuthToken ).toHaveBeenCalled(); + expect( lockAppdata ).not.toHaveBeenCalled(); + expect( readAppdata ).not.toHaveBeenCalled(); + expect( saveAppdata ).not.toHaveBeenCalledWith( {} ); + expect( unlockAppdata ).toHaveBeenCalled(); + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + } ); + + it( 'should report already logged out if no auth token exists', async () => { + ( getAuthToken as jest.Mock ).mockRejectedValue( new Error( 'No auth token' ) ); + + const { runCommand } = await import( '../logout' ); + await runCommand(); + + expect( getAuthToken ).toHaveBeenCalled(); + expect( revokeAuthToken ).not.toHaveBeenCalled(); + expect( lockAppdata ).not.toHaveBeenCalled(); + expect( readAppdata ).not.toHaveBeenCalled(); + expect( saveAppdata ).not.toHaveBeenCalled(); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Already logged out' ); + } ); + + it( 'should unlock appdata even if save fails', async () => { + ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Failed to save' ) ); + + const { runCommand } = await import( '../logout' ); + await runCommand(); + + expect( revokeAuthToken ).toHaveBeenCalled(); + expect( lockAppdata ).toHaveBeenCalled(); + expect( unlockAppdata ).toHaveBeenCalled(); + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index 159407dca..f02605ea7 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -5,6 +5,7 @@ import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning'; import { StatsGroup, StatsMetric } from 'common/types/stats'; import yargs from 'yargs'; import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login'; +import { registerCommand as registerAuthLogoutCommand } from 'cli/commands/auth/logout'; import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create'; import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete'; import { registerCommand as registerListCommand } from 'cli/commands/preview/list'; @@ -48,6 +49,7 @@ async function main() { } ) .command( 'auth', __( 'Manage authentication' ), ( authYargs ) => { registerAuthLoginCommand( authYargs ); + registerAuthLogoutCommand( authYargs ); authYargs.demandCommand( 1, __( 'You must provide a valid auth command' ) ); } ) .command( 'preview', __( 'Manage preview sites' ), ( previewYargs ) => { diff --git a/cli/lib/api.ts b/cli/lib/api.ts index 827a540b3..ac709b185 100644 --- a/cli/lib/api.ts +++ b/cli/lib/api.ts @@ -164,3 +164,16 @@ export async function getUserInfo( throw new LoggerError( __( 'Failed to fetch user info' ), error ); } } + +export async function revokeAuthToken( token: string ): Promise< void > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + try { + await wpcom.req.del( { + apiNamespace: 'wpcom/v2', + path: '/studio-app/token', + method: 'DELETE', + } ); + } catch ( error ) { + throw new LoggerError( __( 'Failed to revoke token' ), error ); + } +} diff --git a/common/logger-actions.ts b/common/logger-actions.ts index 7694ec765..0e07a70fb 100644 --- a/common/logger-actions.ts +++ b/common/logger-actions.ts @@ -3,6 +3,7 @@ export enum AuthCommandLoggerAction { LOGIN = 'login', + LOGOUT = 'logout', } export enum PreviewCommandLoggerAction {