Add user profile panel with self-service password change and dark theme UserMenu
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Property-Based Test: Mismatched password confirmation is rejected client-side
|
||||
*
|
||||
* Feature: user-profile, Property 5: Mismatched password confirmation is rejected client-side
|
||||
* **Validates: Requirements 2.4**
|
||||
*
|
||||
* For any two distinct strings used as newPassword and confirmPassword in the
|
||||
* Password_Change_Form, the form displays a validation error and does not
|
||||
* submit a request to the Auth_API.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import UserProfilePanel from '../components/UserProfilePanel';
|
||||
|
||||
// Mock profile returned by the API so the form renders
|
||||
const MOCK_PROFILE = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
group: 'Standard_User',
|
||||
created_at: '2026-01-15T10:30:00Z',
|
||||
last_login: '2026-07-20T14:22:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Property 5: Mismatched password confirmation is rejected client-side', async () => {
|
||||
// Arbitrary: generate a newPassword of at least 8 characters and a distinct confirmPassword
|
||||
// (also non-empty so the validation message renders).
|
||||
const mismatchedPairArbitrary = fc
|
||||
.tuple(
|
||||
fc.string({ minLength: 8, maxLength: 40 }).filter(s => s.trim().length >= 8),
|
||||
fc.string({ minLength: 1, maxLength: 40 })
|
||||
)
|
||||
.filter(([newPw, confirmPw]) => newPw !== confirmPw);
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(mismatchedPairArbitrary, async ([newPassword, confirmPassword]) => {
|
||||
// Mock fetch: first call returns the profile, subsequent calls are tracked
|
||||
const fetchMock = jest.fn().mockImplementation((url) => {
|
||||
if (typeof url === 'string' && url.includes('/auth/profile')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ...MOCK_PROFILE }),
|
||||
});
|
||||
}
|
||||
// Any other call (e.g. change-password) — should NOT happen
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ message: 'Password changed successfully' }),
|
||||
});
|
||||
});
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const onClose = jest.fn();
|
||||
const { container, unmount, getByPlaceholderText } = render(
|
||||
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||
);
|
||||
|
||||
try {
|
||||
// Wait for the profile to load
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain(MOCK_PROFILE.username);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Fill in the form fields
|
||||
const currentPwInput = getByPlaceholderText('Enter current password');
|
||||
const newPwInput = getByPlaceholderText('Minimum 8 characters');
|
||||
const confirmPwInput = getByPlaceholderText('Re-enter new password');
|
||||
|
||||
fireEvent.change(currentPwInput, { target: { value: 'currentpass1' } });
|
||||
fireEvent.change(newPwInput, { target: { value: newPassword } });
|
||||
fireEvent.change(confirmPwInput, { target: { value: confirmPassword } });
|
||||
|
||||
// Assert the "Passwords do not match" validation error is displayed
|
||||
expect(container.textContent).toContain('Passwords do not match');
|
||||
|
||||
// Assert the submit button is disabled
|
||||
const submitButton = container.querySelector('button[type="submit"]');
|
||||
expect(submitButton).not.toBeNull();
|
||||
expect(submitButton.disabled).toBe(true);
|
||||
|
||||
// Assert that no call was made to the change-password endpoint
|
||||
const changePasswordCalls = fetchMock.mock.calls.filter(
|
||||
([url]) => typeof url === 'string' && url.includes('/auth/change-password')
|
||||
);
|
||||
expect(changePasswordCalls).toHaveLength(0);
|
||||
} finally {
|
||||
unmount();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 120000);
|
||||
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Property-Based Test: Profile panel displays all required fields
|
||||
*
|
||||
* Feature: user-profile, Property 1: Profile panel displays all required fields
|
||||
* **Validates: Requirements 1.2**
|
||||
*
|
||||
* For any valid profile object with arbitrary username, email, group, created_at,
|
||||
* and last_login values, rendering UserProfilePanel displays all five values
|
||||
* in the output.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import UserProfilePanel from '../components/UserProfilePanel';
|
||||
|
||||
// Replicate the component's formatting logic so we know what to expect in the DOM
|
||||
function formatGroupName(group) {
|
||||
if (!group) return '';
|
||||
return group.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'Unknown';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}) + ' at ' + date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ISO date strings from integer timestamps to avoid invalid Date issues
|
||||
const MIN_TS = new Date('2020-01-01T00:00:00Z').getTime();
|
||||
const MAX_TS = new Date('2030-12-31T23:59:59Z').getTime();
|
||||
const isoDateArbitrary = fc
|
||||
.integer({ min: MIN_TS, max: MAX_TS })
|
||||
.map(ts => new Date(ts).toISOString());
|
||||
|
||||
// Arbitrary that generates valid profile objects.
|
||||
// Use minLength >= 3 for username to avoid single-character strings that
|
||||
// match substrings in other UI text (e.g., "d" appearing in "Password").
|
||||
// Use a custom email generator with a longer local part for the same reason.
|
||||
const profileArbitrary = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
username: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,19}$/),
|
||||
email: fc.tuple(
|
||||
fc.stringMatching(/^[a-z]{4,10}$/),
|
||||
fc.stringMatching(/^[a-z]{3,8}$/),
|
||||
fc.constantFrom('com', 'org', 'net', 'io')
|
||||
).map(([local, domain, tld]) => `${local}@${domain}.${tld}`),
|
||||
group: fc.constantFrom('Admin', 'Standard_User', 'Leadership', 'Read_Only'),
|
||||
created_at: isoDateArbitrary,
|
||||
last_login: isoDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: find all fieldValue spans in the rendered component.
|
||||
* The component renders each profile field in a fieldRow div containing
|
||||
* a fieldLabel span and a fieldValue span. We query by the known label
|
||||
* text to locate the corresponding value span.
|
||||
*/
|
||||
function getFieldValueByLabel(container, labelText) {
|
||||
// Each field row has structure:
|
||||
// <div style={fieldRow}>
|
||||
// <svg ... />
|
||||
// <div style={fieldContent}>
|
||||
// <span style={fieldLabel}>LABEL</span>
|
||||
// <span style={fieldValue}>VALUE</span>
|
||||
// </div>
|
||||
// </div>
|
||||
const labels = container.querySelectorAll('span');
|
||||
for (const label of labels) {
|
||||
if (label.textContent.trim().toLowerCase() === labelText.toLowerCase()) {
|
||||
// The value span is the next sibling of the label span
|
||||
const valueSibling = label.nextElementSibling;
|
||||
if (valueSibling) {
|
||||
return valueSibling.textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Property 1: Profile panel displays all required fields for any valid profile', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(profileArbitrary, async (profile) => {
|
||||
// Mock fetch to return the generated profile
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ...profile }),
|
||||
});
|
||||
|
||||
const onClose = jest.fn();
|
||||
const { container, unmount } = render(
|
||||
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||
);
|
||||
|
||||
try {
|
||||
// Wait for the profile data to be fetched and rendered.
|
||||
// Check for the username label to confirm the profile section loaded.
|
||||
await waitFor(() => {
|
||||
expect(getFieldValueByLabel(container, 'Username')).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Assert all five field values appear in their respective field rows
|
||||
|
||||
// 1. Username — rendered directly in the Username field value span
|
||||
const usernameValue = getFieldValueByLabel(container, 'Username');
|
||||
expect(usernameValue).toBe(profile.username);
|
||||
|
||||
// 2. Email — rendered directly in the Email field value span
|
||||
const emailValue = getFieldValueByLabel(container, 'Email');
|
||||
expect(emailValue).toBe(profile.email);
|
||||
|
||||
// 3. Group — rendered through formatGroupName in the Group field value span
|
||||
const groupValue = getFieldValueByLabel(container, 'Group');
|
||||
const expectedGroup = formatGroupName(profile.group);
|
||||
expect(groupValue).toContain(expectedGroup);
|
||||
|
||||
// 4. Created at — rendered through formatDate in the Account Created field value span
|
||||
const createdAtValue = getFieldValueByLabel(container, 'Account Created');
|
||||
const expectedCreatedAt = formatDate(profile.created_at);
|
||||
expect(createdAtValue).toBe(expectedCreatedAt);
|
||||
|
||||
// 5. Last login — rendered through formatDate in the Last Login field value span
|
||||
const lastLoginValue = getFieldValueByLabel(container, 'Last Login');
|
||||
const expectedLastLogin = formatDate(profile.last_login);
|
||||
expect(lastLoginValue).toBe(expectedLastLogin);
|
||||
} finally {
|
||||
// Clean up to avoid leaking state between iterations
|
||||
unmount();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 120000);
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Property-Based Test: Short passwords are rejected client-side
|
||||
*
|
||||
* Feature: user-profile, Property 6 (client-side): Short passwords are rejected
|
||||
* **Validates: Requirements 2.5**
|
||||
*
|
||||
* For any string of length 1–7 (non-empty so the validation message renders —
|
||||
* the component checks `newPassword.length > 0 && newPassword.length < 8`),
|
||||
* the form displays a minimum-length validation error and does not submit a
|
||||
* request to the Auth_API.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import UserProfilePanel from '../components/UserProfilePanel';
|
||||
|
||||
// Mock profile returned by the API so the form renders
|
||||
const MOCK_PROFILE = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
group: 'Standard_User',
|
||||
created_at: '2026-01-15T10:30:00Z',
|
||||
last_login: '2026-07-20T14:22:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Property 6 (client-side): Short passwords are rejected', async () => {
|
||||
// Generate strings of length 1–7 (non-empty so the validation triggers)
|
||||
const shortPasswordArbitrary = fc.string({ minLength: 1, maxLength: 7 });
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(shortPasswordArbitrary, async (shortPassword) => {
|
||||
// Mock fetch: first call returns the profile, subsequent calls are tracked
|
||||
const fetchMock = jest.fn().mockImplementation((url) => {
|
||||
if (typeof url === 'string' && url.includes('/auth/profile')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ...MOCK_PROFILE }),
|
||||
});
|
||||
}
|
||||
// Any other call (e.g. change-password) — should NOT happen
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ message: 'Password changed successfully' }),
|
||||
});
|
||||
});
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const onClose = jest.fn();
|
||||
const { container, unmount, getByPlaceholderText } = render(
|
||||
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||
);
|
||||
|
||||
try {
|
||||
// Wait for the profile to load
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain(MOCK_PROFILE.username);
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Fill in the form fields:
|
||||
// current password, the short new password, and a matching confirm password
|
||||
const currentPwInput = getByPlaceholderText('Enter current password');
|
||||
const newPwInput = getByPlaceholderText('Minimum 8 characters');
|
||||
const confirmPwInput = getByPlaceholderText('Re-enter new password');
|
||||
|
||||
fireEvent.change(currentPwInput, { target: { value: 'currentpass1' } });
|
||||
fireEvent.change(newPwInput, { target: { value: shortPassword } });
|
||||
fireEvent.change(confirmPwInput, { target: { value: shortPassword } });
|
||||
|
||||
// Assert the "Password must be at least 8 characters" validation error is displayed
|
||||
expect(container.textContent).toContain('Password must be at least 8 characters');
|
||||
|
||||
// Assert the submit button is disabled
|
||||
const submitButton = container.querySelector('button[type="submit"]');
|
||||
expect(submitButton).not.toBeNull();
|
||||
expect(submitButton.disabled).toBe(true);
|
||||
|
||||
// Assert that no call was made to the change-password endpoint
|
||||
const changePasswordCalls = fetchMock.mock.calls.filter(
|
||||
([url]) => typeof url === 'string' && url.includes('/auth/change-password')
|
||||
);
|
||||
expect(changePasswordCalls).toHaveLength(0);
|
||||
} finally {
|
||||
unmount();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 120000);
|
||||
Reference in New Issue
Block a user