Skip to content
1 change: 1 addition & 0 deletions app-config.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ auth:
signInPage: oidc

sandbox:
environment: PROD
signupAPI: ${SIGNUP_API}
kubeAPI: ${KUBE_API}
recaptcha:
Expand Down
1 change: 1 addition & 0 deletions deploy/base/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ auth:
dangerouslyAllowSignInWithoutUserInCatalog: true
signInPage: oidc
sandbox:
environment: DEV
signupAPI: https://registration-service-toolchain-host-operator.apps.sandbox.x8i5.p1.openshiftapps.com/api/v1
kubeAPI: https://api-toolchain-host-operator.apps.sandbox.x8i5.p1.openshiftapps.com
recaptcha:
Expand Down
7 changes: 7 additions & 0 deletions plugins/sandbox/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,12 @@ export interface Config {
*/
siteKey: string;
};
/**
* Controls the runtime environment (DEV or PROD).
* When set to DEV, analytics scripts (trustarc, dpal) are not loaded.
* Defaults to PROD if not specified.
* @visibility frontend
*/
environment?: string;
};
}
22 changes: 14 additions & 8 deletions plugins/sandbox/src/api/RegistrationBackendClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ConfigApi } from '@backstage/core-plugin-api';
import { isValidCountryCode, isValidPhoneNumber } from '../utils/phone-utils';
import { CommonResponse, SignupData } from '../types';
import { SecureFetchApi } from './SecureFetchClient';
import { SandboxEnvironment } from '../const';

