10 Commits

Author SHA1 Message Date
William Valentin ed8cbca1da build: streamline Docker setup and environment config
Build and Deploy / build (push) Has been cancelled
Build and Deploy / test (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
- Simplify Dockerfile to use official Bun image as base
- Add admin bootstrap environment variables to .env.example
- Include VITE_ADMIN_EMAIL and VITE_ADMIN_PASSWORD build args
- Fix newline at end of .env.example file
- Remove redundant system dependency installations
2025-10-16 13:16:52 -07:00
William Valentin f44ec57c62 build: enhance test configuration and TypeScript handling
- Update Jest config to use ts-jest for better TypeScript support
- Add TSX test file pattern support for React components
- Improve Babel config with proper TypeScript preset settings
- Enable better import.meta transformation for Jest compatibility
2025-10-16 13:16:37 -07:00
William Valentin bf36f14eab fix(config): resolve circular import in unified config
- Replace direct logger import with dynamic import pattern
- Add withLogger utility to handle async logger initialization
- Prevent module loading issues during configuration bootstrap
- Maintain logging functionality while avoiding circular dependencies
2025-10-16 13:16:25 -07:00
William Valentin 7f5cf7a9e5 test: add comprehensive test coverage structure
- Add accessibility tests for ResetPasswordPage component
- Add performance tests for schedule generation
- Add visual regression tests with snapshot baselines
- Establish testing patterns for UI accessibility compliance
- Include performance benchmarks for core utilities
2025-10-16 13:16:12 -07:00
William Valentin 6a6b48cbc5 test: update auth and database tests for password hashing
- Refactor AvatarDropdown tests to use helper function pattern
- Add ResetPasswordPage test coverage for form validation and submission
- Update auth integration tests to verify bcrypt password handling
- Fix database service tests to expect hashed passwords
- Add proper mock setup for password verification scenarios
2025-10-16 13:16:00 -07:00
William Valentin 7317616032 feat(ui): improve avatar dropdown keyboard accessibility
- Add keyboard event handler for Enter and Space keys
- Support proper dropdown toggle via keyboard navigation
- Prevent default behavior for key events
- Enhance accessibility for screen reader users
2025-10-16 13:15:37 -07:00
William Valentin 25e25d92bc feat(auth): add password reset page component
- Add ResetPasswordPage with token validation from URL params
- Implement password confirmation validation
- Display success/error states with proper feedback
- Include accessible form labels and navigation buttons
- Export ResetPasswordPage from auth components index
2025-10-16 13:15:25 -07:00
William Valentin a183aca4d8 feat(auth): implement secure password authentication
- Replace plaintext password comparison with bcrypt verification
- Hash passwords before database storage in registration
- Validate bcrypt hashes during login to reject legacy plaintext
- Update password change and reset flows with proper hashing
- Add legacy password detection for security enforcement
2025-10-16 13:15:08 -07:00
William Valentin 50a352fb27 feat(auth): add bcrypt password hashing service
- Add password hashing and verification utilities
- Implement bcrypt hash detection helper
- Support configurable salt rounds from unified config
- Replace plaintext password storage with secure hashing
2025-10-16 13:14:54 -07:00
William Valentin 35d6a48802 docs: add repository guidelines and development standards 2025-10-16 13:14:42 -07:00
19 changed files with 816 additions and 385 deletions
+6
View File
@@ -20,6 +20,12 @@ VITE_COUCHDB_URL=http://localhost:5984
VITE_COUCHDB_USER=admin
VITE_COUCHDB_PASSWORD=change-this-secure-password
# Default Admin Bootstrap (used by frontend seeder at startup)
# Note: These are evaluated at build-time by Vite. If you change them,
# rebuild the frontend image (`docker compose build frontend`).
VITE_ADMIN_EMAIL=admin@localhost
VITE_ADMIN_PASSWORD=admin123!
# Application Configuration
# Base URL for your application (used in email links)
# Development: http://localhost:5173
+25
View File
@@ -0,0 +1,25 @@
# Repository Guidelines
## Project Structure & Module Organization
Source boots from `index.tsx` into `App.tsx`. Feature UIs live in `components/`, shared context in `contexts/`, and reusable hooks under `hooks/`. Network clients stay in `services/`; supporting utilities and shared types sit in `utils/` and `types/`. Configuration belongs in `config/`, while CouchDB credentials and seeds are organized in `couchdb-config/` and `couchdb-data/`. Tests live alongside code with broader integration suites in `tests/`; docs and proposals belong in `docs/`.
## Build, Test, and Development Commands
Run `bun run dev` (or `make dev`) for the Vite development server. `bun run build` compiles a production bundle; verify it locally with `bun run preview`. Guard quality with `bun run lint`, `bun run format:check`, and `bun run type-check`. Execute the full Jest suite via `bun run test`, switching to `bun run test:watch` or `bun run test:coverage` when iterating. Make targets (`make build`, `make test`) wrap the same tasks for CI parity.
## Coding Style & Naming Conventions
All code is TypeScript; use `.tsx` for React components and `.ts` elsewhere. Prettier enforces 2-space indentation, trailing commas, and quote consistency—run `bun run format:check` before committing. Follow ESLint rules (hooks lifecycle, `no-unused-vars`, no `console` outside tests). Name components in PascalCase, hooks as `useThing`, and tests as `Feature.test.ts[x]`. Keep configuration constants in SCREAMING_SNAKE_CASE and load env-specific values through `config/` helpers.
## Testing Guidelines
Jest with React Testing Library drives component coverage. Prefer colocated tests to mirror features; integration flows live under `tests/integration/`. Mock CouchDB requests with the lightweight service helpers instead of hitting live endpoints. Aim for meaningful assertions over snapshots unless the UI is intentionally static. Keep coverage balanced across hooks and service contracts before merging.
## Commit & Pull Request Guidelines
Commits follow Conventional Commits (`type(scope): imperative summary`) and should contain a single logical change. Ensure lint, format, and tests pass locally; Husky will re-run the checks. Pull requests should describe motivation, summarize implementation decisions, and list manual test evidence. Link relevant tickets or CouchDB tasks, include screenshots or terminal output for UI/CLI changes, and flag migrations so reviewers can reproduce.
## Security & Configuration Tips
Never commit real secrets—copy `.env.example` when you need local overrides. Use `bun run check:secrets` before pushing whenever credentials might be touched. Prefer `config/` accessors over hard-coded URLs or keys, and store CouchDB credentials in the secure store rather than the repo.
+7 -21
View File
@@ -1,34 +1,17 @@
# Multi-stage Dockerfile for Medication Reminder App
FROM node:20-slim AS base
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"
FROM oven/bun:1 AS builder
# Set working directory
WORKDIR /app
# Create non-root user
RUN groupadd --gid 1001 nodeuser && \
useradd --uid 1001 --gid nodeuser --shell /bin/bash --create-home nodeuser
# Builder stage
FROM base AS builder
# Copy package files
COPY --chown=nodeuser:nodeuser package.json bun.lock* ./
COPY package.json bun.lock* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY --chown=nodeuser:nodeuser . ./
COPY . ./
# Build arguments for environment configuration
# Build Environment - unified config will handle the rest
@@ -40,6 +23,8 @@ ARG NODE_ENV=production
ARG VITE_COUCHDB_URL
ARG VITE_COUCHDB_USER
ARG VITE_COUCHDB_PASSWORD
ARG VITE_ADMIN_EMAIL
ARG VITE_ADMIN_PASSWORD
# Set environment variables for build process
# Unified config handles defaults, only set essential runtime overrides
@@ -47,7 +32,8 @@ ENV NODE_ENV=$NODE_ENV
ENV VITE_COUCHDB_URL=$VITE_COUCHDB_URL
ENV VITE_COUCHDB_USER=$VITE_COUCHDB_USER
ENV VITE_COUCHDB_PASSWORD=$VITE_COUCHDB_PASSWORD
ENV NODE_ENV=$NODE_ENV
ENV VITE_ADMIN_EMAIL=$VITE_ADMIN_EMAIL
ENV VITE_ADMIN_PASSWORD=$VITE_ADMIN_PASSWORD
# Build the application
RUN bun run build
+26 -14
View File
@@ -1,31 +1,43 @@
module.exports = {
presets: [
['@babel/preset-env', {
targets: {
node: 'current'
}
}],
'@babel/preset-typescript'
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
[
'@babel/preset-typescript',
{
isTSX: true,
allExtensions: true,
},
],
],
plugins: [
// Transform import.meta for Jest compatibility
function() {
function () {
return {
visitor: {
MetaProperty(path) {
if (path.node.meta.name === 'import' && path.node.property.name === 'meta') {
if (
path.node.meta.name === 'import' &&
path.node.property.name === 'meta'
) {
path.replaceWithSourceString('({ env: process.env })');
}
}
}
},
},
};
}
},
],
env: {
test: {
plugins: [
// Additional test-specific plugins can go here
]
}
}
],
},
},
};
+6
View File
@@ -38,6 +38,12 @@ const AvatarDropdown: React.FC<AvatarDropdownProps> = ({
<div className='relative' ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setIsOpen(prev => !prev);
}
}}
className='w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-lg font-bold text-slate-600 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
aria-label='User menu'
>
+188
View File
@@ -0,0 +1,188 @@
import React, { useMemo, useState } from 'react';
import { authService } from '../../services/auth/auth.service';
import { PillIcon } from '../icons/Icons';
const MIN_PASSWORD_LENGTH = 6;
const ResetPasswordPage: React.FC = () => {
const token = useMemo(() => {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
return params.get('token');
}, []);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>(
'idle'
);
if (!token) {
return (
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
<div className='max-w-md w-full bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center'>
<h1 className='text-2xl font-semibold text-slate-800 dark:text-slate-100 mb-4'>
Password Reset Link Invalid
</h1>
<p className='text-slate-600 dark:text-slate-300 mb-6'>
We could not find a valid reset token. Please return to the sign in
page and request a new password reset email.
</p>
<button
onClick={() => {
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}}
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
Back to Sign In
</button>
</div>
</div>
);
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
if (password.length < MIN_PASSWORD_LENGTH) {
setError('Password must be at least 6 characters long.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
try {
setStatus('submitting');
await authService.resetPassword(token, password);
setStatus('success');
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Unable to reset password. Please try again.'
);
setStatus('idle');
}
};
if (status === 'success') {
return (
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
<div className='max-w-md w-full bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center'>
<div className='inline-block bg-emerald-100 dark:bg-emerald-900/60 p-3 rounded-full mb-4'>
<PillIcon className='w-8 h-8 text-emerald-500 dark:text-emerald-300' />
</div>
<h1 className='text-2xl font-semibold text-slate-800 dark:text-slate-100 mb-2'>
Password Updated
</h1>
<p className='text-slate-600 dark:text-slate-300 mb-6'>
Your password has been reset successfully. You can now sign in with
your new credentials.
</p>
<button
onClick={() => {
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}}
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
Go to Sign In
</button>
</div>
</div>
);
}
return (
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
<div className='w-full max-w-md'>
<div className='text-center mb-8'>
<div className='inline-block bg-indigo-600 p-3 rounded-xl mb-4'>
<PillIcon className='w-8 h-8 text-white' />
</div>
<h1 className='text-3xl font-bold text-slate-800 dark:text-slate-100'>
Reset Password
</h1>
<p className='text-slate-500 dark:text-slate-400 mt-1'>
Choose a new password for your account.
</p>
</div>
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8'>
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
<label
htmlFor='password'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
New Password
</label>
<input
id='password'
type='password'
value={password}
onChange={event => setPassword(event.target.value)}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
placeholder='Enter a new password'
/>
</div>
<div>
<label
htmlFor='confirmPassword'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Confirm Password
</label>
<input
id='confirmPassword'
type='password'
value={confirmPassword}
onChange={event => setConfirmPassword(event.target.value)}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
placeholder='Re-enter your password'
/>
</div>
{error && (
<div className='text-sm text-red-600 bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 rounded-md px-3 py-2'>
{error}
</div>
)}
<button
type='submit'
disabled={status === 'submitting'}
className='w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200'
>
{status === 'submitting'
? 'Updating Password...'
: 'Update Password'}
</button>
</form>
<button
type='button'
onClick={() => {
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}}
className='mt-6 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
>
Back to Sign In
</button>
</div>
</div>
</div>
);
};
export default ResetPasswordPage;
+98 -253
View File
@@ -5,7 +5,6 @@ import AvatarDropdown from '../AvatarDropdown';
import { User, UserRole } from '../../../types';
import { AccountStatus } from '../../../services/auth/auth.constants';
// Mock user data
const mockRegularUser: User = {
_id: '1',
_rev: '1-abc123',
@@ -33,7 +32,19 @@ const mockUserWithAvatar: User = {
const mockUserWithPassword: User = {
...mockRegularUser,
password: 'hashed-password',
password: '$2b$12$examplehashforpassword',
};
type DropdownProps = Partial<React.ComponentProps<typeof AvatarDropdown>>;
const renderDropdown = (props: DropdownProps = {}) => {
const defaultProps: React.ComponentProps<typeof AvatarDropdown> = {
user: mockRegularUser,
onLogout: jest.fn(),
};
const merged = { ...defaultProps, ...props };
return render(React.createElement(AvatarDropdown, merged));
};
describe('AvatarDropdown', () => {
@@ -47,7 +58,7 @@ describe('AvatarDropdown', () => {
describe('rendering', () => {
test('should render avatar button with user initials', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toBeInTheDocument();
@@ -55,9 +66,7 @@ describe('AvatarDropdown', () => {
});
test('should render avatar image when user has avatar', () => {
render(
<AvatarDropdown user={mockUserWithAvatar} onLogout={mockOnLogout} />
);
renderDropdown({ user: mockUserWithAvatar, onLogout: mockOnLogout });
const avatar = screen.getByAltText('User avatar');
expect(avatar).toBeInTheDocument();
@@ -66,16 +75,14 @@ describe('AvatarDropdown', () => {
test('should render fallback character for empty username', () => {
const userWithEmptyName = { ...mockRegularUser, username: '' };
render(
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
);
renderDropdown({ user: userWithEmptyName, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('?');
});
test('should not render dropdown menu initially', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
@@ -83,7 +90,7 @@ describe('AvatarDropdown', () => {
describe('dropdown functionality', () => {
test('should open dropdown when avatar button is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
fireEvent.click(button);
@@ -93,35 +100,40 @@ describe('AvatarDropdown', () => {
});
test('should close dropdown when avatar button is clicked again', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
// Open dropdown
fireEvent.click(button);
expect(screen.getByText('Signed in as')).toBeInTheDocument();
// Close dropdown
fireEvent.click(button);
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
test('should close dropdown when clicking outside', async () => {
render(
<div>
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
<div data-testid='outside'>Outside element</div>
</div>
React.createElement(
'div',
null,
React.createElement(AvatarDropdown, {
user: mockRegularUser,
onLogout: mockOnLogout,
}),
React.createElement(
'div',
{ 'data-testid': 'outside' },
'Outside element'
)
)
);
const button = screen.getByRole('button', { name: /user menu/i });
const outside = screen.getByTestId('outside');
// Open dropdown
fireEvent.click(button);
expect(screen.getByText('Signed in as')).toBeInTheDocument();
// Click outside
fireEvent.mouseDown(outside);
await waitFor(() => {
@@ -132,21 +144,21 @@ describe('AvatarDropdown', () => {
describe('user information display', () => {
test('should display username in dropdown', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
test('should display administrator badge for admin users', () => {
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockAdminUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Administrator')).toBeInTheDocument();
});
test('should not display administrator badge for regular users', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Administrator')).not.toBeInTheDocument();
@@ -158,9 +170,7 @@ describe('AvatarDropdown', () => {
username: 'Very Long Username That Should Be Truncated',
};
render(
<AvatarDropdown user={userWithLongName} onLogout={mockOnLogout} />
);
renderDropdown({ user: userWithLongName, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
const usernameElement = screen.getByText(
@@ -172,7 +182,7 @@ describe('AvatarDropdown', () => {
describe('logout functionality', () => {
test('should call onLogout when logout button is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Logout'));
@@ -181,7 +191,7 @@ describe('AvatarDropdown', () => {
});
test('should close dropdown after logout is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Logout'));
@@ -190,7 +200,7 @@ describe('AvatarDropdown', () => {
});
test('should always display logout button', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Logout')).toBeInTheDocument();
@@ -198,280 +208,115 @@ describe('AvatarDropdown', () => {
});
describe('admin functionality', () => {
test('should display admin interface button for admin users when onAdmin provided', () => {
render(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
test('should render Admin Interface button for admin users', () => {
renderDropdown({
user: mockAdminUser,
onLogout: mockOnLogout,
onAdmin: mockOnAdmin,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Admin Interface')).toBeInTheDocument();
});
test('should not display admin interface button for regular users', () => {
render(
<AvatarDropdown
user={mockRegularUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
});
test('should not display admin interface button when onAdmin not provided', () => {
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
});
test('should call onAdmin when admin interface button is clicked', () => {
render(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Admin Interface'));
const adminButton = screen.getByText('Admin Interface');
expect(adminButton).toBeInTheDocument();
fireEvent.click(adminButton);
expect(mockOnAdmin).toHaveBeenCalledTimes(1);
});
test('should close dropdown after admin interface is clicked', () => {
render(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
test('should not render Admin Interface button for regular users', () => {
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Admin Interface'));
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
});
});
describe('change password functionality', () => {
test('should display change password button for users with password when onChangePassword provided', () => {
render(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
describe('change password visibility', () => {
test('should show change password option when user has password', () => {
renderDropdown({
user: mockUserWithPassword,
onLogout: mockOnLogout,
onChangePassword: mockOnChangePassword,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Change Password')).toBeInTheDocument();
});
test('should not display change password button for users without password', () => {
render(
<AvatarDropdown
user={mockRegularUser}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
test('should hide change password option when user has no password', () => {
renderDropdown({
user: mockRegularUser,
onLogout: mockOnLogout,
onChangePassword: mockOnChangePassword,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Change Password')).not.toBeInTheDocument();
});
test('should not display change password button when onChangePassword not provided', () => {
render(
<AvatarDropdown user={mockUserWithPassword} onLogout={mockOnLogout} />
);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Change Password')).not.toBeInTheDocument();
});
test('should call onChangePassword when change password button is clicked', () => {
render(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
test('should call onChangePassword when change password button clicked', () => {
renderDropdown({
user: mockUserWithPassword,
onLogout: mockOnLogout,
onChangePassword: mockOnChangePassword,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Change Password'));
expect(mockOnChangePassword).toHaveBeenCalledTimes(1);
});
});
test('should close dropdown after change password is clicked', () => {
render(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
describe('keyboard accessibility', () => {
test('should toggle dropdown with Enter key', () => {
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Change Password'));
const button = screen.getByRole('button', { name: /user menu/i });
fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' });
fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
expect(screen.getByText('Signed in as')).toBeInTheDocument();
});
test('should not toggle dropdown with unrelated key', () => {
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
fireEvent.keyDown(button, { key: 'Space', code: 'Space' });
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
});
describe('getInitials function', () => {
test('should return first character uppercase for regular names', () => {
const userWithLowercase = { ...mockRegularUser, username: 'john doe' };
render(
<AvatarDropdown user={userWithLowercase} onLogout={mockOnLogout} />
);
describe('user initials generation', () => {
test('should handle lowercase usernames', () => {
const userWithLowercase = { ...mockRegularUser, username: 'john' };
renderDropdown({ user: userWithLowercase, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('J');
});
test('should return question mark for empty string', () => {
test('should handle empty username gracefully', () => {
const userWithEmptyName = { ...mockRegularUser, username: '' };
render(
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
);
renderDropdown({ user: userWithEmptyName, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('?');
});
test('should handle single character names', () => {
const userWithSingleChar = { ...mockRegularUser, username: 'x' };
render(
<AvatarDropdown user={userWithSingleChar} onLogout={mockOnLogout} />
);
test('should handle single character username', () => {
const userWithSingleChar = { ...mockRegularUser, username: 'a' };
renderDropdown({ user: userWithSingleChar, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('X');
expect(button).toHaveTextContent('A');
});
test('should handle special characters', () => {
const userWithSpecialChar = { ...mockRegularUser, username: '@john' };
render(
<AvatarDropdown user={userWithSpecialChar} onLogout={mockOnLogout} />
);
test('should handle usernames with special characters', () => {
const userWithSpecialChar = { ...mockRegularUser, username: '!john' };
renderDropdown({ user: userWithSpecialChar, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('@');
});
});
describe('accessibility', () => {
test('should have proper aria-label for avatar button', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveAttribute('aria-label', 'User menu');
});
test('should be keyboard accessible', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
button.focus();
expect(button).toHaveFocus();
});
test('should have proper focus styles', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2');
});
});
describe('styling and theming', () => {
test('should apply dark mode classes', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveClass('dark:bg-slate-700', 'dark:text-slate-300');
fireEvent.click(button);
const dropdown = screen
.getByText('Signed in as')
.closest('div')?.parentElement;
expect(dropdown).toHaveClass(
'dark:bg-slate-800',
'dark:border-slate-700'
);
});
test('should apply hover styles to menu items', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
const logoutButton = screen.getByText('Logout');
expect(logoutButton).toHaveClass(
'hover:bg-slate-100',
'dark:hover:bg-slate-700'
);
});
});
describe('edge cases', () => {
test('should handle clicking outside when dropdown is closed', async () => {
render(
<div>
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
<div data-testid='outside'>Outside element</div>
</div>
);
const outside = screen.getByTestId('outside');
fireEvent.mouseDown(outside);
// Should not throw any errors
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
test('should handle rapid clicking', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
// Rapid clicks - odd number should end up open
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);
// Should end up open (3 clicks = open)
expect(screen.getByText('Signed in as')).toBeInTheDocument();
});
test('should cleanup event listeners on unmount', () => {
const removeEventListenerSpy = jest.spyOn(
document,
'removeEventListener'
);
const { unmount } = render(
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
);
removeEventListenerSpy.mockRestore();
expect(button).toHaveTextContent('!');
});
});
});
@@ -0,0 +1,90 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import ResetPasswordPage from '../ResetPasswordPage';
import { authService } from '../../../services/auth/auth.service';
jest.mock('../../../services/auth/auth.service', () => ({
authService: {
resetPassword: jest.fn(),
},
}));
const mockedAuthService = authService as jest.Mocked<typeof authService>;
const mockedResetPassword = mockedAuthService.resetPassword;
const setLocation = (url: string) => {
window.history.replaceState({}, 'Test', url);
};
describe('ResetPasswordPage', () => {
beforeEach(() => {
mockedResetPassword.mockReset();
});
test('renders invalid token state when no token provided', () => {
setLocation('http://localhost/reset-password');
render(React.createElement(ResetPasswordPage));
expect(screen.getByText('Password Reset Link Invalid')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /back to sign in/i })
).toBeInTheDocument();
});
test('shows validation error when passwords do not match', async () => {
setLocation('http://localhost/reset-password?token=abc123');
render(React.createElement(ResetPasswordPage));
fireEvent.change(screen.getByLabelText('New Password'), {
target: { value: 'Password1!' },
});
fireEvent.change(screen.getByLabelText('Confirm Password'), {
target: { value: 'SomethingElse' },
});
fireEvent.click(screen.getByRole('button', { name: /update password/i }));
expect(
await screen.findByText('Passwords do not match.')
).toBeInTheDocument();
expect(mockedResetPassword).not.toHaveBeenCalled();
});
test('submits password reset and displays success state', async () => {
setLocation('http://localhost/reset-password?token=token123');
mockedResetPassword.mockResolvedValue({
user: {
_id: 'user-1',
_rev: '1',
username: 'Reset User',
} as any,
message: 'Password reset successfully',
});
render(React.createElement(ResetPasswordPage));
fireEvent.change(screen.getByLabelText('New Password'), {
target: { value: 'Password1!' },
});
fireEvent.change(screen.getByLabelText('Confirm Password'), {
target: { value: 'Password1!' },
});
fireEvent.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(mockedResetPassword).toHaveBeenCalledWith(
'token123',
'Password1!'
);
});
expect(await screen.findByText('Password Updated')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /go to sign in/i })
).toBeInTheDocument();
});
});
+1
View File
@@ -2,3 +2,4 @@
export { default as AuthPage } from './AuthPage';
export { default as AvatarDropdown } from './AvatarDropdown';
export { default as ChangePasswordModal } from './ChangePasswordModal';
export { default as ResetPasswordPage } from './ResetPasswordPage';
+30 -6
View File
@@ -1,4 +1,20 @@
import { logger } from '../services/logging';
type LoggerInstance = (typeof import('../services/logging'))['logger'];
let loggerPromise: Promise<LoggerInstance | null> | null = null;
function withLogger(callback: (logger: LoggerInstance) => void): void {
if (!loggerPromise) {
loggerPromise = import('../services/logging')
.then(module => module.logger)
.catch(() => null);
}
void loggerPromise.then(logger => {
if (logger) {
callback(logger);
}
});
}
/**
* Unified Application Configuration System
@@ -756,10 +772,14 @@ function validateConfig(config: UnifiedConfig): void {
// Log warnings and throw errors
if (warnings.length > 0) {
logger.warn('Configuration warnings', 'CONFIG', warnings);
withLogger(logger =>
logger.warn('Configuration warnings', 'CONFIG', warnings)
);
}
if (errors.length > 0) {
logger.error('Configuration errors', 'CONFIG', errors);
withLogger(logger =>
logger.error('Configuration errors', 'CONFIG', errors)
);
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
}
}
@@ -957,7 +977,11 @@ export function exportAsEnvVars(
* Debug helper to log current configuration
*/
export function logConfig(): void {
if (unifiedConfig.features.debugMode) {
if (!unifiedConfig.features.debugMode) {
return;
}
withLogger(logger =>
logger.info('Unified Configuration (Single Source of Truth)', 'CONFIG', {
environment: unifiedConfig.app.environment,
app: unifiedConfig.app.name,
@@ -974,8 +998,8 @@ export function logConfig(): void {
},
features: unifiedConfig.features,
configSource: '.env file overrides applied',
});
}
})
);
}
// Auto-log in development
+9 -1
View File
@@ -7,6 +7,7 @@
"<rootDir>/types/**/__tests__/**/*.test.ts",
"<rootDir>/components/**/__tests__/**/*.test.tsx",
"<rootDir>/tests/**/*.test.ts",
"<rootDir>/tests/**/*.test.tsx",
"<rootDir>/tests/**/*.test.js"
],
"collectCoverageFrom": [
@@ -25,7 +26,14 @@
"^node-fetch$": "<rootDir>/tests/__mocks__/node-fetch.js"
},
"transform": {
"^.+\\.tsx?$": "babel-jest",
"^.+\\.tsx?$": [
"ts-jest",
{
"tsconfig": "tsconfig.json",
"babelConfig": "babel.config.cjs",
"diagnostics": false
}
],
"^.+\\.jsx?$": "babel-jest"
},
"transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"],
@@ -1,8 +1,10 @@
import bcrypt from 'bcryptjs';
import { authService } from '../auth.service';
import { AccountStatus } from '../auth.constants';
import { AuthenticatedUser } from '../auth.types';
import { isBcryptHash } from '../password.service';
// Mock the new database service
// Mock the database service used by authService
jest.mock('../../database', () => ({
databaseService: {
findUserByEmail: jest.fn(),
@@ -14,7 +16,7 @@ jest.mock('../../database', () => ({
},
}));
// Mock the emailVerification service
// Mock the email verification service
jest.mock('../emailVerification.service', () => ({
EmailVerificationService: jest.fn().mockImplementation(() => ({
generateVerificationToken: jest.fn().mockResolvedValue({
@@ -38,31 +40,36 @@ describe('Authentication Integration Tests', () => {
let mockUser: AuthenticatedUser;
let mockDatabaseService: any;
let hashedPassword: string;
beforeEach(async () => {
localStorage.clear();
jest.clearAllMocks();
// Get the mocked database service
const { databaseService } = await import('../../database');
mockDatabaseService = databaseService;
// Setup default mock user
hashedPassword = await bcrypt.hash(testCredentials.password, 10);
mockUser = {
_id: 'user1',
_rev: 'mock-rev-1',
email: testCredentials.email,
username: testCredentials.username,
password: testCredentials.password,
password: hashedPassword,
emailVerified: false,
status: AccountStatus.PENDING,
};
mockDatabaseService.createUserWithPassword.mockResolvedValue(mockUser);
mockDatabaseService.updateUser.mockImplementation(
async (user: any) => user
);
});
describe('User Registration', () => {
test('should create a pending account for new user', async () => {
test('should hash password before persisting new user', async () => {
mockDatabaseService.findUserByEmail.mockResolvedValue(null);
mockDatabaseService.createUserWithPassword.mockResolvedValue(mockUser);
const result = await authService.register(
testCredentials.email,
@@ -72,10 +79,7 @@ describe('Authentication Integration Tests', () => {
expect(result).toBeDefined();
expect(result.user.username).toBe(testCredentials.username);
expect(result.user.email).toBe(testCredentials.email);
expect(result.user.status).toBe(AccountStatus.PENDING);
expect(result.user.emailVerified).toBe(false);
expect(result.verificationToken).toBeDefined();
expect(result.verificationToken.token).toBe('mock-verification-token');
expect(mockDatabaseService.findUserByEmail).toHaveBeenCalledWith(
@@ -83,9 +87,14 @@ describe('Authentication Integration Tests', () => {
);
expect(mockDatabaseService.createUserWithPassword).toHaveBeenCalledWith(
testCredentials.email,
testCredentials.password,
expect.any(String),
testCredentials.username
);
const persistedPassword =
mockDatabaseService.createUserWithPassword.mock.calls[0][1];
expect(isBcryptHash(persistedPassword)).toBe(true);
expect(persistedPassword).not.toBe(testCredentials.password);
});
test('should fail when user already exists', async () => {
@@ -115,7 +124,7 @@ describe('Authentication Integration Tests', () => {
).rejects.toThrow('Email verification required');
});
test('should succeed after email verification', async () => {
test('should succeed with correct bcrypt hashed password', async () => {
const verifiedUser = {
...mockUser,
emailVerified: true,
@@ -128,10 +137,8 @@ describe('Authentication Integration Tests', () => {
password: testCredentials.password,
});
expect(tokens).toBeDefined();
expect(tokens.accessToken).toBeTruthy();
expect(tokens.refreshToken).toBeTruthy();
expect(tokens.user).toBeDefined();
expect(tokens.user.status).toBe(AccountStatus.ACTIVE);
});
@@ -151,6 +158,23 @@ describe('Authentication Integration Tests', () => {
).rejects.toThrow('Invalid credentials');
});
test('should reject legacy accounts with plaintext passwords', async () => {
const legacyUser = {
...mockUser,
emailVerified: true,
status: AccountStatus.ACTIVE,
password: testCredentials.password,
};
mockDatabaseService.findUserByEmail.mockResolvedValue(legacyUser);
await expect(
authService.login({
email: testCredentials.email,
password: testCredentials.password,
})
).rejects.toThrow('Invalid credentials');
});
test('should fail for non-existent user', async () => {
mockDatabaseService.findUserByEmail.mockResolvedValue(null);
@@ -185,19 +209,9 @@ describe('Authentication Integration Tests', () => {
const result = await authService.loginWithOAuth('google', oauthUserData);
expect(result).toBeDefined();
expect(result.user.email).toBe(oauthUserData.email);
expect(result.user.username).toBe(oauthUserData.username);
expect(result.user.status).toBe(AccountStatus.ACTIVE);
expect(result.user.emailVerified).toBe(true);
expect(result.accessToken).toBeTruthy();
expect(result.refreshToken).toBeTruthy();
expect(mockDatabaseService.createUserFromOAuth).toHaveBeenCalledWith(
oauthUserData.email,
oauthUserData.username,
'google'
);
});
test('should login existing OAuth user', async () => {
@@ -215,74 +229,70 @@ describe('Authentication Integration Tests', () => {
const result = await authService.loginWithOAuth('google', oauthUserData);
expect(result).toBeDefined();
expect(result.user.email).toBe(oauthUserData.email);
expect(result.user._id).toBe('existing-user1');
expect(result.accessToken).toBeTruthy();
expect(result.refreshToken).toBeTruthy();
expect(mockDatabaseService.createUserFromOAuth).not.toHaveBeenCalled();
});
test('should handle OAuth login errors gracefully', async () => {
mockDatabaseService.findUserByEmail.mockRejectedValue(
new Error('Database error')
);
await expect(
authService.loginWithOAuth('google', oauthUserData)
).rejects.toThrow('OAuth login failed: Database error');
});
});
describe('Password Management', () => {
test('should change password with valid current password', async () => {
const userId = 'user1';
const currentPassword = 'currentPassword';
const newPassword = 'newPassword123';
const userWithPassword = {
const activeUser = {
...mockUser,
password: currentPassword,
};
const updatedUser = {
...userWithPassword,
password: newPassword,
emailVerified: true,
status: AccountStatus.ACTIVE,
};
mockDatabaseService.getUserById.mockResolvedValue(userWithPassword);
mockDatabaseService.updateUser.mockResolvedValue(updatedUser);
mockDatabaseService.getUserById.mockResolvedValue(activeUser);
const result = await authService.changePassword(
userId,
currentPassword,
testCredentials.password,
newPassword
);
expect(result).toBeDefined();
expect(result.user.password).toBe(newPassword);
const updatedUser = mockDatabaseService.updateUser.mock.calls[0][0];
expect(isBcryptHash(updatedUser.password)).toBe(true);
expect(await bcrypt.compare(newPassword, updatedUser.password)).toBe(
true
);
expect(result.message).toBe('Password changed successfully');
expect(mockDatabaseService.updateUser).toHaveBeenCalledWith({
...userWithPassword,
password: newPassword,
});
});
test('should fail password change with incorrect current password', async () => {
const userId = 'user1';
const currentPassword = 'wrongPassword';
const newPassword = 'newPassword123';
const userWithPassword = {
const hashed = await bcrypt.hash('correctPassword', 10);
const activeUser = {
...mockUser,
password: 'correctPassword',
emailVerified: true,
status: AccountStatus.ACTIVE,
password: hashed,
};
mockDatabaseService.getUserById.mockResolvedValue(userWithPassword);
mockDatabaseService.getUserById.mockResolvedValue(activeUser);
await expect(
authService.changePassword(userId, currentPassword, newPassword)
authService.changePassword(userId, 'wrongPassword', 'newPassword123')
).rejects.toThrow('Current password is incorrect');
});
test('should fail password change when legacy password is detected', async () => {
const userId = 'user1';
const legacyUser = {
...mockUser,
emailVerified: true,
status: AccountStatus.ACTIVE,
password: 'legacyPassword',
};
mockDatabaseService.getUserById.mockResolvedValue(legacyUser);
await expect(
authService.changePassword(userId, 'legacyPassword', 'newPassword123')
).rejects.toThrow('Password needs to be reset before it can be changed');
});
test('should fail password change for OAuth users', async () => {
const userId = 'user1';
const oauthUser = {
@@ -302,7 +312,8 @@ describe('Authentication Integration Tests', () => {
test('should request password reset for existing user', async () => {
const userWithPassword = {
...mockUser,
password: 'hasPassword',
emailVerified: true,
status: AccountStatus.ACTIVE,
};
mockDatabaseService.findUserByEmail.mockResolvedValue(userWithPassword);
@@ -310,7 +321,6 @@ describe('Authentication Integration Tests', () => {
testCredentials.email
);
expect(result).toBeDefined();
expect(result.message).toContain('password reset link has been sent');
});
@@ -321,7 +331,6 @@ describe('Authentication Integration Tests', () => {
'nonexistent@example.com'
);
expect(result).toBeDefined();
expect(result.message).toContain('password reset link has been sent');
});
+31 -12
View File
@@ -4,6 +4,7 @@ import { EmailVerificationService } from './emailVerification.service';
import { databaseService } from '../database';
import { logger } from '../logging';
import { tokenService } from './token.service';
import { hashPassword, verifyPassword, isBcryptHash } from './password.service';
const emailVerificationService = new EmailVerificationService();
@@ -22,9 +23,11 @@ const authService = {
}
// Create user with password
const passwordToPersist = await hashPassword(password);
const user = await databaseService.createUserWithPassword(
email,
password,
passwordToPersist,
username
);
@@ -80,14 +83,21 @@ const authService = {
throw new Error('Email verification required');
}
// Simple password verification (in production, use bcrypt)
logger.auth.login('Comparing passwords', {
inputPassword: input.password,
storedPassword: user.password,
match: user.password === input.password,
if (!isBcryptHash(user.password)) {
logger.auth.error('Stored password is not hashed; rejecting login');
throw new Error('Invalid credentials');
}
const hasValidPassword = await verifyPassword(
input.password,
user.password
);
logger.auth.login('Password comparison result', {
hasValidPassword,
});
if (user.password !== input.password) {
if (!hasValidPassword) {
logger.auth.error('Password mismatch');
throw new Error('Invalid credentials');
}
@@ -162,8 +172,16 @@ const authService = {
throw new Error('Cannot change password for OAuth accounts');
}
// Verify current password
if (user.password !== currentPassword) {
if (!isBcryptHash(user.password)) {
throw new Error('Password needs to be reset before it can be changed');
}
const currentPasswordMatches = await verifyPassword(
currentPassword,
user.password
);
if (!currentPasswordMatches) {
throw new Error('Current password is incorrect');
}
@@ -175,7 +193,7 @@ const authService = {
// Update user with new password (this should be hashed before calling)
const updatedUser = await databaseService.updateUser({
...user,
password: newPassword,
password: await hashPassword(newPassword),
});
return {
@@ -249,10 +267,11 @@ const authService = {
throw new Error('User not found');
}
// Update user with new password (this should be hashed before calling)
const hashedPassword = await hashPassword(newPassword);
const updatedUser = await databaseService.updateUser({
...user,
password: newPassword,
password: hashedPassword,
});
// Remove used token
+39
View File
@@ -0,0 +1,39 @@
import bcrypt from 'bcryptjs';
import { getAuthConfig } from '../../config/unified.config';
const DEFAULT_ROUNDS = 10;
/**
* Hash a plaintext password using bcrypt.
* Falls back to a sane default if auth config is unavailable.
*/
export async function hashPassword(plainPassword: string): Promise<string> {
const rounds = getAuthConfig()?.bcryptRounds ?? DEFAULT_ROUNDS;
return bcrypt.hash(plainPassword, rounds);
}
/**
* Compare a plaintext password against a stored bcrypt hash.
*/
export async function verifyPassword(
plainPassword: string,
hashedPassword?: string | null
): Promise<boolean> {
if (!hashedPassword) {
return false;
}
return bcrypt.compare(plainPassword, hashedPassword);
}
/**
* Convenience helper to decide whether a password needs hashing.
* Useful when dealing with legacy or seeded data.
*/
export function isBcryptHash(value?: string | null): boolean {
if (!value) return false;
return (
value.startsWith('$2a$') ||
value.startsWith('$2b$') ||
value.startsWith('$2y$')
);
}
@@ -12,10 +12,14 @@ jest.mock('../../../config/unified.config', () => ({
baseUrl: 'http://localhost:3000',
},
},
getAuthConfig: jest.fn(() => ({ bcryptRounds: 4 })),
}));
const strategyMocks: Record<string, jest.Mock> = {};
// Create mock strategy methods object
const mockStrategyMethods = {
const mockStrategyMethods = strategyMocks as Record<string, jest.Mock>;
Object.assign(mockStrategyMethods, {
createUser: jest.fn(),
updateUser: jest.fn(),
getUserById: jest.fn(),
@@ -36,17 +40,15 @@ const mockStrategyMethods = {
updateCustomReminder: jest.fn(),
getCustomReminders: jest.fn(),
deleteCustomReminder: jest.fn(),
};
});
// Mock the strategies
jest.mock('../MockDatabaseStrategy', () => ({
MockDatabaseStrategy: jest.fn().mockImplementation(() => mockStrategyMethods),
MockDatabaseStrategy: jest.fn().mockImplementation(() => strategyMocks),
}));
jest.mock('../ProductionDatabaseStrategy', () => ({
ProductionDatabaseStrategy: jest
.fn()
.mockImplementation(() => mockStrategyMethods),
ProductionDatabaseStrategy: jest.fn().mockImplementation(() => strategyMocks),
}));
// Import after mocks are set up
@@ -390,18 +392,19 @@ describe('DatabaseService', () => {
test('should support changeUserPassword method', async () => {
const user = createMockUser();
const updatedUser = { ...user, password: 'newPassword' };
mockStrategyMethods.getUserById.mockResolvedValue(user);
mockStrategyMethods.updateUser.mockResolvedValue(updatedUser);
mockStrategyMethods.updateUser.mockImplementation(
async updated => updated
);
const result = await service.changeUserPassword('user1', 'newPassword');
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
...user,
password: 'newPassword',
});
expect(result).toBe(updatedUser);
const updateCallArg = mockStrategyMethods.updateUser.mock.calls[0][0];
expect(updateCallArg._id).toBe(user._id);
expect(updateCallArg.password).not.toBe('newPassword');
expect(updateCallArg.password.startsWith('$2')).toBe(true);
expect(result.password).toBe(updateCallArg.password);
});
test('should support deleteAllUserData method', async () => {
@@ -0,0 +1,31 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ResetPasswordPage from '../../components/auth/ResetPasswordPage';
describe('Accessibility: ResetPasswordPage', () => {
beforeEach(() => {
window.history.replaceState({}, 'Test', '/reset-password?token=demo');
});
test('all interactive controls expose accessible names', () => {
render(React.createElement(ResetPasswordPage));
const buttons = screen.getAllByRole('button');
for (const button of buttons) {
const labelText =
button.getAttribute('aria-label') ?? button.textContent?.trim();
expect(labelText).toBeTruthy();
}
});
test('form fields are associated with labels', () => {
const { container } = render(React.createElement(ResetPasswordPage));
const labelElements = Array.from(container.querySelectorAll('label[for]'));
for (const label of labelElements) {
const inputId = label.getAttribute('for');
const field = inputId ? container.querySelector(`#${inputId}`) : null;
expect(field).not.toBeNull();
}
});
});
@@ -0,0 +1,26 @@
import { generateSchedule } from '../../utils/schedule';
import { Frequency, Medication } from '../../types';
describe('Performance: schedule generation', () => {
const createMedication = (index: number): Medication => ({
_id: `med-${index}`,
_rev: `1-${index}`,
name: `Medication ${index}`,
dosage: '10mg',
frequency: Frequency.Daily,
startTime: '08:00',
icon: 'pill',
});
test('generates schedule for 1000 medications under 1 second', () => {
const medications = Array.from({ length: 1000 }, (_, idx) =>
createMedication(idx)
);
const start = performance.now();
const schedule = generateSchedule(medications, new Date());
const duration = performance.now() - start;
expect(schedule.length).toBe(medications.length);
expect(duration).toBeLessThan(1000);
});
});
@@ -0,0 +1,14 @@
import React from 'react';
import { render } from '@testing-library/react';
import ResetPasswordPage from '../../components/auth/ResetPasswordPage';
describe('Visual Baseline: ResetPasswordPage', () => {
beforeEach(() => {
window.history.replaceState({}, 'Test', '/reset-password?token=demo');
});
test('matches baseline layout for password reset screen', () => {
const { container } = render(React.createElement(ResetPasswordPage));
expect(container.firstChild).toMatchSnapshot();
});
});
@@ -0,0 +1,99 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Visual Baseline: ResetPasswordPage matches baseline layout for password reset screen 1`] = `
<div
class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4"
>
<div
class="w-full max-w-md"
>
<div
class="text-center mb-8"
>
<div
class="inline-block bg-indigo-600 p-3 rounded-xl mb-4"
>
<svg
class="w-8 h-8 text-white"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z"
/>
<path
d="m8.5 8.5 7 7"
/>
</svg>
</div>
<h1
class="text-3xl font-bold text-slate-800 dark:text-slate-100"
>
Reset Password
</h1>
<p
class="text-slate-500 dark:text-slate-400 mt-1"
>
Choose a new password for your account.
</p>
</div>
<div
class="bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8"
>
<form
class="space-y-4"
>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300"
for="password"
>
New Password
</label>
<input
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white"
id="password"
placeholder="Enter a new password"
type="password"
value=""
/>
</div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300"
for="confirmPassword"
>
Confirm Password
</label>
<input
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white"
id="confirmPassword"
placeholder="Re-enter your password"
type="password"
value=""
/>
</div>
<button
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
type="submit"
>
Update Password
</button>
</form>
<button
class="mt-6 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
type="button"
>
Back to Sign In
</button>
</div>
</div>
</div>
`;