Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions infrastructure/topgear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ Community-app's Wipro community (`topgear.<domain>`, formerly also served at
`wipro.<domain>`) 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
Expand Down
5 changes: 4 additions & 1 deletion src/apps/platform/src/PlatformApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -18,7 +19,9 @@ const PlatformApp: FC<{}> = () => {
<AppHeader />
<NotificationsContainer />
<div className='root-container'>
<PlatformRouter />
<TopgearAccessGate>
<PlatformRouter />
</TopgearAccessGate>
</div>
<ToastContainer
position={toast.POSITION.TOP_RIGHT}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.restricted {
margin: 0 auto;
max-width: 900px;
min-height: 60vh;
padding: 72px 24px;

p { margin: 24px 0; }
a { display: inline-block; text-decoration: underline; }
button { cursor: pointer; margin-right: 24px; padding: 8px 16px; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */
import '@testing-library/jest-dom'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'

import { TopgearAccessGate } from './TopgearAccessGate'
import { hasTopgearAccess } from './topgear-access.service'

let mockSubdomain = 'topgear'
let mockInitialized = true
let mockMemberId: number | undefined = 123
const mockLogin = jest.fn<string, [string]>(() => '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 }) => <div role='status'>{props.message}</div>,
}), { 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 <div>Private Topgear route</div>
}

/** Renders the gate in a router for the supplied direct path; children expose mounting as a spy. */
const renderGate = (path = '/opportunities/challenge'): ReturnType<typeof render> => render(
<MemoryRouter initialEntries={[path]}>
<TopgearAccessGate><PrivateRoute /></TopgearAccessGate>
</MemoryRouter>,
)

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<boolean>(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(<MemoryRouter><TopgearAccessGate><PrivateRoute /></TopgearAccessGate></MemoryRouter>)
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()
})
})
Original file line number Diff line number Diff line change
@@ -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> = 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<AccessResult>()
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 (
<LoadingSpinner
message={initialized && !memberId ? 'Redirecting to login…' : 'Checking Topgear access…'}
/>
)
}

if (result.state === 'allowed') return <>{props.children}</>

return (
<main className={styles.restricted}>
<h1>{result.state === 'error' ? 'Unable to verify Topgear access' : 'Topgear access restricted'}</h1>
<p>
{result.state === 'error'
? 'We could not verify your Wipro membership. Please try again.'
: 'Topgear is available only to members of the Wipro - All group.'}
</p>
{result.state === 'error' && <button onClick={retry} type='button'>Try again</button>}
<a href='https://topgear-app.wipro.com'>Go to Topgear Home</a>
</main>
)
}

/**
* 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> = props => (
EnvironmentConfig.SUBDOMAIN === AppSubdomain.topgear
? <WiproMemberGate>{props.children}</WiproMemberGate>
: <>{props.children}</>
)
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const groupId = EnvironmentConfig.TOPGEAR.ACCESS_GROUP_ID.trim()
if (!memberId.trim() || !groupId) return false

const groups = await xhrGetAsync<unknown>(
`${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)
}
1 change: 1 addition & 0 deletions src/config/environments/default.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export const TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS: string[] = getReactEnv<string>
// 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<string>('TOPGEAR_ACCESS_GROUP_ID', 'b7f7c0f8-8ee8-409e-9e5c-33404983b635'),
GROUP_ID: getReactEnv<string>('TOPGEAR_GROUP_ID', 'b7f7c0f8-8ee8-409e-9e5c-33404983b635'),
}

Expand Down
Loading
Loading