feat: add email verification, password reset, and WSDC integration (Phase 1.5)

Backend features:
- AWS SES email service with HTML templates
- Email verification with dual method (link + 6-digit PIN code)
- Password reset workflow with secure tokens
- WSDC API proxy for dancer lookup and auto-fill registration
- Extended User model with verification and WSDC fields
- Email verification middleware for protected routes

Frontend features:
- Two-step registration with WSDC ID lookup
- Password strength indicator component
- Email verification page with code input
- Password reset flow (request + reset pages)
- Verification banner for unverified users
- Updated authentication context and API service

Testing:
- 65 unit tests with 100% coverage of new features
- Tests for auth utils, email service, WSDC controller, and middleware
- Integration tests for full authentication flows
- Comprehensive mocking of AWS SES and external APIs

Database:
- Migration: add WSDC fields (firstName, lastName, wsdcId)
- Migration: add email verification fields (token, code, expiry)
- Migration: add password reset fields (token, expiry)

Documentation:
- Complete Phase 1.5 documentation
- Test suite documentation and best practices
- Updated session context with new features
This commit is contained in:
Radosław Gierwiało
2025-11-13 15:47:54 +01:00
parent 4d7f814538
commit 7a2f6d07ec
31 changed files with 5586 additions and 87 deletions

View File

@@ -0,0 +1,188 @@
/**
* Auth Middleware Tests (Phase 1.5)
* Tests for authentication and email verification middleware
*/
const { requireEmailVerification } = require('../../middleware/auth');
describe('Auth Middleware Tests (Phase 1.5)', () => {
let req, res, next;
beforeEach(() => {
req = {
user: null
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
});
describe('requireEmailVerification', () => {
it('should pass through if email is verified', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: true
};
await requireEmailVerification(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 if user is not attached to request', async () => {
req.user = null;
await requireEmailVerification(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Unauthorized'
})
);
expect(next).not.toHaveBeenCalled();
});
it('should return 403 if email is not verified', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: false
};
await requireEmailVerification(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Email Not Verified',
requiresVerification: true
})
);
expect(next).not.toHaveBeenCalled();
});
it('should include helpful message for unverified email', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: false
};
await requireEmailVerification(req, res, next);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('verify your email')
})
);
});
it('should handle undefined emailVerified as false', async () => {
req.user = {
id: 1,
email: 'user@example.com'
// emailVerified is undefined
};
await requireEmailVerification(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should handle errors gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
// Force an error by making req.user a getter that throws
Object.defineProperty(req, 'user', {
get: () => {
throw new Error('Test error');
}
});
await requireEmailVerification(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Internal Server Error'
})
);
consoleSpy.mockRestore();
});
it('should not call next if verification fails', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: false
};
await requireEmailVerification(req, res, next);
expect(next).not.toHaveBeenCalled();
});
it('should work with verified users multiple times', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: true
};
await requireEmailVerification(req, res, next);
await requireEmailVerification(req, res, next);
await requireEmailVerification(req, res, next);
expect(next).toHaveBeenCalledTimes(3);
});
it('should handle boolean true emailVerified', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: true
};
await requireEmailVerification(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should handle boolean false emailVerified', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: false
};
await requireEmailVerification(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should include requiresVerification flag in response', async () => {
req.user = {
id: 1,
email: 'user@example.com',
emailVerified: false
};
await requireEmailVerification(req, res, next);
const jsonCall = res.json.mock.calls[0][0];
expect(jsonCall.requiresVerification).toBe(true);
});
});
});