diff --git a/plugins/sandbox/src/api/RegistrationBackendClient.tsx b/plugins/sandbox/src/api/RegistrationBackendClient.tsx index df0c270..d427edd 100644 --- a/plugins/sandbox/src/api/RegistrationBackendClient.tsx +++ b/plugins/sandbox/src/api/RegistrationBackendClient.tsx @@ -20,6 +20,7 @@ import { isValidCountryCode, isValidPhoneNumber } from '../utils/phone-utils'; import { CommonResponse, SignupData } from '../types'; import { SecureFetchApi } from './SecureFetchClient'; import { SandboxEnvironment } from '../const'; +import UserSignupError from './errors/UserSignupError'; export type RegistrationBackendClientOptions = { configApi: ConfigApi; @@ -67,20 +68,28 @@ export class RegistrationBackendClient implements RegistrationService { ); }; + /** + * Get the "UserSignup" resource for the current user. + * @returns the current user's signup resource or code undefined it is not + * found. + */ getSignUpData = async (): Promise => { const signupURL = await this.signupAPI(); const response = await this.secureFetchApi.fetch(signupURL, { method: 'GET', }); - if (!response.ok) { - if (response.status === 404) { - return undefined; - } - throw new Error( - `Unexpected status code: ${response.status} ${response.statusText}`, - ); + if (response.ok) { + return response.json(); } - return response.json(); + + // The user signup is not found, we return undefined to signal that it is + // safe to create it instead. + if (response.status === 404) { + return undefined; + } + + // At this point we need to signal the user that something went wrong. + throw await UserSignupError.fromResponse(response); }; getRecaptchaToken = async (): Promise => { diff --git a/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx b/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx index 4ae269d..f0cdac5 100644 --- a/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx +++ b/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx @@ -17,35 +17,8 @@ import { ConfigApi } from '@backstage/core-plugin-api'; import { RegistrationBackendClient } from '../RegistrationBackendClient'; import { SecureFetchApi } from '../SecureFetchClient'; - -// Helper to create a mock Response object -const createMockResponse = (options: { - ok: boolean; - status?: number; - statusText?: string; - json?: () => Promise; -}): Response => { - const { ok, status = 200, statusText = '', json } = options; - return { - ok, - status, - statusText, - headers: new Headers(), - redirected: false, - type: 'basic', - url: 'http://mock', - json: json || (() => Promise.resolve({})), - text: () => Promise.resolve(''), - blob: () => Promise.resolve(new Blob()), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - formData: () => Promise.resolve(new FormData()), - bodyUsed: false, - body: null, - clone: function () { - return this; - }, - } as Response; -}; +import { createMockResponse } from '../../test-utils/mockResponse'; +import UserSignupError from '../errors/UserSignupError'; describe('RegistrationBackendClient', () => { let mockConfigApi: jest.Mocked; @@ -103,7 +76,7 @@ describe('RegistrationBackendClient', () => { expect(result).toEqual(mockData); }); - it('should return undefined on 404 response', async () => { + it('should return undefined on a 404 response', async () => { mockConfigApi.getString.mockReturnValue('http://api'); mockSecureFetchApi.fetch.mockResolvedValue( createMockResponse({ @@ -116,7 +89,7 @@ describe('RegistrationBackendClient', () => { expect(result).toBeUndefined(); }); - it('should throw error on other unsuccessful responses', async () => { + it('should throw a UserSignupError on a 500 response', async () => { mockConfigApi.getString.mockReturnValue('http://api'); mockSecureFetchApi.fetch.mockResolvedValue( createMockResponse({ @@ -126,8 +99,28 @@ describe('RegistrationBackendClient', () => { }), ); + await expect(client.getSignUpData()).rejects.toThrow(UserSignupError); + await expect(client.getSignUpData()).rejects.toThrow( + 'Unable to sign you up into Developer Sandbox. Please contact devsandbox@redhat.com', + ); + }); + + it('should throw a UserSignupError with the correct message for a known error', async () => { + mockConfigApi.getString.mockReturnValue('http://api'); + mockSecureFetchApi.fetch.mockResolvedValue( + createMockResponse({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + message: 'user has been suspended', + }), + }), + ); + + await expect(client.getSignUpData()).rejects.toThrow(UserSignupError); await expect(client.getSignUpData()).rejects.toThrow( - 'Unexpected status code: 500 Server Error', + 'Access to the Developer Sandbox has been suspended due to suspicious activity or detected abuse', ); }); }); diff --git a/plugins/sandbox/src/api/__tests__/UserSignupError.test.ts b/plugins/sandbox/src/api/__tests__/UserSignupError.test.ts new file mode 100644 index 0000000..dff532d --- /dev/null +++ b/plugins/sandbox/src/api/__tests__/UserSignupError.test.ts @@ -0,0 +1,165 @@ +import { createMockResponse } from '../../test-utils/mockResponse'; +import UserSignupError from '../errors/UserSignupError'; + +describe('user signup error', () => { + const defaultErrorMessage: string = + 'Unable to sign you up into Developer Sandbox. Please contact devsandbox@redhat.com'; + + it('sets a message when using the main constructor', () => { + const errorMsg = 'test error msg'; + + // Call the function under test. + const userSignupError = new UserSignupError(errorMsg); + + expect(userSignupError.message).toBe(errorMsg); + }); + + it('returns the default error message on internal server errors', async () => { + const response = createMockResponse({ + ok: false, + status: 500, + }); + + // Call the function under test. + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe(defaultErrorMessage); + }); + + it('returns the default error message when the response body is not valid JSON', async () => { + const response = createMockResponse({ + ok: false, + status: 400, + json: () => Promise.reject(new Error('invalid json')), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe(defaultErrorMessage); + }); + + it('returns the default error message when the response body is null', async () => { + const response = createMockResponse({ + ok: false, + status: 400, + json: () => Promise.resolve(null), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe(defaultErrorMessage); + }); + + it('returns the default error message when the response body has no message field', async () => { + const response = createMockResponse({ + ok: false, + status: 400, + json: () => Promise.resolve({ details: 'some detail' }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe(defaultErrorMessage); + }); + + it('returns the invalid code message with details', async () => { + const response = createMockResponse({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + message: 'invalid code provided', + details: 'code ABC123 is not recognized', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe( + 'The provided activation code is invalid: code ABC123 is not recognized', + ); + }); + + it('returns the suspended message', async () => { + const response = createMockResponse({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + message: 'user has been suspended', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe( + 'Access to the Developer Sandbox has been suspended due to suspicious activity or detected abuse', + ); + }); + + it('returns the denied message', async () => { + const response = createMockResponse({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + message: 'user has been denied', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe( + 'Access to the Developer Sandbox has been denied', + ); + }); + + it('returns the admin not allowed message', async () => { + const response = createMockResponse({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + message: 'failed to create usersignup for admin-user', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe( + 'A CRT admin is not allowed to sign up', + ); + }); + + it('returns the already signed up message', async () => { + const response = createMockResponse({ + ok: false, + status: 409, + json: () => + Promise.resolve({ + message: 'there is already an active UserSignup with such a username', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe( + 'An account is already signed up to Developer Sandbox with your username', + ); + }); + + it('returns the default error message for an unrecognized message', async () => { + const response = createMockResponse({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + message: 'some completely unexpected error from the backend', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError.message).toBe(defaultErrorMessage); + }); +}); diff --git a/plugins/sandbox/src/api/errors/UserSignupError.ts b/plugins/sandbox/src/api/errors/UserSignupError.ts new file mode 100644 index 0000000..4644997 --- /dev/null +++ b/plugins/sandbox/src/api/errors/UserSignupError.ts @@ -0,0 +1,66 @@ +import { CommonResponse } from '../../types'; + +/** + * Defines the error type that is useful for problems with the user's signup. + */ +export default class UserSignupError extends Error { + constructor(message: string) { + super(message); + } + + /** + * Creates the error from the given response, by attempting to read and set + * a user friendly error message. + * @param response the response to process. + * @returns a user friendly error message. + */ + static async fromResponse(response: Response): Promise { + const defaultErrorMessage: string = + 'Unable to sign you up into Developer Sandbox. Please contact devsandbox@redhat.com'; + + if (response.status === 500) { + return new UserSignupError(defaultErrorMessage); + } + + let body: CommonResponse | undefined; + try { + body = await response.json(); + } catch { + return new UserSignupError(defaultErrorMessage); + } + + if (!body || !body.message) { + return new UserSignupError(defaultErrorMessage); + } + + const { message, details } = body; + switch (true) { + case message.includes('invalid code'): { + const baseErrMessage = 'The provided activation code is invalid'; + if (details) { + return new UserSignupError(`${baseErrMessage}: ${details}`); + } else { + return new UserSignupError(baseErrMessage); + } + } + case message.includes('has been suspended'): + return new UserSignupError( + 'Access to the Developer Sandbox has been suspended due to suspicious activity or detected abuse', + ); + case message.includes('has been denied'): + return new UserSignupError( + 'Access to the Developer Sandbox has been denied', + ); + case message.includes('failed to create usersignup for'): + return new UserSignupError('A CRT admin is not allowed to sign up'); + case message.includes( + 'there is already an active UserSignup with such a username', + ): + return new UserSignupError( + 'An account is already signed up to Developer Sandbox with your username', + ); + default: + return new UserSignupError(defaultErrorMessage); + } + } +} diff --git a/plugins/sandbox/src/components/SandboxCatalog/SandboxCatalogBanner.tsx b/plugins/sandbox/src/components/SandboxCatalog/SandboxCatalogBanner.tsx index 2528631..b59e176 100644 --- a/plugins/sandbox/src/components/SandboxCatalog/SandboxCatalogBanner.tsx +++ b/plugins/sandbox/src/components/SandboxCatalog/SandboxCatalogBanner.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useEffect } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Card from '@mui/material/Card'; @@ -29,11 +29,18 @@ import { useTheme } from '@mui/material/styles'; import Image from '../../assets/images/sandbox-banner-image.svg'; import { useSandboxContext } from '../../hooks/useSandboxContext'; import { calculateDaysBetweenDates } from '../../utils/common'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; export const SandboxCatalogBanner: React.FC = () => { const theme = useTheme(); - const { userData, pendingApproval, verificationRequired, loading } = - useSandboxContext(); + const alertApi = useApi(alertApiRef); + const { + userData, + signupError, + pendingApproval, + verificationRequired, + loading, + } = useSandboxContext(); const [anchorEl, setAnchorEl] = React.useState(null); const calculateDaysLeft = React.useCallback(() => { @@ -53,6 +60,12 @@ export const SandboxCatalogBanner: React.FC = () => { setAnchorEl(null); }; + useEffect(() => { + if (signupError) { + alertApi.post({ message: signupError, severity: 'error' }); + } + }, [signupError, alertApi]); + const open = Boolean(anchorEl); return ( diff --git a/plugins/sandbox/src/components/SandboxCatalog/__tests__/SandboxCatalogBanner.test.tsx b/plugins/sandbox/src/components/SandboxCatalog/__tests__/SandboxCatalogBanner.test.tsx index 5a2cc6a..8d8e8cf 100644 --- a/plugins/sandbox/src/components/SandboxCatalog/__tests__/SandboxCatalogBanner.test.tsx +++ b/plugins/sandbox/src/components/SandboxCatalog/__tests__/SandboxCatalogBanner.test.tsx @@ -35,6 +35,8 @@ const SandboxContext = React.createContext<{ pendingApproval: false, }); +const mockAlertApi = { post: jest.fn() }; + jest.mock( '../../../assets/images/sandbox-banner-image.svg', () => 'mocked-image-path', @@ -42,6 +44,10 @@ jest.mock( jest.mock('../../../hooks/useSandboxContext', () => ({ useSandboxContext: jest.fn(), })); +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: () => mockAlertApi, +})); describe('SandboxCatalogBanner', () => { const mockTheme = createTheme(); @@ -57,6 +63,7 @@ describe('SandboxCatalogBanner', () => { contextValue: Partial<{ loading: boolean; userData?: SignupData; + signupError?: string; userFound: boolean; userReady: boolean; verificationRequired: boolean; @@ -68,6 +75,7 @@ describe('SandboxCatalogBanner', () => { mockUseSandboxContext.mockReturnValue({ loading: false, userData: undefined, + signupError: undefined, userFound: false, userReady: false, verificationRequired: false, @@ -405,4 +413,20 @@ describe('SandboxCatalogBanner', () => { jest.restoreAllMocks(); }); + + it('posts an alert when signupError is set', () => { + const errorMessage = 'Access to the Developer Sandbox has been denied'; + renderWithProviders({ signupError: errorMessage }); + + expect(mockAlertApi.post).toHaveBeenCalledWith({ + message: errorMessage, + severity: 'error', + }); + }); + + it('does not post an alert when signupError is undefined', () => { + renderWithProviders(); + + expect(mockAlertApi.post).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/sandbox/src/hooks/useSandboxContext.tsx b/plugins/sandbox/src/hooks/useSandboxContext.tsx index 7537f16..5158ae9 100644 --- a/plugins/sandbox/src/hooks/useSandboxContext.tsx +++ b/plugins/sandbox/src/hooks/useSandboxContext.tsx @@ -47,6 +47,7 @@ import { useSegmentAnalytics, SegmentTrackingData, } from '../utils/segment-analytics'; +import UserSignupError from '../api/errors/UserSignupError'; interface AAPDataResult { status: AnsibleStatus; @@ -65,6 +66,7 @@ interface SandboxContextType { verificationRequired: boolean; pendingApproval: boolean; userData: SignupData | undefined; + signupError: string | undefined; loading: boolean; refetchUserData: () => Promise; signupUser: () => void; @@ -122,6 +124,7 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({ const [statusUnknown, setStatusUnknown] = useState(true); const [userFound, setUserFound] = useState(false); const [userData, setData] = useState(undefined); + const [signupError, setSignupError] = useState(undefined); const [loading, setLoading] = useState(true); const [userReady, setUserReady] = useState(false); const [verificationRequired, setVerificationRequired] = @@ -178,6 +181,7 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({ } if (result) { setUserFound(true); + setSignupError(undefined); } else { setUserFound(false); } @@ -186,6 +190,10 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({ console.error('Error fetching user data:', err); setData(undefined); setUserFound(false); + + if (!isRefetch && err instanceof UserSignupError) { + setSignupError(err.message); + } } finally { setLoading(false); setStatusUnknown(false); @@ -670,6 +678,7 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({ verificationRequired, pendingApproval, userData, + signupError, loading, refetchUserData: fetchData, signupUser, diff --git a/plugins/sandbox/src/test-utils/mockResponse.ts b/plugins/sandbox/src/test-utils/mockResponse.ts new file mode 100644 index 0000000..82f323e --- /dev/null +++ b/plugins/sandbox/src/test-utils/mockResponse.ts @@ -0,0 +1,43 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const createMockResponse = (options: { + ok: boolean; + status?: number; + statusText?: string; + json?: () => Promise; +}): Response => { + const { ok, status = 200, statusText = '', json } = options; + return { + ok, + status, + statusText, + headers: new Headers(), + redirected: false, + type: 'basic', + url: 'http://mock', + json: json || (() => Promise.resolve({})), + text: () => Promise.resolve(''), + blob: () => Promise.resolve(new Blob()), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + formData: () => Promise.resolve(new FormData()), + bodyUsed: false, + body: null, + clone: function () { + return this; + }, + } as Response; +};