From 31f2f209bc693a65d077206f1dbec9ba7b6c8d07 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Tue, 23 Jun 2026 17:55:37 -0400 Subject: [PATCH 1/8] Show alerts for user signup errors Before these changes users were not given any feedback if anything went wrong with the user signup. For example, if the user happened to be banned, or there was some permission error, the Developer Sandbox UI would not properly work but also would not give the users any clues of why. These changes use the existing "alert" system from the backstage to show informative and user friendly messages when something goes wrong. Assisted-by: Cursor with Opus 4.6 Jira-ticket: SANDBOX-1893 --- .../src/api/RegistrationBackendClient.tsx | 13 +- .../RegistrationBackendClient.test.tsx | 60 +++---- .../src/api/__tests__/UserSignupError.test.ts | 165 ++++++++++++++++++ .../sandbox/src/api/errors/UserSignupError.ts | 61 +++++++ .../SandboxCatalog/SandboxCatalogBanner.tsx | 19 +- .../__tests__/SandboxCatalogBanner.test.tsx | 24 +++ .../sandbox/src/hooks/useSandboxContext.tsx | 8 + .../sandbox/src/test-utils/mockResponse.ts | 43 +++++ 8 files changed, 348 insertions(+), 45 deletions(-) create mode 100644 plugins/sandbox/src/api/__tests__/UserSignupError.test.ts create mode 100644 plugins/sandbox/src/api/errors/UserSignupError.ts create mode 100644 plugins/sandbox/src/test-utils/mockResponse.ts diff --git a/plugins/sandbox/src/api/RegistrationBackendClient.tsx b/plugins/sandbox/src/api/RegistrationBackendClient.tsx index df0c270..e0d0db5 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; @@ -72,15 +73,11 @@ export class RegistrationBackendClient implements RegistrationService { 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(); + + 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..2a705bf 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 throw a UserSignupError on a 404 response', async () => { mockConfigApi.getString.mockReturnValue('http://api'); mockSecureFetchApi.fetch.mockResolvedValue( createMockResponse({ @@ -112,11 +85,10 @@ describe('RegistrationBackendClient', () => { }), ); - const result = await client.getSignUpData(); - expect(result).toBeUndefined(); + await expect(client.getSignUpData()).rejects.toThrow(UserSignupError); }); - 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 +98,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..290316b --- /dev/null +++ b/plugins/sandbox/src/api/errors/UserSignupError.ts @@ -0,0 +1,61 @@ +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(readonly 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); + } + + switch (true) { + case body?.message.includes('invalid code'): + return new UserSignupError( + `The provided activation code is invalid: ${body?.details}`, + ); + case body?.message.includes('has been suspended'): + return new UserSignupError( + 'Access to the Developer Sandbox has been suspended due to suspicious activity or detected abuse', + ); + case body?.message.includes('has been denied'): + return new UserSignupError( + 'Access to the Developer Sandbox has been denied', + ); + case body?.message.includes('failed to create usersignup for'): + return new UserSignupError('A CRT admin is not allowed to sign up'); + case body?.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..315cf71 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] = @@ -186,6 +189,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 +677,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; +}; From 98411f08c373104ed4e74f56c1d09ef4291befd5 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Tue, 23 Jun 2026 18:13:22 -0400 Subject: [PATCH 2/8] Clear the signup errors upon successful fetches Jira-ticket: SANDBOX-1893 --- plugins/sandbox/src/hooks/useSandboxContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/sandbox/src/hooks/useSandboxContext.tsx b/plugins/sandbox/src/hooks/useSandboxContext.tsx index 315cf71..5158ae9 100644 --- a/plugins/sandbox/src/hooks/useSandboxContext.tsx +++ b/plugins/sandbox/src/hooks/useSandboxContext.tsx @@ -181,6 +181,7 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({ } if (result) { setUserFound(true); + setSignupError(undefined); } else { setUserFound(false); } From 1ccf15fc35b378ed56f4c23766e7b293f98a357a Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Tue, 23 Jun 2026 18:21:52 -0400 Subject: [PATCH 3/8] Guard against undefined "details" key in user signup error response Jira-ticket: SANDBOX-1893 --- .../sandbox/src/api/errors/UserSignupError.ts | 9 +- .../errors/__tests__/UserSignupError.test.ts | 183 ++++++++++++++++++ 2 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts diff --git a/plugins/sandbox/src/api/errors/UserSignupError.ts b/plugins/sandbox/src/api/errors/UserSignupError.ts index 290316b..5068c98 100644 --- a/plugins/sandbox/src/api/errors/UserSignupError.ts +++ b/plugins/sandbox/src/api/errors/UserSignupError.ts @@ -35,9 +35,12 @@ export default class UserSignupError extends Error { switch (true) { case body?.message.includes('invalid code'): - return new UserSignupError( - `The provided activation code is invalid: ${body?.details}`, - ); + const baseErrMessage = 'The provided activation code is invalid'; + if (body.details) { + return new UserSignupError(`${baseErrMessage}: ${body.details}`); + } else { + return new UserSignupError(baseErrMessage); + } case body?.message.includes('has been suspended'): return new UserSignupError( 'Access to the Developer Sandbox has been suspended due to suspicious activity or detected abuse', diff --git a/plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts b/plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts new file mode 100644 index 0000000..5a96dca --- /dev/null +++ b/plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts @@ -0,0 +1,183 @@ +import { createMockResponse } from '../../../test-utils/mockResponse'; +import UserSignupError from '../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 invalid code message without details when details are absent', async () => { + const response = createMockResponse({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + message: 'invalid code provided', + }), + }); + + const userSignupError = await UserSignupError.fromResponse(response); + + expect(userSignupError).toBeInstanceOf(UserSignupError); + expect(userSignupError.message).toBe( + 'The provided activation code is invalid', + ); + }); + + 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); + }); +}); From 15ad2dda65390b7f5da592f2c6e37b3afe3b7074 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Tue, 23 Jun 2026 18:28:03 -0400 Subject: [PATCH 4/8] Destructure the known fields for code clarity Assisted-by: Cursor with Opus 4.6 Jira-ticket: SANDBOX-1893 --- plugins/sandbox/src/api/errors/UserSignupError.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/sandbox/src/api/errors/UserSignupError.ts b/plugins/sandbox/src/api/errors/UserSignupError.ts index 5068c98..2db21a9 100644 --- a/plugins/sandbox/src/api/errors/UserSignupError.ts +++ b/plugins/sandbox/src/api/errors/UserSignupError.ts @@ -33,25 +33,26 @@ export default class UserSignupError extends Error { return new UserSignupError(defaultErrorMessage); } + const { message, details } = body; switch (true) { - case body?.message.includes('invalid code'): + case message.includes('invalid code'): const baseErrMessage = 'The provided activation code is invalid'; - if (body.details) { - return new UserSignupError(`${baseErrMessage}: ${body.details}`); + if (details) { + return new UserSignupError(`${baseErrMessage}: ${details}`); } else { return new UserSignupError(baseErrMessage); } - case body?.message.includes('has been suspended'): + 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 body?.message.includes('has been denied'): + case message.includes('has been denied'): return new UserSignupError( 'Access to the Developer Sandbox has been denied', ); - case body?.message.includes('failed to create usersignup for'): + case message.includes('failed to create usersignup for'): return new UserSignupError('A CRT admin is not allowed to sign up'); - case body?.message.includes( + case message.includes( 'there is already an active UserSignup with such a username', ): return new UserSignupError( From 561b22bff8ea62cfcc516db764dadb21419e7295 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Tue, 23 Jun 2026 18:30:53 -0400 Subject: [PATCH 5/8] Confine constant in case Technically the declared and initialized variables in a switch statement can be seen by the other branches, so in order to be safe we are enclosing the constant declaration in its own scope. Assisted-by: Cursor with Opus 4.6 Jira-ticket: SANDBOX-1893 --- plugins/sandbox/src/api/errors/UserSignupError.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/sandbox/src/api/errors/UserSignupError.ts b/plugins/sandbox/src/api/errors/UserSignupError.ts index 2db21a9..b4f3d8e 100644 --- a/plugins/sandbox/src/api/errors/UserSignupError.ts +++ b/plugins/sandbox/src/api/errors/UserSignupError.ts @@ -35,13 +35,14 @@ export default class UserSignupError extends Error { const { message, details } = body; switch (true) { - case message.includes('invalid code'): + 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', From 64600783223e22ee9b6abffc5fe90560fcd2ecd9 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Tue, 23 Jun 2026 18:32:28 -0400 Subject: [PATCH 6/8] Remove "readonly" keyword to avoid shadowing the super constructor Assisted-by: Cursor with Opus 4.6 Jira-ticket: SANDBOX-1893 --- plugins/sandbox/src/api/errors/UserSignupError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/sandbox/src/api/errors/UserSignupError.ts b/plugins/sandbox/src/api/errors/UserSignupError.ts index b4f3d8e..4644997 100644 --- a/plugins/sandbox/src/api/errors/UserSignupError.ts +++ b/plugins/sandbox/src/api/errors/UserSignupError.ts @@ -4,7 +4,7 @@ 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(readonly message: string) { + constructor(message: string) { super(message); } From c1c9fef13228faa96aad6d16f8a3c137fb766ea7 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Wed, 24 Jun 2026 09:02:28 -0400 Subject: [PATCH 7/8] Remove duplicated tests Jira-ticket: SANDBOX-1893 --- .../errors/__tests__/UserSignupError.test.ts | 183 ------------------ 1 file changed, 183 deletions(-) delete mode 100644 plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts diff --git a/plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts b/plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts deleted file mode 100644 index 5a96dca..0000000 --- a/plugins/sandbox/src/api/errors/__tests__/UserSignupError.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { createMockResponse } from '../../../test-utils/mockResponse'; -import UserSignupError from '../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 invalid code message without details when details are absent', async () => { - const response = createMockResponse({ - ok: false, - status: 403, - json: () => - Promise.resolve({ - message: 'invalid code provided', - }), - }); - - const userSignupError = await UserSignupError.fromResponse(response); - - expect(userSignupError).toBeInstanceOf(UserSignupError); - expect(userSignupError.message).toBe( - 'The provided activation code is invalid', - ); - }); - - 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); - }); -}); From 6468371c865c24cf38f7f718db06c63fc33e9098 Mon Sep 17 00:00:00 2001 From: MikelAlejoBR Date: Wed, 24 Jun 2026 09:15:52 -0400 Subject: [PATCH 8/8] Restore "not found" behavior for user signups The "404" errors when checking for user signups are a valid state that trigger the creation a new user signup, and therefore the behavior needed to be kept. I removed the code by mistake when refactoring it. Assisted-by: Cursor with Opus 4.6 Jira-ticket: SANDBOX-1893 --- .../sandbox/src/api/RegistrationBackendClient.tsx | 12 ++++++++++++ .../api/__tests__/RegistrationBackendClient.test.tsx | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/sandbox/src/api/RegistrationBackendClient.tsx b/plugins/sandbox/src/api/RegistrationBackendClient.tsx index e0d0db5..d427edd 100644 --- a/plugins/sandbox/src/api/RegistrationBackendClient.tsx +++ b/plugins/sandbox/src/api/RegistrationBackendClient.tsx @@ -68,6 +68,11 @@ 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, { @@ -77,6 +82,13 @@ export class RegistrationBackendClient implements RegistrationService { 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); }; diff --git a/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx b/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx index 2a705bf..f0cdac5 100644 --- a/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx +++ b/plugins/sandbox/src/api/__tests__/RegistrationBackendClient.test.tsx @@ -76,7 +76,7 @@ describe('RegistrationBackendClient', () => { expect(result).toEqual(mockData); }); - it('should throw a UserSignupError on a 404 response', async () => { + it('should return undefined on a 404 response', async () => { mockConfigApi.getString.mockReturnValue('http://api'); mockSecureFetchApi.fetch.mockResolvedValue( createMockResponse({ @@ -85,7 +85,8 @@ describe('RegistrationBackendClient', () => { }), ); - await expect(client.getSignUpData()).rejects.toThrow(UserSignupError); + const result = await client.getSignUpData(); + expect(result).toBeUndefined(); }); it('should throw a UserSignupError on a 500 response', async () => {