export type RegistrationBackendClientOptions = {
configApi: ConfigApi;
Expand Down Expand Up @@ -111,18 +112,23 @@ export class RegistrationBackendClient implements RegistrationService {
};

signup = async (): Promise<void> => {
let token = '';
try {
token = await this.getRecaptchaToken();
} catch (err) {
throw new Error(`Error getting recaptcha token: ${err}`);
const isDev =
(this.configApi.getOptionalString('sandbox.environment') ??
SandboxEnvironment.PROD) === SandboxEnvironment.DEV;
const headers: Record<string, string> = {};

if (!isDev) {
try {
headers['Recaptcha-Token'] = await this.getRecaptchaToken();
} catch (err) {
throw new Error(`Error getting recaptcha token: ${err}`);
}
}

const signupURL = await this.signupAPI();
await this.secureFetchApi.fetch(signupURL, {
method: 'POST',
headers: {
'Recaptcha-Token': token,
},
headers,
body: null,
});
};
Expand Down
41 changes: 23 additions & 18 deletions plugins/sandbox/src/components/SandboxHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,40 @@ import { useTheme } from '@mui/material/styles';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
import { Header, Link } from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { useTrackAnalytics } from '../utils/eddl-utils';
import { SandboxEnvironment } from '../const';

interface SandboxHeaderProps {
pageTitle: string;
}

export const SandboxHeader: React.FC<SandboxHeaderProps> = ({ pageTitle }) => {
const trackAnalytics = useTrackAnalytics();
const configApi = useApi(configApiRef);
const environment =
configApi.getOptionalString('sandbox.environment') ??
SandboxEnvironment.PROD;

useEffect(() => {
const initializeAnalytics = async () => {
// Check if script is already loaded
if (!document.getElementById('trustarc')) {
const script = document.createElement('script');
script.id = 'trustarc';
script.src =
'//static.redhat.com/libs/redhat/marketing/latest/trustarc/trustarc.js';
document.body.appendChild(script);
}
if (!document.getElementById('dpal')) {
const script = document.createElement('script');
script.id = 'dpal';
script.src = 'https://www.redhat.com/ma/dpal.js';
document.body.appendChild(script);
}
};
if (environment === SandboxEnvironment.DEV) {
return;
}

initializeAnalytics();
}, []);
if (!document.getElementById('trustarc')) {
const script = document.createElement('script');
script.id = 'trustarc';
script.src =
'//static.redhat.com/libs/redhat/marketing/latest/trustarc/trustarc.js';
document.body.appendChild(script);
}
if (!document.getElementById('dpal')) {
const script = document.createElement('script');
script.id = 'dpal';
script.src = 'https://www.redhat.com/ma/dpal.js';
document.body.appendChild(script);
}
}, [environment]);

const theme = useTheme();

Expand Down
68 changes: 63 additions & 5 deletions plugins/sandbox/src/components/__tests__/SandboxHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { SandboxHeader } from '../SandboxHeader';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { wrapInTestApp } from '@backstage/test-utils';
import {
MockConfigApi,
TestApiProvider,
wrapInTestApp,
} from '@backstage/test-utils';
import { configApiRef } from '@backstage/core-plugin-api';
import { SandboxEnvironment } from '../../const';
import * as eddlUtils from '../../utils/eddl-utils';

// Mock the useTrackAnalytics hook
Expand All @@ -26,24 +32,50 @@ jest.mock('../../utils/eddl-utils', () => ({
useTrackAnalytics: jest.fn(),
}));

const removeAnalyticsScripts = () => {
document.getElementById('trustarc')?.remove();
document.getElementById('dpal')?.remove();
};

describe('SandboxHeader', () => {
const mockTrackAnalytics = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
removeAnalyticsScripts();
// Mock the useTrackAnalytics hook to return a mock function
(eddlUtils.useTrackAnalytics as jest.Mock).mockReturnValue(
mockTrackAnalytics,
);
});

const renderComponent = (pageTitle = 'My Page Title') => {
afterEach(() => {
removeAnalyticsScripts();
});

const renderComponent = (
pageTitle = 'My Page Title',
environment?: string,
) => {
const theme = createTheme();
const content = (
<ThemeProvider theme={theme}>
<SandboxHeader pageTitle={pageTitle} />
</ThemeProvider>
);
return render(
wrapInTestApp(
<ThemeProvider theme={theme}>
<SandboxHeader pageTitle={pageTitle} />
</ThemeProvider>,
environment ? (
<TestApiProvider
apis={[
[configApiRef, new MockConfigApi({ sandbox: { environment } })],
]}
>
{content}
</TestApiProvider>
) : (
content
),
),
);
};
Expand Down Expand Up @@ -118,4 +150,30 @@ describe('SandboxHeader', () => {

windowOpenSpy.mockRestore();
});

describe('analytics scripts loading', () => {
test('does not load trustarc and dpal scripts when environment is DEV', () => {
renderComponent('My Page Title', SandboxEnvironment.DEV);
expect(document.getElementById('trustarc')).toBeNull();
expect(document.getElementById('dpal')).toBeNull();
});

test('loads trustarc and dpal scripts when environment is PROD', () => {
renderComponent('My Page Title', SandboxEnvironment.PROD);
const trustarcScript = document.getElementById(
'trustarc',
) as HTMLScriptElement;
const dpalScript = document.getElementById('dpal') as HTMLScriptElement;
expect(trustarcScript).not.toBeNull();
expect(trustarcScript.src).toContain('trustarc.js');
expect(dpalScript).not.toBeNull();
expect(dpalScript.src).toContain('dpal.js');
});

test('loads analytics scripts by default when environment is not set', () => {
renderComponent();
expect(document.getElementById('trustarc')).not.toBeNull();
expect(document.getElementById('dpal')).not.toBeNull();
});
});
});
5 changes: 5 additions & 0 deletions plugins/sandbox/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@
*/
export const SHORT_INTERVAL = 2000;
export const LONG_INTERVAL = 20000;

export enum SandboxEnvironment {
DEV = 'DEV',
PROD = 'PROD',
}
8 changes: 5 additions & 3 deletions plugins/sandbox/src/hooks/useRecaptcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import { useApi } from '@backstage/core-plugin-api';
import { loadRecaptchaScript } from '../utils/recaptcha';
import { registerApiRef } from '../api';

