diff --git a/infrastructure/topgear/README.md b/infrastructure/topgear/README.md index 0fbac4abe..e73eabc39 100644 --- a/infrastructure/topgear/README.md +++ b/infrastructure/topgear/README.md @@ -28,3 +28,9 @@ production rollout is explicitly scheduled. Rollback the function to its saved prior code and republish. Roll back the navigation and Platform UI build using the normal deployment process. There is no database change and no new DNS change needed for this dev rollout. + +The edge redirect only canonicalizes the URL. Authentication and Wipro - All +membership are checked by Platform UI before any Topgear route is mounted, +including direct visits that bypass `/challenges`. Do not infer membership from +cookie presence or an email suffix at CloudFront. Verify anonymous login handoff, +non-member denial, membership service failures, and authorized member access. diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 1ff6fdf97..b34348662 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -12,6 +12,17 @@ Community-app's Wipro community (`topgear.`, formerly also served at `wipro.`) is replaced by this app on the `topgear` host. When the first hostname label is `topgear`: +- all Topgear routes require login and verified membership in **Wipro - All**. + `TopgearAccessGate` withholds the entire route tree until the authenticated + Groups API (`/v6/groups/memberGroups/:memberId?uuid=true`) confirms the active + membership. Anonymous users go to login with their full return URL; non-members + see an access-restricted page. Errors, missing configuration, and pending checks + never render the listing or request challenge data. Direct detail routes are + gated too, and checks repeat when the member or route path changes; +- the required group defaults to `b7f7c0f8-8ee8-409e-9e5c-33404983b635` and can be + configured separately with `REACT_APP_TOPGEAR_ACCESS_GROUP_ID`. Changing the + listing's `REACT_APP_TOPGEAR_GROUP_ID` does not change who may enter Topgear. + Challenge APIs continue to enforce their own data-access permissions; - the host root and legacy `/challenges` listing redirect to `/opportunities/challenge`; detail aliases still redirect to challenge details; - Universal Navigation automatically renders the Wipro, Topcoder, and Topgear diff --git a/src/apps/platform/src/PlatformApp.tsx b/src/apps/platform/src/PlatformApp.tsx index 990284ea3..79b579c56 100644 --- a/src/apps/platform/src/PlatformApp.tsx +++ b/src/apps/platform/src/PlatformApp.tsx @@ -6,6 +6,7 @@ import { NotificationsContainer, useViewportUnitsFix } from '~/libs/shared' import { AppFooter } from './components/app-footer' import { AppHeader } from './components/app-header' +import { TopgearAccessGate } from './components/topgear-access-gate/TopgearAccessGate' import { Providers } from './providers' import { PlatformRouter } from './platform-router' @@ -18,7 +19,9 @@ const PlatformApp: FC<{}> = () => {
- + + +
(() => 'https://accounts.example.com/login') +const mountedChild = jest.fn() + +jest.mock('~/config', () => ({ + AppSubdomain: { topgear: 'topgear' }, + EnvironmentConfig: { get SUBDOMAIN(): string { return mockSubdomain } }, +}), { virtual: true }) +jest.mock('~/libs/core', () => ({ + authUrlLogin: (returnUrl: string) => mockLogin(returnUrl), + useProfileContext: () => ({ + initialized: mockInitialized, + isLoggedIn: !!mockMemberId, + profile: mockMemberId ? { email: 'employee@wipro.com', userId: mockMemberId } : undefined, + }), +}), { virtual: true }) +jest.mock('~/libs/ui', () => ({ + LoadingSpinner: (props: { message: string }) =>
{props.message}
, +}), { virtual: true }) +jest.mock('./topgear-access.service', () => ({ hasTopgearAccess: jest.fn() })) +const checkAccess = hasTopgearAccess as jest.Mock + +/** Represents a nested listing/detail route that must not mount before access is granted. */ +const PrivateRoute = (): JSX.Element => { + mountedChild() + return
Private Topgear route
+} + +/** Renders the gate in a router for the supplied direct path; children expose mounting as a spy. */ +const renderGate = (path = '/opportunities/challenge'): ReturnType => render( + + + , +) + +describe('Topgear access gate', () => { + const originalLocation = window.location + const replace = jest.fn() + + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { href: 'https://topgear.topcoder-dev.com/opportunities/challenge?search=React#results', replace }, + }) + }) + afterAll(() => Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })) + beforeEach(() => { + jest.clearAllMocks() + mockLogin.mockReturnValue('https://accounts.example.com/login') + mockSubdomain = 'topgear' + mockInitialized = true + mockMemberId = 123 + }) + + it('does not mount routes or check groups while the profile is loading', () => { + mockInitialized = false + renderGate() + expect(screen.getByRole('status')) + .toHaveTextContent('Checking Topgear access') + expect(checkAccess).not.toHaveBeenCalled() + expect(mountedChild).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() + }) + + it('sends anonymous users to login with their full return URL before loading routes', () => { + mockMemberId = undefined + renderGate('/challenges') + expect(mockLogin) + .toHaveBeenCalledWith(window.location.href) + expect(replace) + .toHaveBeenCalledWith('https://accounts.example.com/login') + expect(checkAccess).not.toHaveBeenCalled() + expect(mountedChild).not.toHaveBeenCalled() + }) + + const protectedPaths = ['/opportunities/challenge', '/opportunities/challenge/123', '/work'] + it.each(protectedPaths)('blocks non-members even with a Wipro email on %s', async path => { + checkAccess.mockResolvedValue(false) + renderGate(path) + expect(await screen.findByRole('heading', { name: 'Topgear access restricted' })) + .toBeInTheDocument() + expect(mountedChild).not.toHaveBeenCalled() + }) + + it('waits for the membership response before mounting authorized content', async () => { + let allow: (value: boolean) => void = () => undefined + checkAccess.mockReturnValue(new Promise(resolve => { allow = resolve })) + renderGate() + expect(mountedChild).not.toHaveBeenCalled() + await act(async () => allow(true)) + expect(screen.getByText('Private Topgear route')) + .toBeInTheDocument() + }) + + it('keeps routes hidden on membership errors and permits retry', async () => { + checkAccess.mockRejectedValueOnce(new Error('Unavailable')) + .mockResolvedValueOnce(true) + renderGate() + expect(await screen.findByText('Unable to verify Topgear access')) + .toBeInTheDocument() + expect(mountedChild).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(await screen.findByText('Private Topgear route')) + .toBeInTheDocument() + }) + + it('does not reuse a successful membership check after the profile changes', async () => { + checkAccess.mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + const view = renderGate() + await screen.findByText('Private Topgear route') + mockMemberId = 456 + view.rerender() + expect(screen.queryByText('Private Topgear route')).not.toBeInTheDocument() + await screen.findByText('Topgear access restricted') + expect(checkAccess) + .toHaveBeenLastCalledWith('456', expect.any(AbortSignal)) + }) + + it('cancels in-flight requests on unmount', () => { + checkAccess.mockReturnValue(new Promise(() => { /* Keep the check pending until the component unmounts. */ })) + const view = renderGate() + const signal = checkAccess.mock.calls[0][1] as AbortSignal + view.unmount() + expect(signal.aborted) + .toBe(true) + }) + + it('keeps non-Topgear hosts public without checking Wipro membership', async () => { + mockSubdomain = 'platform-ui' + mockMemberId = undefined + renderGate() + await waitFor(() => expect(screen.getByText('Private Topgear route')) + .toBeInTheDocument()) + expect(checkAccess).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() + }) +}) diff --git a/src/apps/platform/src/components/topgear-access-gate/TopgearAccessGate.tsx b/src/apps/platform/src/components/topgear-access-gate/TopgearAccessGate.tsx new file mode 100644 index 000000000..f85955620 --- /dev/null +++ b/src/apps/platform/src/components/topgear-access-gate/TopgearAccessGate.tsx @@ -0,0 +1,95 @@ +import { FC, ReactNode, useCallback, useEffect, useState } from 'react' +import { useLocation } from 'react-router-dom' + +import { AppSubdomain, EnvironmentConfig } from '~/config' +import { authUrlLogin, ProfileContextData, useProfileContext } from '~/libs/core' +import { LoadingSpinner } from '~/libs/ui' + +import { hasTopgearAccess } from './topgear-access.service' +import styles from './TopgearAccessGate.module.scss' + +interface Props { + children: ReactNode +} + +interface AccessResult { + key: string + state: 'allowed' | 'denied' | 'error' +} + +/** + * Withholds route children until login and Wipro - All membership are verified. + * Rechecks on member/path changes; cancels old requests and never reuses a result + * for another member. Anonymous visitors return to their full URL after login. + * + * @param props route tree to render only after access is granted. + * @returns a loading, restricted, retry, or authorized route view. + * @throws Does not throw; API failures leave the route tree unmounted. + */ +const WiproMemberGate: FC = props => { + const { initialized, isLoggedIn, profile }: ProfileContextData = useProfileContext() + const location = useLocation() + const memberId = initialized && isLoggedIn && profile?.userId ? String(profile.userId) : '' + const [attempt, setAttempt] = useState(0) + const [result, setResult] = useState() + const accessKey = `${memberId}:${location.pathname}:${attempt}` + const loginUrl = authUrlLogin(window.location.href) + + useEffect(() => { + if (initialized && !memberId) { + window.location.replace(loginUrl) + } + }, [initialized, memberId, loginUrl]) + + useEffect(() => { + if (!memberId) return undefined + const controller = new AbortController() + hasTopgearAccess(memberId, controller.signal) + .then(allowed => { + if (!controller.signal.aborted) { + setResult({ key: accessKey, state: allowed ? 'allowed' : 'denied' }) + } + }) + .catch(() => { + if (!controller.signal.aborted) setResult({ key: accessKey, state: 'error' }) + }) + return () => controller.abort() + }, [memberId, accessKey]) + + /** Starts a fresh membership check without displaying previously authorized content. */ + const retry = useCallback((): void => setAttempt(value => value + 1), []) + + if (!initialized || !memberId || result?.key !== accessKey) { + return ( + + ) + } + + if (result.state === 'allowed') return <>{props.children} + + return ( +
+

{result.state === 'error' ? 'Unable to verify Topgear access' : 'Topgear access restricted'}

+

+ {result.state === 'error' + ? 'We could not verify your Wipro membership. Please try again.' + : 'Topgear is available only to members of the Wipro - All group.'} +

+ {result.state === 'error' && } + Go to Topgear Home +
+ ) +} + +/** + * Applies the Wipro membership gate to every route on the Topgear host only. + * @param props nested Platform Router; other host integrations render normally. + * @returns guarded Topgear content or unchanged children. Does not throw. + */ +export const TopgearAccessGate: FC = props => ( + EnvironmentConfig.SUBDOMAIN === AppSubdomain.topgear + ? {props.children} + : <>{props.children} +) diff --git a/src/apps/platform/src/components/topgear-access-gate/topgear-access.service.spec.ts b/src/apps/platform/src/components/topgear-access-gate/topgear-access.service.spec.ts new file mode 100644 index 000000000..471900318 --- /dev/null +++ b/src/apps/platform/src/components/topgear-access-gate/topgear-access.service.spec.ts @@ -0,0 +1,54 @@ +import { xhrGetAsync } from '~/libs/core' + +import { hasTopgearAccess } from './topgear-access.service' + +let mockAccessGroupId = 'wipro-all' +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { V6: 'https://api.example.com/v6' }, + TOPGEAR: { get ACCESS_GROUP_ID(): string { return mockAccessGroupId } }, + }, +}), { virtual: true }) +jest.mock('~/libs/core', () => ({ xhrGetAsync: jest.fn() }), { virtual: true }) +const get = xhrGetAsync as jest.Mock + +describe('Topgear membership service', () => { + beforeEach(() => { + jest.clearAllMocks() + mockAccessGroupId = 'wipro-all' + }) + + it('uses authenticated member-group IDs and grants only the configured access group', async () => { + const signal = new AbortController().signal + get.mockResolvedValue(['another-group', 'wipro-all']) + await expect(hasTopgearAccess('123', signal)).resolves.toBe(true) + expect(get) + .toHaveBeenCalledWith( + 'https://api.example.com/v6/groups/memberGroups/123?uuid=true', + undefined, + { signal, timeout: 15000 }, + ) + get.mockResolvedValue(['another-group']) + await expect(hasTopgearAccess('123')).resolves.toBe(false) + get.mockResolvedValue([]) + await expect(hasTopgearAccess('123')).resolves.toBe(false) + }) + + it('fails closed for missing identity or configuration without a membership request', async () => { + await expect(hasTopgearAccess('')).resolves.toBe(false) + mockAccessGroupId = ' ' + await expect(hasTopgearAccess('123')).resolves.toBe(false) + expect(get).not.toHaveBeenCalled() + }) + + const invalidResponses = [undefined, { groups: ['wipro-all'] }, [{ id: 'wipro-all' }]] + it.each(invalidResponses)('rejects malformed group responses: %s', async response => { + get.mockResolvedValue(response) + await expect(hasTopgearAccess('123')).rejects.toThrow('Invalid group membership response') + }) + + it.each([401, 403, 500])('does not grant access on HTTP %s', async status => { + get.mockRejectedValue({ status }) + await expect(hasTopgearAccess('123')).rejects.toEqual({ status }) + }) +}) diff --git a/src/apps/platform/src/components/topgear-access-gate/topgear-access.service.ts b/src/apps/platform/src/components/topgear-access-gate/topgear-access.service.ts new file mode 100644 index 000000000..19af9b862 --- /dev/null +++ b/src/apps/platform/src/components/topgear-access-gate/topgear-access.service.ts @@ -0,0 +1,28 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +/** + * Checks Wipro - All membership using the authenticated Groups API, including + * active parent-group memberships resolved by that API. Used before mounting + * any routes on the Topgear host; neither email nor a cookie alone grants access. + * + * @param memberId ID from the signed-in profile, never from URL parameters. + * @param signal cancels an obsolete membership check when the user or route changes. + * @returns true only when the configured access group is in the API response. + * @throws Propagates HTTP, timeout, cancellation, and malformed-response errors. + */ +export async function hasTopgearAccess(memberId: string, signal?: AbortSignal): Promise { + const groupId = EnvironmentConfig.TOPGEAR.ACCESS_GROUP_ID.trim() + if (!memberId.trim() || !groupId) return false + + const groups = await xhrGetAsync( + `${EnvironmentConfig.API.V6}/groups/memberGroups/${encodeURIComponent(memberId)}?uuid=true`, + undefined, + { signal, timeout: 15000 }, + ) + if (!Array.isArray(groups) || groups.some(group => typeof group !== 'string')) { + throw new Error('Invalid group membership response') + } + + return groups.includes(groupId) +} diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 0cbd40334..36a4d1687 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -231,6 +231,7 @@ export const TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS: string[] = getReactEnv // Community-app's TopGear (Wipro) community listed the challenges of this // Topcoder group; the `topgear` host filters Opportunities the same way. export const TOPGEAR = { + ACCESS_GROUP_ID: getReactEnv('TOPGEAR_ACCESS_GROUP_ID', 'b7f7c0f8-8ee8-409e-9e5c-33404983b635'), GROUP_ID: getReactEnv('TOPGEAR_GROUP_ID', 'b7f7c0f8-8ee8-409e-9e5c-33404983b635'), } diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 4036c6826..0ce0a1388 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -69,6 +69,7 @@ export interface GlobalConfig { } TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS: string[] TOPGEAR: { + ACCESS_GROUP_ID: string GROUP_ID: string } TERMS_URL?: string