Skip to content

Commit ce4be9a

Browse files
Dashboard: track auth outcomes with a bumpStat counter (#112682)
Co-authored-by: Philip Jackson <p-jackson@live.com>
1 parent 56019c1 commit ce4be9a

2 files changed

Lines changed: 191 additions & 32 deletions

File tree

client/dashboard/app/auth/index.tsx

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,23 @@ import { setUser } from '@automattic/calypso-sentry';
55
import { isSupportUserSession } from '@automattic/calypso-support-session';
66
import { magnificentNonEnLocales } from '@automattic/i18n-utils';
77
import {
8+
hashKey,
89
useQuery,
910
useQueryClient,
1011
type QueryCacheNotifyEvent,
1112
type MutationCacheNotifyEvent,
1213
} from '@tanstack/react-query';
1314
import { createContext, useContext, useMemo, useEffect, useRef, useCallback } from 'react';
1415
import { wpcomLink } from '../../utils/link';
16+
import { bumpStat } from '../analytics';
1517
import { useAppContext } from '../context';
1618
import { OAUTH_CALLBACK_PATH } from './oauth-callback';
1719
import type { WPError } from '@automattic/api-core';
1820

1921
export const AUTH_QUERY_KEY = [ 'auth', 'user' ];
2022

23+
const BOOTSTRAP_ERROR_MESSAGE = 'Failed to bootstrap user object';
24+
2125
function getOAuthAuthorizeUrl( {
2226
state,
2327
next = '',
@@ -56,22 +60,37 @@ interface AuthContextType {
5660
}
5761
export const AuthContext = createContext< AuthContextType | undefined >( undefined );
5862

59-
export async function initializeCurrentUser(): Promise< User > {
63+
function shouldUseBootstrap(): boolean {
6064
// In support user session the `currentUser` refers to the wrong person so we should request
6165
// the user object. Note we do not check `isSupportNextSession()` because in "next" support
6266
// sessions the server does bootstrap the correct `currentUser`.
63-
const useBootstrap = ! isSupportUserSession() && config.isEnabled( 'wpcom-user-bootstrap' );
67+
return ! isSupportUserSession() && config.isEnabled( 'wpcom-user-bootstrap' );
68+
}
6469

65-
if ( useBootstrap ) {
70+
export async function initializeCurrentUser(): Promise< User > {
71+
if ( shouldUseBootstrap() ) {
6672
if ( window.currentUser ) {
6773
return window.currentUser;
6874
}
69-
throw new Error( 'Failed to bootstrap user object' );
75+
throw new Error( BOOTSTRAP_ERROR_MESSAGE );
7076
}
7177

7278
return fetchUser();
7379
}
7480

81+
function getAuthErrorReason( error: unknown ): string {
82+
if ( error instanceof Error && error.message === BOOTSTRAP_ERROR_MESSAGE ) {
83+
return 'bootstrap';
84+
}
85+
if (
86+
isWpError( error ) &&
87+
( error.error === 'authorization_required' || error.statusCode === 401 )
88+
) {
89+
return 'unauthorized';
90+
}
91+
return 'error';
92+
}
93+
7594
/**
7695
* This component:
7796
* 1. Fetches and provides auth data via context
@@ -87,6 +106,7 @@ export function AuthProvider( { children }: { children: React.ReactNode } ) {
87106
data: user,
88107
isLoading: userIsLoading,
89108
isError: userIsError,
109+
error: userError,
90110
} = useQuery( {
91111
queryKey: AUTH_QUERY_KEY,
92112
queryFn: initializeCurrentUser,
@@ -108,37 +128,42 @@ export function AuthProvider( { children }: { children: React.ReactNode } ) {
108128
};
109129
}, [ user ] );
110130

111-
const handleAuthError = useCallback( () => {
112-
// Prevents repeated calls to redirect
113-
if ( authErrorHandled.current ) {
114-
return;
115-
}
131+
const handleAuthError = useCallback(
132+
( reason: string ) => {
133+
// Prevents repeated calls to redirect
134+
if ( authErrorHandled.current ) {
135+
return;
136+
}
116137

117-
authErrorHandled.current = true;
138+
authErrorHandled.current = true;
118139

119-
if ( config.isEnabled( 'oauth' ) ) {
120-
const state = crypto.randomUUID();
121-
sessionStorage.setItem( 'wpcom_oauth_state', state );
140+
bumpStat( 'dashboard-auth', `bounce:${ reason }` );
122141

123-
// Default to the signup screen rather than the login screen for certain routes.
124-
const isNewUser =
125-
supports.startStoreRoute === true && window.location.pathname === '/start-store';
142+
if ( config.isEnabled( 'oauth' ) ) {
143+
const state = crypto.randomUUID();
144+
sessionStorage.setItem( 'wpcom_oauth_state', state );
126145

127-
window.location.replace(
128-
getOAuthAuthorizeUrl( {
129-
state,
130-
isNewUser,
131-
next: window.location.pathname + window.location.search,
132-
} )
133-
);
134-
return;
135-
}
146+
// Default to the signup screen rather than the login screen for certain routes.
147+
const isNewUser =
148+
supports.startStoreRoute === true && window.location.pathname === '/start-store';
136149

137-
const currentPath = window.location.href;
138-
const path = config( 'wpcom_login_url' ) || wpcomLink( '/log-in' );
139-
const loginUrl = `${ path }?redirect_to=${ encodeURIComponent( currentPath ) }`;
140-
window.location.href = loginUrl;
141-
}, [ supports.startStoreRoute ] );
150+
window.location.replace(
151+
getOAuthAuthorizeUrl( {
152+
state,
153+
isNewUser,
154+
next: window.location.pathname + window.location.search,
155+
} )
156+
);
157+
return;
158+
}
159+
160+
const currentPath = window.location.href;
161+
const path = config( 'wpcom_login_url' ) || wpcomLink( '/log-in' );
162+
const loginUrl = `${ path }?redirect_to=${ encodeURIComponent( currentPath ) }`;
163+
window.location.href = loginUrl;
164+
},
165+
[ supports.startStoreRoute ]
166+
);
142167

143168
// Subscribe to network errors and when errors occur due to being logged
144169
// out, redirect the user to the log in screen.
@@ -148,13 +173,18 @@ export function AuthProvider( { children }: { children: React.ReactNode } ) {
148173
};
149174

150175
const handleEvent = ( event: MutationCacheNotifyEvent | QueryCacheNotifyEvent ) => {
176+
// Errors fetching the user object itself are handled (and classified) below.
177+
if ( 'query' in event && event.query.queryHash === hashKey( AUTH_QUERY_KEY ) ) {
178+
return;
179+
}
180+
151181
if (
152182
event.type === 'updated' &&
153183
event.action.type === 'error' &&
154184
isWpError( event.action.error ) &&
155185
isAuthError( event.action.error )
156186
) {
157-
handleAuthError();
187+
handleAuthError( 'expired' );
158188
}
159189
};
160190
const unsubMutationCache = queryClient.getMutationCache().subscribe( handleEvent );
@@ -165,17 +195,23 @@ export function AuthProvider( { children }: { children: React.ReactNode } ) {
165195
};
166196
}, [ queryClient, handleAuthError ] );
167197

198+
const successStatBumped = useRef( false );
168199
useEffect( () => {
169200
if ( user?.ID ) {
170201
setUser( { id: user.ID.toString() } );
202+
203+
if ( ! successStatBumped.current ) {
204+
successStatBumped.current = true;
205+
bumpStat( 'dashboard-auth', shouldUseBootstrap() ? 'success:bootstrap' : 'success:fetch' );
206+
}
171207
}
172208
}, [ user ] );
173209

174210
// Handles _all_ errors fetching the user object, regardless of whether they are
175211
// `authorization_required` errors or not.
176212
if ( userIsError ) {
177213
if ( typeof window !== 'undefined' ) {
178-
handleAuthError();
214+
handleAuthError( getAuthErrorReason( userError ) );
179215
}
180216
return null;
181217
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import config from '@automattic/calypso-config';
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6+
import { render, screen, waitFor } from '@testing-library/react';
7+
import nock from 'nock';
8+
import { bumpStat } from '../../analytics';
9+
import { AppProvider, APP_CONTEXT_DEFAULT_CONFIG } from '../../context';
10+
import { AuthProvider } from '../index';
11+
import type { User } from '@automattic/api-core';
12+
13+
jest.mock( '../../analytics', () => ( {
14+
...jest.requireActual( '../../analytics' ),
15+
bumpStat: jest.fn(),
16+
} ) );
17+
18+
const mockedBumpStat = jest.mocked( bumpStat );
19+
20+
const testUser = { ID: 1, username: 'testuser', language: 'en' } as User;
21+
22+
function wpError( fields: { status: number; statusCode: number; error?: string } ) {
23+
return Object.assign( new Error( 'boom' ), fields );
24+
}
25+
26+
function renderAuth() {
27+
const queryClient = new QueryClient();
28+
return {
29+
queryClient,
30+
...render(
31+
<QueryClientProvider client={ queryClient }>
32+
<AppProvider config={ APP_CONTEXT_DEFAULT_CONFIG }>
33+
<AuthProvider>
34+
<div>signed in</div>
35+
</AuthProvider>
36+
</AppProvider>
37+
</QueryClientProvider>
38+
),
39+
};
40+
}
41+
42+
describe( '<AuthProvider> stats', () => {
43+
beforeEach( () => {
44+
Object.defineProperty( window, 'location', {
45+
writable: true,
46+
value: { href: 'https://example.com/sites', pathname: '/sites', search: '' },
47+
} );
48+
} );
49+
50+
afterEach( () => {
51+
config.disable( 'wpcom-user-bootstrap' );
52+
delete window.currentUser;
53+
} );
54+
55+
test( 'bumps a success stat when the bootstrapped user is available', async () => {
56+
config.enable( 'wpcom-user-bootstrap' );
57+
window.currentUser = testUser;
58+
59+
renderAuth();
60+
61+
expect( await screen.findByText( 'signed in' ) ).toBeVisible();
62+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'success:bootstrap' );
63+
} );
64+
65+
test( 'bumps a bounce stat and redirects to login when the bootstrapped user is missing', async () => {
66+
config.enable( 'wpcom-user-bootstrap' );
67+
68+
renderAuth();
69+
70+
await waitFor( () =>
71+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'bounce:bootstrap' )
72+
);
73+
expect( window.location.href ).toContain( '/log-in?redirect_to=' );
74+
expect( screen.queryByText( 'signed in' ) ).not.toBeInTheDocument();
75+
} );
76+
77+
test( 'bumps a success stat when the user is fetched from the API', async () => {
78+
nock( 'https://public-api.wordpress.com' )
79+
.get( '/rest/v1.1/me' )
80+
.query( true )
81+
.reply( 200, testUser );
82+
83+
renderAuth();
84+
85+
expect( await screen.findByText( 'signed in' ) ).toBeVisible();
86+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'success:fetch' );
87+
} );
88+
89+
test( 'bumps a bounce stat and redirects to login when fetching the user is unauthorized', async () => {
90+
nock( 'https://public-api.wordpress.com' )
91+
.get( '/rest/v1.1/me' )
92+
.query( true )
93+
.reply( 403, { error: 'authorization_required', message: 'User cannot access this' } );
94+
95+
renderAuth();
96+
97+
await waitFor( () =>
98+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'bounce:unauthorized' )
99+
);
100+
expect( window.location.href ).toContain( '/log-in?redirect_to=' );
101+
} );
102+
103+
test( 'bumps a bounce stat when the session expires mid-app', async () => {
104+
config.enable( 'wpcom-user-bootstrap' );
105+
window.currentUser = testUser;
106+
107+
const { queryClient } = renderAuth();
108+
expect( await screen.findByText( 'signed in' ) ).toBeVisible();
109+
110+
const error = wpError( { status: 401, statusCode: 401, error: 'authorization_required' } );
111+
await expect(
112+
queryClient.fetchQuery( {
113+
queryKey: [ 'some-data' ],
114+
queryFn: () => Promise.reject( error ),
115+
retry: false,
116+
} )
117+
).rejects.toBe( error );
118+
119+
await waitFor( () =>
120+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'bounce:expired' )
121+
);
122+
} );
123+
} );

0 commit comments

Comments
 (0)