Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';

import { useCapabilities } from '../../hooks/useCapabilities';
import { useResourceSelection } from '../../hooks/useResourceSelection';
import { useUser } from '../../hooks/useUser';
import { ResourceSelector } from './ResourceSelector';

jest.mock('../../hooks/useEnvironment', () => ({
useEnvironment: () => ({ name: 'test', apiUrl: '/rest/v1' }),
}));
jest.mock('../../hooks/useCapabilities');
jest.mock('../../hooks/useUser');
jest.mock('../../hooks/useResourceSelection');

const mockCaps = useCapabilities as jest.Mock;
const mockUser = useUser as jest.Mock;
const mockSel = useResourceSelection as jest.Mock;

function standardsFetch(list: string[]): jest.Mock {
return jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve(list),
});
}

describe('ResourceSelector', () => {
beforeEach(() => {
mockCaps.mockReturnValue({
capabilities: { myopencre: true, login: true },
loading: false,
});
mockUser.mockReturnValue({
user: 'u',
isLoggedIn: true,
loading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockSel.mockReturnValue({
selected: ['ASVS'],
loading: false,
saving: false,
error: null,
loadError: false,
save: jest.fn().mockResolvedValue(['ASVS']),
});
(global as any).fetch = standardsFetch(['ASVS', 'CWE', 'SAMM']);
});

// clearAllMocks (not resetAllMocks): keep the mock implementations across the
// async boundary so a fetch that resolves after a test can't crash a leaked
// render by making a mocked hook return undefined.
afterEach(() => jest.clearAllMocks());

it('fetches the full universe of standards with ?all=true', async () => {
render(<ResourceSelector />);
await waitFor(() => expect((global as any).fetch).toHaveBeenCalled());
const url = (global as any).fetch.mock.calls[0][0] as string;
expect(url).toContain('/standards?all=true');
});

it('pre-checks the standards in the current selection', async () => {
const { container, findByText } = render(<ResourceSelector />);
await findByText('Save');
const inputs = container.querySelectorAll('input[type="checkbox"]');
expect(inputs.length).toBe(3);
expect((inputs[0] as HTMLInputElement).checked).toBe(true); // ASVS (selected)
expect((inputs[1] as HTMLInputElement).checked).toBe(false); // CWE
expect((inputs[2] as HTMLInputElement).checked).toBe(false); // SAMM
});

it('toggling a checkbox and clicking Save persists the updated list', async () => {
const saveMock = jest.fn().mockResolvedValue(['ASVS', 'CWE']);
mockSel.mockReturnValue({
selected: ['ASVS'],
loading: false,
saving: false,
error: null,
loadError: false,
save: saveMock,
});
const { container, findByText, getByText } = render(<ResourceSelector />);
await findByText('Save');
// semantic-ui-react <Checkbox> toggles when the event target is the input:
// click the CWE input directly (index 1 in the fixed standards order).
const inputs = container.querySelectorAll('input[type="checkbox"]');
fireEvent.click(inputs[1] as HTMLElement); // add CWE
fireEvent.click(getByText('Save'));
await waitFor(() => expect(saveMock).toHaveBeenCalled());
const arg = (saveMock.mock.calls[0][0] as string[]).slice().sort();
expect(arg).toEqual(['ASVS', 'CWE']);
});

it('shows a success message after a successful save', async () => {
const { findByText, getByText } = render(<ResourceSelector />);
await findByText('Save');
fireEvent.click(getByText('Save'));
expect(await findByText(/saved/i)).toBeTruthy();
});

it('shows an error message when the hook reports an error', async () => {
mockSel.mockReturnValue({
selected: ['ASVS'],
loading: false,
saving: false,
error: 'Could not save your standards. Please try again.',
loadError: false,
save: jest.fn(),
});
const { findByText } = render(<ResourceSelector />);
expect(await findByText(/could not save/i)).toBeTruthy();
});

it('blocks the picker (no Save) when the initial selection load failed', async () => {
// A failed load must not let an empty selection overwrite persisted data.
mockSel.mockReturnValue({
selected: [],
loading: false,
saving: false,
error: 'Could not load your saved standards.',
loadError: true,
save: jest.fn(),
});
const { findByText, queryByText } = render(<ResourceSelector />);
expect(await findByText(/could not load your saved standards/i)).toBeTruthy();
expect(queryByText('Save')).toBeNull();
});

it('renders a login prompt (not the checklist) when logged out', async () => {
mockUser.mockReturnValue({
user: null,
isLoggedIn: false,
loading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { findByText, queryByText } = render(<ResourceSelector />);
expect(await findByText(/log in/i)).toBeTruthy();
expect(queryByText('Save')).toBeNull();
});

it('shows an unavailable message (no picker) when logged out and login is disabled', async () => {
mockCaps.mockReturnValue({
capabilities: { myopencre: true, login: false },
loading: false,
});
mockUser.mockReturnValue({
user: null,
isLoggedIn: false,
loading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { findByText, queryByText } = render(<ResourceSelector />);
expect(await findByText(/unavailable/i)).toBeTruthy();
expect(queryByText('Save')).toBeNull();
});

it('renders nothing when the MyOpenCRE capability is off', () => {
mockCaps.mockReturnValue({
capabilities: { myopencre: false, login: true },
loading: false,
});
const { container } = render(<ResourceSelector />);
expect(container.firstChild).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react';
import { Button, Checkbox, List, Loader, Message } from 'semantic-ui-react';

import { useCapabilities } from '../../hooks/useCapabilities';
import { useEnvironment } from '../../hooks/useEnvironment';
import { useResourceSelection } from '../../hooks/useResourceSelection';
import { useUser } from '../../hooks/useUser';

// Lets a logged-in user pick which standards appear in their account view, and
// persist it (Part of #586). Reuses useUser / useCapabilities / useEnvironment
// and the useResourceSelection hook; renders with semantic-ui-react.
export const ResourceSelector = () => {
const { apiUrl } = useEnvironment();
const { capabilities } = useCapabilities();
const { isLoggedIn, loading: userLoading, login } = useUser();
const { selected, loading: selectionLoading, saving, error, loadError, save } = useResourceSelection();

const [standards, setStandards] = useState<string[]>([]);
const [standardsLoading, setStandardsLoading] = useState(true);
const [standardsError, setStandardsError] = useState<string | null>(null);
const [checked, setChecked] = useState<string[]>([]);
const [saved, setSaved] = useState(false);

// Fetch the FULL universe of standards. ?all=true bypasses the per-user
// server-side filter (PR3); without it the picker could only ever show the
// user's already-selected standards and never offer new ones to add.
useEffect(() => {
let active = true;
fetch(`${apiUrl}/standards?all=true`, { method: 'GET' })
.then((res) => {
if (res.status === 200) {
return res.json();
}
throw new Error(`Unexpected /standards status: ${res.status}`);
})
.then((data) => {
if (active && Array.isArray(data)) {
setStandards(data);
}
})
.catch((err) => {
if (active) {
setStandardsError('Could not load the list of standards.');
}
console.error('ResourceSelector: could not load standards', err);
})
.finally(() => {
if (active) {
setStandardsLoading(false);
}
});
return () => {
active = false;
};
}, [apiUrl]);

// Initialise the local checkbox state from the saved selection once it loads.
useEffect(() => {
setChecked(selected);
}, [selected]);

const toggle = (name: string) => {
setSaved(false);
setChecked((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]));
};

const onSave = async () => {
setSaved(false);
const stored = await save(checked);
if (stored) {
setSaved(true);
}
};

// Feature gate: this belongs to MyOpenCRE.
if (capabilities && !capabilities.myopencre) {
return null;
}

// Wait for capabilities and auth state to settle before deciding.
if (!capabilities || userLoading) {
return <Loader active inline="centered" content="Loading your standards…" />;
}

// Anonymous users can never edit a selection — cover EVERY logged-out case,
// not only when the login capability is available.
if (!isLoggedIn) {
if (capabilities.login) {
return (
<Message info>
<Message.Header>Log in to choose your standards</Message.Header>
<p>Sign in to pick which standards appear in your OpenCRE view.</p>
<Button primary onClick={login} content="Login" />
</Message>
);
}
return <Message info>Choosing your standards requires signing in, which is unavailable here.</Message>;
}

// Logged in: wait for the selection and the standards universe to load.
if (selectionLoading || standardsLoading) {
return <Loader active inline="centered" content="Loading your standards…" />;
}

// If the initial selection load failed we don't know the user's real
// selection — block editing/Save so an empty list can't overwrite it.
if (loadError) {
return <Message negative>Could not load your saved standards. Please refresh and try again.</Message>;
}

if (standardsError) {
return <Message negative>{standardsError}</Message>;
}

return (
<div className="resource-selector">
<p>Choose which standards appear in your view. OpenCRE is always included.</p>
{standards.length === 0 ? (
<Message info>No standards are available yet.</Message>
) : (
<List>
{standards.map((name) => (
<List.Item key={name}>
<Checkbox label={name} checked={checked.includes(name)} onChange={() => toggle(name)} />
</List.Item>
))}
</List>
)}
<Button primary loading={saving} disabled={saving} onClick={onSave} content="Save" />
{saved && !error && <Message positive>Your standards were saved.</Message>}
{error && <Message negative>{error}</Message>}
</div>
);
};
1 change: 1 addition & 0 deletions application/frontend/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { useEnvironment } from './useEnvironment';
export { useLocationFromOutsideRoute } from './useLocationFromOutsideRoute';
export { useCapabilities } from './useCapabilities';
export { useUser } from './useUser';
export { useResourceSelection } from './useResourceSelection';
Loading
Loading