export const useRecaptcha = () => {
export const useRecaptcha = (enabled = true) => {
const registerApi = useApi(registerApiRef);
React.useEffect(() => {
loadRecaptchaScript(registerApi.getRecaptchaAPIKey());
if (enabled) {
loadRecaptchaScript(registerApi.getRecaptchaAPIKey());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [enabled]);
};
15 changes: 11 additions & 4 deletions plugins/sandbox/src/hooks/useSandboxContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
import { isEqual } from 'lodash';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { AAPData, SignupData } from '../types';
import { useApi } from '@backstage/core-plugin-api';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { aapApiRef, kubeApiRef, registerApiRef } from '../api';
import { useRecaptcha } from './useRecaptcha';
import { LONG_INTERVAL, SHORT_INTERVAL } from '../const';
import { LONG_INTERVAL, SandboxEnvironment, SHORT_INTERVAL } from '../const';
import { signupDataToStatus } from '../utils/register-utils';
import { AnsibleStatus, decode, getReadyCondition } from '../utils/aap-utils';
import { errorMessage } from '../utils/common';
Expand Down Expand Up @@ -63,7 +63,11 @@ export const useSandboxContext = (): SandboxContextType => {
export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
useRecaptcha();
const configApi = useApi(configApiRef);
const isProd =
(configApi.getOptionalString('sandbox.environment') ??
SandboxEnvironment.PROD) !== SandboxEnvironment.DEV;
useRecaptcha(isProd);
Comment on lines +66 to +70
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail-open environment parsing can silently re-enable tracking.

Line 67 currently treats anything except exact 'DEV' as production. Values like dev, Dev, or ' DEV ' will enable reCAPTCHA/Segment unexpectedly.

🔧 Proposed fix
-  const isProd =
-    (configApi.getOptionalString('sandbox.environment') ?? 'PROD') !== 'DEV';
+  const environment =
+    (configApi.getOptionalString('sandbox.environment') ?? 'PROD')
+      .trim()
+      .toUpperCase();
+  const isProd = environment === 'PROD';

Please apply the same normalization pattern in plugins/sandbox/src/components/SandboxHeader.tsx (Lines 34-39) to keep behavior consistent.

As per coding guidelines, "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const configApi = useApi(configApiRef);
const isProd =
(configApi.getOptionalString('sandbox.environment') ?? 'PROD') !== 'DEV';
useRecaptcha(isProd);
const configApi = useApi(configApiRef);
const environment =
(configApi.getOptionalString('sandbox.environment') ?? 'PROD')
.trim()
.toUpperCase();
const isProd = environment === 'PROD';
useRecaptcha(isProd);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/sandbox/src/hooks/useSandboxContext.tsx` around lines 66 - 69,
Normalize the environment string before comparing so non-exact variants like
"dev", " Dev ", or "dev\n" don't count as production: change the isProd
computation in useSandboxContext (the configApi/getOptionalString usage that
sets isProd and calls useRecaptcha) to obtain the optional string, apply .trim()
and .toUpperCase() and then compare to 'DEV' (i.e., isProd =
(env?.trim().toUpperCase() ?? 'PROD') !== 'DEV'); apply the exact same
normalization pattern in the SandboxHeader component where it parses
sandbox.environment (replace the current loose comparison with the same
trim+toUpperCase flow) so both places behave consistently.

const aapApi = useApi(aapApiRef);
const kubeApi = useApi(kubeApiRef);
const registerApi = useApi(registerApiRef);
Expand Down Expand Up @@ -210,6 +214,9 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({

// Initialize Segment Analytics
useEffect(() => {
if (!isProd) {
return;
}
const fetchSegmentWriteKey = async () => {
try {
const writeKey = await registerApi.getSegmentWriteKey();
Expand All @@ -219,7 +226,7 @@ export const SandboxProvider: React.FC<{ children: React.ReactNode }> = ({
}
};
fetchSegmentWriteKey();
}, [registerApi]);
}, [registerApi, isProd]);

// Fetch Marketo webhook URL from UI config
useEffect(() => {
Expand Down
Loading