Add user profile panel with self-service password change and dark theme UserMenu

This commit is contained in:
root
2026-04-24 17:29:06 +00:00
parent 53439b2af8
commit 8bf8dc55dd
14 changed files with 2244 additions and 34 deletions

View File

@@ -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);

View 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);

View File

@@ -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 17 (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 17 (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);