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:
225
backend/src/__tests__/TESTS_README.md
Normal file
225
backend/src/__tests__/TESTS_README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Unit Tests - Phase 1.5
|
||||
|
||||
This directory contains comprehensive unit tests for Phase 1.5 features: Email Verification, Password Reset, and WSDC Integration.
|
||||
|
||||
## Test Overview
|
||||
|
||||
### ✅ Test Suite Summary
|
||||
|
||||
| Test Suite | Tests | Coverage | Status |
|
||||
|------------|-------|----------|--------|
|
||||
| Auth Utils | 18 | 100% | ✅ |
|
||||
| Email Service | 22 | 100% | ✅ |
|
||||
| WSDC Controller | 13 | 100% | ✅ |
|
||||
| Auth Middleware | 11 | 42%* | ✅ |
|
||||
| **TOTAL** | **65** | - | **✅** |
|
||||
|
||||
*Note: 42% coverage is for auth.js middleware file which includes the original `authenticate` middleware (not tested here). The new `requireEmailVerification` middleware has 100% coverage.
|
||||
|
||||
## Test Files
|
||||
|
||||
### Unit Tests (No Database Required)
|
||||
|
||||
- **`utils/auth.test.js`** - Authentication utilities
|
||||
- Token generation (verification, reset)
|
||||
- PIN code generation
|
||||
- Token expiry calculation
|
||||
- Password hashing (existing)
|
||||
- JWT tokens (existing)
|
||||
|
||||
- **`utils/email.test.js`** - Email service with AWS SES mocks
|
||||
- Email sending functionality
|
||||
- Verification email (link + code)
|
||||
- Password reset email
|
||||
- Welcome email
|
||||
- Error handling
|
||||
|
||||
- **`wsdc.test.js`** - WSDC API proxy
|
||||
- Dancer lookup by ID
|
||||
- Input validation
|
||||
- Error handling
|
||||
- API integration
|
||||
|
||||
- **`middleware/auth.test.js`** - Auth middleware
|
||||
- `requireEmailVerification` function
|
||||
- Email verification checks
|
||||
- Authorization flow
|
||||
|
||||
### Integration Tests (Database Required)
|
||||
|
||||
- **`auth-phase1.5.test.js`** - Full auth flow integration
|
||||
- Registration with WSDC data
|
||||
- Email verification (token + code)
|
||||
- Resend verification
|
||||
- Password reset request
|
||||
- Password reset with token
|
||||
- Database interactions
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Unit Tests Only (Fast, No Database)
|
||||
|
||||
```bash
|
||||
# From backend directory
|
||||
npm test -- --testPathPattern="utils/|wsdc.test|middleware/"
|
||||
```
|
||||
|
||||
This runs all unit tests that don't require database connection. Perfect for quick development feedback.
|
||||
|
||||
### All Tests (Including Integration)
|
||||
|
||||
```bash
|
||||
# Start Docker containers first
|
||||
docker compose up -d
|
||||
|
||||
# Run all tests
|
||||
docker compose exec backend npm test
|
||||
|
||||
# Or from backend directory (if database is running)
|
||||
npm test
|
||||
```
|
||||
|
||||
### Watch Mode (Development)
|
||||
|
||||
```bash
|
||||
npm test -- --watch --testPathPattern="utils/email"
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
```bash
|
||||
# Generate coverage report
|
||||
npm test -- --coverage
|
||||
|
||||
# Coverage for Phase 1.5 only
|
||||
npm test -- --coverage --testPathPattern="utils/|wsdc.test|middleware/|auth-phase1.5"
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
### AWS SES
|
||||
Email tests use Jest mocks to avoid actual AWS SES calls:
|
||||
```javascript
|
||||
jest.mock('@aws-sdk/client-ses', () => ({
|
||||
SESClient: jest.fn(() => ({ send: mockSend })),
|
||||
SendEmailCommand: jest.fn((params) => params)
|
||||
}));
|
||||
```
|
||||
|
||||
### Global Fetch (WSDC API)
|
||||
WSDC tests mock the global fetch function:
|
||||
```javascript
|
||||
global.fetch = jest.fn();
|
||||
```
|
||||
|
||||
### Prisma (Integration Tests)
|
||||
Integration tests use real Prisma client but clean up test data:
|
||||
```javascript
|
||||
beforeAll(async () => {
|
||||
await prisma.user.deleteMany({});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Each test file follows this structure:
|
||||
|
||||
1. **Setup** - Mock dependencies, configure environment
|
||||
2. **BeforeEach** - Reset mocks, prepare test data
|
||||
3. **Test Suites** - Grouped by functionality
|
||||
4. **AfterEach** - Clean up test data
|
||||
5. **AfterAll** - Disconnect from database
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
When adding new features to Phase 1.5:
|
||||
|
||||
1. **Create test file** in appropriate directory
|
||||
2. **Mock external dependencies** (AWS, APIs, database)
|
||||
3. **Write descriptive test names** ("should verify email with valid token")
|
||||
4. **Test happy path first**, then edge cases
|
||||
5. **Test error handling** and validation
|
||||
6. **Aim for 100% coverage** of new code
|
||||
|
||||
## Common Test Patterns
|
||||
|
||||
### Testing Controller Endpoints
|
||||
```javascript
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
```
|
||||
|
||||
### Testing Async Functions
|
||||
```javascript
|
||||
it('should send email successfully', async () => {
|
||||
const result = await sendEmail({ /* params */ });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Error Cases
|
||||
```javascript
|
||||
it('should handle invalid input', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=invalid')
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests should be run:
|
||||
- ✅ Before committing code
|
||||
- ✅ In CI/CD pipeline
|
||||
- ✅ Before merging Pull Requests
|
||||
- ✅ After deploying to staging
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Can't reach database server"
|
||||
- **Unit tests**: Use `--testPathPattern` to run only unit tests
|
||||
- **Integration tests**: Ensure Docker containers are running
|
||||
|
||||
### "Module not found: '@aws-sdk/client-ses'"
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Tests timeout
|
||||
- Increase timeout in jest.config.js or use `--testTimeout=10000`
|
||||
- Check if database is responding slowly
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. ✅ **Mock external services** (AWS, APIs) in unit tests
|
||||
2. ✅ **Clean up test data** after each test
|
||||
3. ✅ **Use descriptive test names** that explain what's being tested
|
||||
4. ✅ **Test one thing per test** case
|
||||
5. ✅ **Don't rely on test execution order**
|
||||
6. ✅ **Test error cases** as thoroughly as happy paths
|
||||
7. ✅ **Keep tests fast** - unit tests should run in seconds
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
- **New Features**: 100% coverage
|
||||
- **Utils**: 100% coverage
|
||||
- **Controllers**: >80% coverage
|
||||
- **Overall**: >80% coverage
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Add E2E tests for full user flows
|
||||
- [ ] Add performance tests for API endpoints
|
||||
- [ ] Add security tests for authentication
|
||||
- [ ] Set up CI/CD test automation
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-13
|
||||
**Phase:** 1.5 (Email Verification & WSDC Integration)
|
||||
**Test Status:** ✅ All Passing (65/65 tests)
|
||||
512
backend/src/__tests__/auth-phase1.5.test.js
Normal file
512
backend/src/__tests__/auth-phase1.5.test.js
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Authentication API Tests - Phase 1.5
|
||||
* Tests for email verification and password reset functionality
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const { prisma } = require('../utils/db');
|
||||
const { hashPassword, generateVerificationToken, generateVerificationCode } = require('../utils/auth');
|
||||
|
||||
// Mock email service
|
||||
jest.mock('../utils/email', () => ({
|
||||
sendVerificationEmail: jest.fn().mockResolvedValue({ success: true }),
|
||||
sendPasswordResetEmail: jest.fn().mockResolvedValue({ success: true }),
|
||||
sendWelcomeEmail: jest.fn().mockResolvedValue({ success: true })
|
||||
}));
|
||||
|
||||
const emailService = require('../utils/email');
|
||||
|
||||
// Clean up database before and after tests
|
||||
beforeAll(async () => {
|
||||
await prisma.user.deleteMany({});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Authentication API Tests - Phase 1.5', () => {
|
||||
describe('POST /api/auth/register with WSDC data', () => {
|
||||
it('should register user with WSDC data', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'wsdcuser',
|
||||
email: 'wsdc@example.com',
|
||||
password: 'password123',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
wsdcId: '26997'
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.user.firstName).toBe('John');
|
||||
expect(response.body.data.user.lastName).toBe('Doe');
|
||||
expect(response.body.data.user.wsdcId).toBe('26997');
|
||||
expect(response.body.data.user.emailVerified).toBe(false);
|
||||
});
|
||||
|
||||
it('should send verification email on registration', async () => {
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'emailtest',
|
||||
email: 'emailtest@example.com',
|
||||
password: 'password123',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Smith'
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(emailService.sendVerificationEmail).toHaveBeenCalledTimes(1);
|
||||
expect(emailService.sendVerificationEmail).toHaveBeenCalledWith(
|
||||
'emailtest@example.com',
|
||||
'Alice',
|
||||
expect.any(String), // verification token
|
||||
expect.any(String) // verification code
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject duplicate WSDC ID', async () => {
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'user1',
|
||||
email: 'user1@example.com',
|
||||
password: 'password123',
|
||||
wsdcId: '12345'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'user2',
|
||||
email: 'user2@example.com',
|
||||
password: 'password123',
|
||||
wsdcId: '12345' // Same WSDC ID
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('WSDC ID');
|
||||
});
|
||||
|
||||
it('should continue registration even if email send fails', async () => {
|
||||
emailService.sendVerificationEmail.mockRejectedValueOnce(new Error('Email failed'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'emailfail',
|
||||
email: 'emailfail@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/verify-email', () => {
|
||||
let testUser;
|
||||
let verificationToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
verificationToken = generateVerificationToken();
|
||||
const verificationCode = generateVerificationCode();
|
||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h from now
|
||||
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'verifytest',
|
||||
email: 'verify@example.com',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
verificationToken,
|
||||
verificationCode,
|
||||
verificationTokenExpiry: expiry,
|
||||
emailVerified: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.user.deleteMany({ where: { email: 'verify@example.com' } });
|
||||
});
|
||||
|
||||
it('should verify email with valid token', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/auth/verify-email?token=${verificationToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toContain('verified successfully');
|
||||
|
||||
// Check database
|
||||
const user = await prisma.user.findUnique({ where: { id: testUser.id } });
|
||||
expect(user.emailVerified).toBe(true);
|
||||
expect(user.verificationToken).toBeNull();
|
||||
expect(user.verificationCode).toBeNull();
|
||||
|
||||
// Should send welcome email
|
||||
expect(emailService.sendWelcomeEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 400 if token is missing', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/verify-email')
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('token');
|
||||
});
|
||||
|
||||
it('should return 404 for invalid token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/verify-email?token=invalidtoken123')
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error).toContain('Invalid or expired');
|
||||
});
|
||||
|
||||
it('should return 400 for expired token', async () => {
|
||||
const expiredToken = generateVerificationToken();
|
||||
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: 'expireduser',
|
||||
email: 'expired@example.com',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
verificationToken: expiredToken,
|
||||
verificationCode: '123456',
|
||||
verificationTokenExpiry: pastExpiry,
|
||||
emailVerified: false
|
||||
}
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/auth/verify-email?token=${expiredToken}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('expired');
|
||||
|
||||
await prisma.user.deleteMany({ where: { email: 'expired@example.com' } });
|
||||
});
|
||||
|
||||
it('should handle already verified email', async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: testUser.id },
|
||||
data: { emailVerified: true }
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/auth/verify-email?token=${verificationToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.message).toContain('already verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/verify-code', () => {
|
||||
let testUser;
|
||||
let verificationCode;
|
||||
|
||||
beforeEach(async () => {
|
||||
verificationCode = generateVerificationCode();
|
||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'codetest',
|
||||
email: 'code@example.com',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
verificationToken: generateVerificationToken(),
|
||||
verificationCode,
|
||||
verificationTokenExpiry: expiry,
|
||||
emailVerified: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.user.deleteMany({ where: { email: 'code@example.com' } });
|
||||
});
|
||||
|
||||
it('should verify email with valid code', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/verify-code')
|
||||
.send({
|
||||
email: 'code@example.com',
|
||||
code: verificationCode
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
// Check database
|
||||
const user = await prisma.user.findUnique({ where: { id: testUser.id } });
|
||||
expect(user.emailVerified).toBe(true);
|
||||
expect(emailService.sendWelcomeEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid code', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/verify-code')
|
||||
.send({
|
||||
email: 'code@example.com',
|
||||
code: '999999'
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('Invalid');
|
||||
});
|
||||
|
||||
it('should require both email and code', async () => {
|
||||
const response1 = await request(app)
|
||||
.post('/api/auth/verify-code')
|
||||
.send({ email: 'code@example.com' })
|
||||
.expect(400);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/api/auth/verify-code')
|
||||
.send({ code: '123456' })
|
||||
.expect(400);
|
||||
|
||||
expect(response1.body.error).toBeDefined();
|
||||
expect(response2.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/resend-verification', () => {
|
||||
let testUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'resendtest',
|
||||
email: 'resend@example.com',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
emailVerified: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.user.deleteMany({ where: { email: 'resend@example.com' } });
|
||||
});
|
||||
|
||||
it('should resend verification email', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/resend-verification')
|
||||
.send({ email: 'resend@example.com' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(emailService.sendVerificationEmail).toHaveBeenCalled();
|
||||
|
||||
// Check that new tokens were generated
|
||||
const user = await prisma.user.findUnique({ where: { id: testUser.id } });
|
||||
expect(user.verificationToken).toBeDefined();
|
||||
expect(user.verificationCode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 400 for already verified email', async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: testUser.id },
|
||||
data: { emailVerified: true }
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/resend-verification')
|
||||
.send({ email: 'resend@example.com' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('already verified');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/resend-verification')
|
||||
.send({ email: 'nonexistent@example.com' })
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/request-password-reset', () => {
|
||||
let testUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'resettest',
|
||||
email: 'reset@example.com',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
firstName: 'Reset',
|
||||
emailVerified: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.user.deleteMany({ where: { email: 'reset@example.com' } });
|
||||
});
|
||||
|
||||
it('should send reset email for existing user', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/request-password-reset')
|
||||
.send({ email: 'reset@example.com' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(emailService.sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||
'reset@example.com',
|
||||
'Reset',
|
||||
expect.any(String)
|
||||
);
|
||||
|
||||
// Check database has reset token
|
||||
const user = await prisma.user.findUnique({ where: { id: testUser.id } });
|
||||
expect(user.resetToken).toBeDefined();
|
||||
expect(user.resetTokenExpiry).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return success even for non-existent user (security)', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/request-password-reset')
|
||||
.send({ email: 'nonexistent@example.com' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require email', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/request-password-reset')
|
||||
.send({})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('Email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/reset-password', () => {
|
||||
let testUser;
|
||||
let resetToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetToken = generateVerificationToken();
|
||||
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1h from now
|
||||
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'newpasstest',
|
||||
email: 'newpass@example.com',
|
||||
passwordHash: await hashPassword('oldpassword123'),
|
||||
resetToken,
|
||||
resetTokenExpiry: expiry,
|
||||
emailVerified: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.user.deleteMany({ where: { email: 'newpass@example.com' } });
|
||||
});
|
||||
|
||||
it('should reset password with valid token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({
|
||||
token: resetToken,
|
||||
newPassword: 'newpassword123'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
// Check database
|
||||
const user = await prisma.user.findUnique({ where: { id: testUser.id } });
|
||||
expect(user.resetToken).toBeNull();
|
||||
expect(user.resetTokenExpiry).toBeNull();
|
||||
|
||||
// Password should be changed (test by comparing hashes are different)
|
||||
expect(user.passwordHash).not.toBe(testUser.passwordHash);
|
||||
});
|
||||
|
||||
it('should reject short password', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({
|
||||
token: resetToken,
|
||||
newPassword: 'short'
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('8 characters');
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({
|
||||
token: 'invalidtoken',
|
||||
newPassword: 'newpassword123'
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('Invalid or expired');
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
const expiredToken = generateVerificationToken();
|
||||
const pastExpiry = new Date(Date.now() - 1000);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: 'expiredreset',
|
||||
email: 'expiredreset@example.com',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
resetToken: expiredToken,
|
||||
resetTokenExpiry: pastExpiry,
|
||||
emailVerified: true
|
||||
}
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({
|
||||
token: expiredToken,
|
||||
newPassword: 'newpassword123'
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('expired');
|
||||
|
||||
await prisma.user.deleteMany({ where: { email: 'expiredreset@example.com' } });
|
||||
});
|
||||
|
||||
it('should require both token and new password', async () => {
|
||||
const response1 = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({ token: resetToken })
|
||||
.expect(400);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({ newPassword: 'newpassword123' })
|
||||
.expect(400);
|
||||
|
||||
expect(response1.body.error).toBeDefined();
|
||||
expect(response2.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
backend/src/__tests__/middleware/auth.test.js
Normal file
188
backend/src/__tests__/middleware/auth.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
const { hashPassword, comparePassword, generateToken, verifyToken } = require('../../utils/auth');
|
||||
const {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
verifyToken,
|
||||
generateVerificationToken,
|
||||
generateVerificationCode,
|
||||
getTokenExpiry
|
||||
} = require('../../utils/auth');
|
||||
|
||||
// Set up test environment variables
|
||||
beforeAll(() => {
|
||||
@@ -96,4 +104,99 @@ describe('Auth Utils Tests', () => {
|
||||
expect(Math.abs(decoded.exp - expectedExpiration)).toBeLessThan(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateVerificationToken (Phase 1.5)', () => {
|
||||
it('should generate a random verification token', () => {
|
||||
const token = generateVerificationToken();
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBe(64); // 32 bytes * 2 (hex)
|
||||
});
|
||||
|
||||
it('should generate unique tokens', () => {
|
||||
const token1 = generateVerificationToken();
|
||||
const token2 = generateVerificationToken();
|
||||
const token3 = generateVerificationToken();
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token2).not.toBe(token3);
|
||||
expect(token1).not.toBe(token3);
|
||||
});
|
||||
|
||||
it('should generate URL-safe tokens (hex)', () => {
|
||||
const token = generateVerificationToken();
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateVerificationCode (Phase 1.5)', () => {
|
||||
it('should generate a 6-digit code', () => {
|
||||
const code = generateVerificationCode();
|
||||
|
||||
expect(code).toBeDefined();
|
||||
expect(typeof code).toBe('string');
|
||||
expect(code.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should generate numeric codes', () => {
|
||||
const code = generateVerificationCode();
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
});
|
||||
|
||||
it('should generate codes in valid range', () => {
|
||||
const code = generateVerificationCode();
|
||||
const numCode = parseInt(code, 10);
|
||||
|
||||
expect(numCode).toBeGreaterThanOrEqual(100000);
|
||||
expect(numCode).toBeLessThanOrEqual(999999);
|
||||
});
|
||||
|
||||
it('should generate different codes', () => {
|
||||
const codes = new Set();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
codes.add(generateVerificationCode());
|
||||
}
|
||||
// Should have at least 90 unique codes out of 100
|
||||
expect(codes.size).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenExpiry (Phase 1.5)', () => {
|
||||
it('should generate expiry date with default 24 hours', () => {
|
||||
const now = new Date();
|
||||
const expiry = getTokenExpiry();
|
||||
|
||||
expect(expiry).toBeInstanceOf(Date);
|
||||
expect(expiry.getTime()).toBeGreaterThan(now.getTime());
|
||||
|
||||
// Should be approximately 24 hours from now (allowing 1 second tolerance)
|
||||
const expectedTime = now.getTime() + (24 * 60 * 60 * 1000);
|
||||
expect(Math.abs(expiry.getTime() - expectedTime)).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should generate expiry date with custom hours', () => {
|
||||
const now = new Date();
|
||||
const expiry = getTokenExpiry(1); // 1 hour
|
||||
|
||||
expect(expiry).toBeInstanceOf(Date);
|
||||
expect(expiry.getTime()).toBeGreaterThan(now.getTime());
|
||||
|
||||
// Should be approximately 1 hour from now
|
||||
const expectedTime = now.getTime() + (1 * 60 * 60 * 1000);
|
||||
expect(Math.abs(expiry.getTime() - expectedTime)).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should work with various hour values', () => {
|
||||
const now = new Date();
|
||||
|
||||
const expiry12h = getTokenExpiry(12);
|
||||
const expiry48h = getTokenExpiry(48);
|
||||
const expiry1h = getTokenExpiry(1);
|
||||
|
||||
expect(expiry1h.getTime() - now.getTime()).toBeCloseTo(1 * 60 * 60 * 1000, -3);
|
||||
expect(expiry12h.getTime() - now.getTime()).toBeCloseTo(12 * 60 * 60 * 1000, -3);
|
||||
expect(expiry48h.getTime() - now.getTime()).toBeCloseTo(48 * 60 * 60 * 1000, -3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
345
backend/src/__tests__/utils/email.test.js
Normal file
345
backend/src/__tests__/utils/email.test.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Email Service Tests (Phase 1.5)
|
||||
* Tests for AWS SES email functionality
|
||||
*/
|
||||
|
||||
// Mock AWS SES before requiring the module
|
||||
const mockSend = jest.fn();
|
||||
jest.mock('@aws-sdk/client-ses', () => ({
|
||||
SESClient: jest.fn(() => ({
|
||||
send: mockSend
|
||||
})),
|
||||
SendEmailCommand: jest.fn((params) => params)
|
||||
}));
|
||||
|
||||
const {
|
||||
sendEmail,
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendWelcomeEmail
|
||||
} = require('../../utils/email');
|
||||
|
||||
// Set up test environment variables
|
||||
beforeAll(() => {
|
||||
process.env.AWS_REGION = 'us-east-1';
|
||||
process.env.AWS_ACCESS_KEY_ID = 'test-key';
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret';
|
||||
process.env.SES_FROM_EMAIL = 'test@example.com';
|
||||
process.env.SES_FROM_NAME = 'Test App';
|
||||
process.env.FRONTEND_URL = 'http://localhost:8080';
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSend.mockClear();
|
||||
// Default successful response
|
||||
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
||||
});
|
||||
|
||||
describe('Email Service Tests (Phase 1.5)', () => {
|
||||
describe('sendEmail', () => {
|
||||
it('should send email successfully', async () => {
|
||||
const result = await sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Test Subject',
|
||||
htmlBody: '<p>Test HTML</p>',
|
||||
textBody: 'Test Text'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe('test-message-id');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use correct sender information', async () => {
|
||||
await sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
textBody: 'Test'
|
||||
});
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
expect(sendCommand.Source).toBe('Test App <test@example.com>');
|
||||
});
|
||||
|
||||
it('should include recipient email', async () => {
|
||||
await sendEmail({
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
textBody: 'Test'
|
||||
});
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
expect(sendCommand.Destination.ToAddresses).toEqual(['recipient@example.com']);
|
||||
});
|
||||
|
||||
it('should handle email send failure', async () => {
|
||||
mockSend.mockRejectedValue(new Error('SES Error'));
|
||||
|
||||
await expect(sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
textBody: 'Test'
|
||||
})).rejects.toThrow('Failed to send email');
|
||||
});
|
||||
|
||||
it('should include both HTML and text bodies', async () => {
|
||||
const htmlBody = '<h1>HTML Content</h1>';
|
||||
const textBody = 'Text Content';
|
||||
|
||||
await sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody,
|
||||
textBody
|
||||
});
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
expect(sendCommand.Message.Body.Html.Data).toBe(htmlBody);
|
||||
expect(sendCommand.Message.Body.Text.Data).toBe(textBody);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendVerificationEmail', () => {
|
||||
it('should send verification email with token and code', async () => {
|
||||
const result = await sendVerificationEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'test-token-123',
|
||||
'123456'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
expect(sendCommand.Message.Subject.Data).toContain('Verify your spotlight.cam email');
|
||||
});
|
||||
|
||||
it('should include verification link in email', async () => {
|
||||
await sendVerificationEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'test-token-123',
|
||||
'123456'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('http://localhost:8080/verify-email?token=test-token-123');
|
||||
});
|
||||
|
||||
it('should include verification code in email', async () => {
|
||||
await sendVerificationEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'test-token-123',
|
||||
'654321'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('654321');
|
||||
});
|
||||
|
||||
it('should personalize with user first name', async () => {
|
||||
await sendVerificationEmail(
|
||||
'user@example.com',
|
||||
'Alice',
|
||||
'token',
|
||||
'123456'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('Alice');
|
||||
});
|
||||
|
||||
it('should include plain text version', async () => {
|
||||
await sendVerificationEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'test-token-123',
|
||||
'123456'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const textBody = sendCommand.Message.Body.Text.Data;
|
||||
|
||||
expect(textBody).toContain('test-token-123');
|
||||
expect(textBody).toContain('123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPasswordResetEmail', () => {
|
||||
it('should send password reset email', async () => {
|
||||
const result = await sendPasswordResetEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'reset-token-123'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
expect(sendCommand.Message.Subject.Data).toContain('Reset your spotlight.cam password');
|
||||
});
|
||||
|
||||
it('should include reset link in email', async () => {
|
||||
await sendPasswordResetEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'reset-token-abc'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('http://localhost:8080/reset-password?token=reset-token-abc');
|
||||
});
|
||||
|
||||
it('should personalize with user first name', async () => {
|
||||
await sendPasswordResetEmail(
|
||||
'user@example.com',
|
||||
'Bob',
|
||||
'token'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('Bob');
|
||||
});
|
||||
|
||||
it('should include security warning', async () => {
|
||||
await sendPasswordResetEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'token'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody.toLowerCase()).toContain('security');
|
||||
});
|
||||
|
||||
it('should include plain text version', async () => {
|
||||
await sendPasswordResetEmail(
|
||||
'user@example.com',
|
||||
'John',
|
||||
'reset-token-123'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const textBody = sendCommand.Message.Body.Text.Data;
|
||||
|
||||
expect(textBody).toContain('reset-token-123');
|
||||
expect(textBody).toContain('http://localhost:8080/reset-password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWelcomeEmail', () => {
|
||||
it('should send welcome email', async () => {
|
||||
const result = await sendWelcomeEmail(
|
||||
'user@example.com',
|
||||
'John'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
expect(sendCommand.Message.Subject.Data).toContain('Welcome to spotlight.cam');
|
||||
});
|
||||
|
||||
it('should personalize with user first name', async () => {
|
||||
await sendWelcomeEmail(
|
||||
'user@example.com',
|
||||
'Charlie'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('Charlie');
|
||||
});
|
||||
|
||||
it('should include features information', async () => {
|
||||
await sendWelcomeEmail(
|
||||
'user@example.com',
|
||||
'John'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
// Should mention key features
|
||||
expect(htmlBody.toLowerCase()).toContain('event');
|
||||
expect(htmlBody.toLowerCase()).toContain('video');
|
||||
});
|
||||
|
||||
it('should include call-to-action link', async () => {
|
||||
await sendWelcomeEmail(
|
||||
'user@example.com',
|
||||
'John'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const htmlBody = sendCommand.Message.Body.Html.Data;
|
||||
|
||||
expect(htmlBody).toContain('http://localhost:8080/events');
|
||||
});
|
||||
|
||||
it('should include plain text version', async () => {
|
||||
await sendWelcomeEmail(
|
||||
'user@example.com',
|
||||
'John'
|
||||
);
|
||||
|
||||
const sendCommand = mockSend.mock.calls[0][0];
|
||||
const textBody = sendCommand.Message.Body.Text.Data;
|
||||
|
||||
expect(textBody).toContain('verified'); // "Your email has been verified!"
|
||||
expect(textBody).toContain('http://localhost:8080/events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle AWS SES errors gracefully', async () => {
|
||||
mockSend.mockRejectedValue(new Error('AWS SES unavailable'));
|
||||
|
||||
await expect(sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
textBody: 'Test'
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should log errors', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockSend.mockRejectedValue(new Error('Test error'));
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
textBody: 'Test'
|
||||
});
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
212
backend/src/__tests__/wsdc.test.js
Normal file
212
backend/src/__tests__/wsdc.test.js
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* WSDC Controller Tests (Phase 1.5)
|
||||
* Tests for WSDC API proxy functionality
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('WSDC Controller Tests (Phase 1.5)', () => {
|
||||
beforeEach(() => {
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
describe('GET /api/wsdc/lookup', () => {
|
||||
it('should lookup dancer by WSDC ID successfully', async () => {
|
||||
const mockDancerData = {
|
||||
dancer_wsdcid: 26997,
|
||||
dancer_first: 'Radoslaw',
|
||||
dancer_last: 'Gierwialo',
|
||||
recent_year: 2025
|
||||
};
|
||||
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockDancerData
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.dancer).toMatchObject({
|
||||
wsdcId: 26997,
|
||||
firstName: 'Radoslaw',
|
||||
lastName: 'Gierwialo'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if WSDC ID is missing', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Bad Request');
|
||||
expect(response.body.message).toContain('WSDC ID is required');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid WSDC ID format', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=abc123');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Bad Request');
|
||||
expect(response.body.message).toContain('Invalid WSDC ID format');
|
||||
});
|
||||
|
||||
it('should return 400 for WSDC ID too long', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=12345678901'); // 11 digits
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Bad Request');
|
||||
});
|
||||
|
||||
it('should return 404 if dancer not found', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}) // Empty response
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=99999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Not Found');
|
||||
expect(response.body.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return 502 if WSDC API fails', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body.error).toBe('Bad Gateway');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
fetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
it('should call WSDC API with correct URL', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
dancer_wsdcid: 26997,
|
||||
dancer_first: 'Test',
|
||||
dancer_last: 'User'
|
||||
})
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://points.worldsdc.com/lookup2020/find?q=26997'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate numeric WSDC IDs only', async () => {
|
||||
const invalidIds = ['abc', '123abc', 'test', '!@#$'];
|
||||
|
||||
for (const id of invalidIds) {
|
||||
const response = await request(app)
|
||||
.get(`/api/wsdc/lookup?id=${id}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid numeric WSDC IDs', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
dancer_wsdcid: 123,
|
||||
dancer_first: 'Test',
|
||||
dancer_last: 'User'
|
||||
})
|
||||
});
|
||||
|
||||
const validIds = ['1', '123', '12345', '1234567890'];
|
||||
|
||||
for (const id of validIds) {
|
||||
const response = await request(app)
|
||||
.get(`/api/wsdc/lookup?id=${id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include optional fields if available', async () => {
|
||||
const mockDancerData = {
|
||||
dancer_wsdcid: 26997,
|
||||
dancer_first: 'Radoslaw',
|
||||
dancer_last: 'Gierwialo',
|
||||
recent_year: 2025,
|
||||
dominate_data: {
|
||||
short_dominate_role: 'Leader'
|
||||
}
|
||||
};
|
||||
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockDancerData
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.dancer.recentYear).toBe(2025);
|
||||
expect(response.body.dancer.dominateRole).toBe('Leader');
|
||||
});
|
||||
|
||||
it('should handle missing optional fields gracefully', async () => {
|
||||
const mockDancerData = {
|
||||
dancer_wsdcid: 26997,
|
||||
dancer_first: 'Test',
|
||||
dancer_last: 'User'
|
||||
// No recent_year or dominate_data
|
||||
};
|
||||
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockDancerData
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.dancer.dominateRole).toBeNull();
|
||||
});
|
||||
|
||||
it('should log errors for debugging', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
fetch.mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await request(app)
|
||||
.get('/api/wsdc/lookup?id=26997');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/users', require('./routes/users'));
|
||||
app.use('/api/events', require('./routes/events'));
|
||||
app.use('/api/wsdc', require('./routes/wsdc'));
|
||||
// app.use('/api/matches', require('./routes/matches'));
|
||||
// app.use('/api/ratings', require('./routes/ratings'));
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
const { prisma } = require('../utils/db');
|
||||
const { hashPassword, comparePassword, generateToken } = require('../utils/auth');
|
||||
const {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
generateVerificationToken,
|
||||
generateVerificationCode,
|
||||
getTokenExpiry
|
||||
} = require('../utils/auth');
|
||||
const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email');
|
||||
|
||||
// Register new user
|
||||
// Register new user (Phase 1.5 - with WSDC support and email verification)
|
||||
async function register(req, res, next) {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
const { username, email, password, firstName, lastName, wsdcId } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
@@ -12,6 +20,7 @@ async function register(req, res, next) {
|
||||
OR: [
|
||||
{ email },
|
||||
{ username },
|
||||
...(wsdcId ? [{ wsdcId }] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -23,38 +32,80 @@ async function register(req, res, next) {
|
||||
error: 'Email already registered',
|
||||
});
|
||||
}
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username already taken',
|
||||
});
|
||||
if (existingUser.username === username) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username already taken',
|
||||
});
|
||||
}
|
||||
if (wsdcId && existingUser.wsdcId === wsdcId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'WSDC ID already registered',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Generate verification token and code
|
||||
const verificationToken = generateVerificationToken();
|
||||
const verificationCode = generateVerificationCode();
|
||||
const verificationTokenExpiry = getTokenExpiry(24); // 24 hours
|
||||
|
||||
// Create display name for avatar
|
||||
const displayName = firstName && lastName
|
||||
? `${firstName} ${lastName}`
|
||||
: username;
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=6366f1&color=fff`,
|
||||
firstName,
|
||||
lastName,
|
||||
wsdcId,
|
||||
verificationToken,
|
||||
verificationCode,
|
||||
verificationTokenExpiry,
|
||||
emailVerified: false,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&background=6366f1&color=fff`,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
wsdcId: true,
|
||||
emailVerified: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate token
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(
|
||||
user.email,
|
||||
user.firstName || user.username,
|
||||
verificationToken,
|
||||
verificationCode
|
||||
);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
// Continue even if email fails - user can request resend
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({ userId: user.id });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User registered successfully',
|
||||
message: 'User registered successfully. Please check your email to verify your account.',
|
||||
data: {
|
||||
user,
|
||||
token,
|
||||
@@ -111,7 +162,337 @@ async function login(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify email by token (link in email)
|
||||
async function verifyEmailByToken(req, res, next) {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification token is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by verification token
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { verificationToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired verification token',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email already verified',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token expired
|
||||
if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification token has expired. Please request a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
// Update user - mark as verified and clear tokens
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
verificationToken: null,
|
||||
verificationCode: null,
|
||||
verificationTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
await sendWelcomeEmail(user.email, user.firstName || user.username);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email verified successfully!',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify email by code (6-digit PIN)
|
||||
async function verifyEmailByCode(req, res, next) {
|
||||
try {
|
||||
const { code, email } = req.body;
|
||||
|
||||
if (!code || !email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email and verification code are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email and code
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
verificationCode: code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid verification code',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email already verified',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token expired
|
||||
if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification code has expired. Please request a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
// Update user - mark as verified and clear tokens
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
verificationToken: null,
|
||||
verificationCode: null,
|
||||
verificationTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
await sendWelcomeEmail(user.email, user.firstName || user.username);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email verified successfully!',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Resend verification email
|
||||
async function resendVerification(req, res, next) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email is already verified',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new verification token and code
|
||||
const verificationToken = generateVerificationToken();
|
||||
const verificationCode = generateVerificationCode();
|
||||
const verificationTokenExpiry = getTokenExpiry(24); // 24 hours
|
||||
|
||||
// Update user with new tokens
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
verificationToken,
|
||||
verificationCode,
|
||||
verificationTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
await sendVerificationEmail(
|
||||
user.email,
|
||||
user.firstName || user.username,
|
||||
verificationToken,
|
||||
verificationCode
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Verification email sent successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Request password reset (send email with reset link)
|
||||
async function requestPasswordReset(req, res, next) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = generateVerificationToken();
|
||||
const resetTokenExpiry = getTokenExpiry(1); // 1 hour expiry
|
||||
|
||||
// Save reset token to database
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken,
|
||||
resetTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Send password reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(
|
||||
user.email,
|
||||
user.firstName || user.username,
|
||||
resetToken
|
||||
);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send password reset email:', emailError);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send password reset email',
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset password using token
|
||||
async function resetPassword(req, res, next) {
|
||||
try {
|
||||
const { token, newPassword } = req.body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token and new password are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Password must be at least 8 characters long',
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by reset token
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { resetToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired reset token',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token expired
|
||||
if (user.resetTokenExpiry && new Date() > user.resetTokenExpiry) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Reset token has expired. Please request a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
// Update user password and clear reset token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Password reset successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
verifyEmailByToken,
|
||||
verifyEmailByCode,
|
||||
resendVerification,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
};
|
||||
|
||||
76
backend/src/controllers/wsdc.js
Normal file
76
backend/src/controllers/wsdc.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* WSDC API Controller
|
||||
* Provides proxy endpoint for World Swing Dance Council (WSDC) dancer lookup
|
||||
*/
|
||||
|
||||
const WSDC_API_BASE = 'https://points.worldsdc.com/lookup2020/find';
|
||||
|
||||
/**
|
||||
* Lookup dancer by WSDC ID
|
||||
* GET /api/wsdc/lookup?id=26997
|
||||
*/
|
||||
exports.lookupDancer = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.query;
|
||||
|
||||
// Validate WSDC ID
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'WSDC ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate WSDC ID format (numeric, max 10 digits)
|
||||
if (!/^\d{1,10}$/.test(id)) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid WSDC ID format. Must be numeric.'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch from WSDC API
|
||||
const url = `${WSDC_API_BASE}?q=${id}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`WSDC API error: ${response.status} ${response.statusText}`);
|
||||
return res.status(502).json({
|
||||
error: 'Bad Gateway',
|
||||
message: 'Failed to fetch data from WSDC API'
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if dancer was found
|
||||
if (!data || !data.dancer_wsdcid) {
|
||||
return res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'Dancer with this WSDC ID not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Extract relevant fields
|
||||
const dancerData = {
|
||||
wsdcId: data.dancer_wsdcid,
|
||||
firstName: data.dancer_first || '',
|
||||
lastName: data.dancer_last || '',
|
||||
// Optional: include competitive level info if needed
|
||||
recentYear: data.recent_year,
|
||||
dominateRole: data.dominate_data?.short_dominate_role || null
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
dancer: dancerData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in lookupDancer:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An error occurred while looking up dancer'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -35,6 +35,10 @@ async function authenticate(req, res, next) {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
wsdcId: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -61,4 +65,37 @@ async function authenticate(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { authenticate };
|
||||
// Middleware to check if email is verified (Phase 1.5)
|
||||
// Use this after authenticate middleware on routes that require verified email
|
||||
async function requireEmailVerification(req, res, next) {
|
||||
try {
|
||||
// User should be attached by authenticate middleware
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
if (!req.user.emailVerified) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Email Not Verified',
|
||||
message: 'Please verify your email address to access this feature',
|
||||
requiresVerification: true,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Email verification middleware error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { authenticate, requireEmailVerification };
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
const express = require('express');
|
||||
const { register, login } = require('../controllers/auth');
|
||||
const {
|
||||
register,
|
||||
login,
|
||||
verifyEmailByToken,
|
||||
verifyEmailByCode,
|
||||
resendVerification,
|
||||
requestPasswordReset,
|
||||
resetPassword
|
||||
} = require('../controllers/auth');
|
||||
const { registerValidation, loginValidation } = require('../middleware/validators');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -10,4 +18,19 @@ router.post('/register', registerValidation, register);
|
||||
// POST /api/auth/login - Login user
|
||||
router.post('/login', loginValidation, login);
|
||||
|
||||
// GET /api/auth/verify-email?token=xxx - Verify email by token (link)
|
||||
router.get('/verify-email', verifyEmailByToken);
|
||||
|
||||
// POST /api/auth/verify-code - Verify email by code (PIN)
|
||||
router.post('/verify-code', verifyEmailByCode);
|
||||
|
||||
// POST /api/auth/resend-verification - Resend verification email
|
||||
router.post('/resend-verification', resendVerification);
|
||||
|
||||
// POST /api/auth/request-password-reset - Request password reset
|
||||
router.post('/request-password-reset', requestPasswordReset);
|
||||
|
||||
// POST /api/auth/reset-password - Reset password with token
|
||||
router.post('/reset-password', resetPassword);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
16
backend/src/routes/wsdc.js
Normal file
16
backend/src/routes/wsdc.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* WSDC API Routes
|
||||
* Endpoints for World Swing Dance Council dancer lookup
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const wsdcController = require('../controllers/wsdc');
|
||||
|
||||
/**
|
||||
* GET /api/wsdc/lookup?id=26997
|
||||
* Lookup dancer by WSDC ID
|
||||
*/
|
||||
router.get('/lookup', wsdcController.lookupDancer);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,5 +1,6 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Hash password with bcrypt
|
||||
async function hashPassword(password) {
|
||||
@@ -28,9 +29,28 @@ function verifyToken(token) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate random verification token (URL-safe)
|
||||
function generateVerificationToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Generate 6-digit verification code
|
||||
function generateVerificationCode() {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
// Calculate token expiry time
|
||||
function getTokenExpiry(hours = 24) {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
verifyToken,
|
||||
generateVerificationToken,
|
||||
generateVerificationCode,
|
||||
getTokenExpiry,
|
||||
};
|
||||
|
||||
320
backend/src/utils/email.js
Normal file
320
backend/src/utils/email.js
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Email Service using AWS SES
|
||||
* Handles sending emails for verification, password reset, etc.
|
||||
*/
|
||||
|
||||
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
|
||||
|
||||
// Configure AWS SES Client
|
||||
const sesClient = new SESClient({
|
||||
region: process.env.AWS_REGION || 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Send email via AWS SES
|
||||
* @param {Object} params - Email parameters
|
||||
* @param {string} params.to - Recipient email address
|
||||
* @param {string} params.subject - Email subject
|
||||
* @param {string} params.htmlBody - HTML email body
|
||||
* @param {string} params.textBody - Plain text email body (fallback)
|
||||
* @returns {Promise<Object>} - SES response
|
||||
*/
|
||||
async function sendEmail({ to, subject, htmlBody, textBody }) {
|
||||
const params = {
|
||||
Source: `${process.env.SES_FROM_NAME} <${process.env.SES_FROM_EMAIL}>`,
|
||||
Destination: {
|
||||
ToAddresses: [to],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: subject,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Html: {
|
||||
Data: htmlBody,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Text: {
|
||||
Data: textBody,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const command = new SendEmailCommand(params);
|
||||
const response = await sesClient.send(command);
|
||||
console.log(`Email sent successfully to ${to}. MessageId: ${response.MessageId}`);
|
||||
return { success: true, messageId: response.MessageId };
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
throw new Error(`Failed to send email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification email with link and PIN code
|
||||
* @param {string} email - User email
|
||||
* @param {string} firstName - User first name
|
||||
* @param {string} verificationToken - Unique verification token
|
||||
* @param {string} verificationCode - 6-digit PIN code
|
||||
*/
|
||||
async function sendVerificationEmail(email, firstName, verificationToken, verificationCode) {
|
||||
const verificationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`;
|
||||
|
||||
const subject = 'Verify your spotlight.cam email';
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.code-box { background: white; border: 2px solid #667eea; border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0; }
|
||||
.code { font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #667eea; font-family: monospace; }
|
||||
.button { display: inline-block; background: #667eea; color: white; padding: 14px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: 600; }
|
||||
.footer { text-align: center; color: #666; font-size: 14px; margin-top: 30px; }
|
||||
.divider { border-top: 1px solid #e5e7eb; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎥 spotlight.cam</h1>
|
||||
<p>Welcome to the dance community!</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Hi ${firstName || 'there'}! 👋</h2>
|
||||
<p>Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address.</p>
|
||||
|
||||
<h3>Option 1: Click the button</h3>
|
||||
<a href="${verificationLink}" class="button">Verify Email Address</a>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h3>Option 2: Enter this code</h3>
|
||||
<div class="code-box">
|
||||
<div class="code">${verificationCode}</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">This code will expire in 24 hours.</p>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p><strong>Didn't create an account?</strong> You can safely ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>spotlight.cam - P2P video exchange for dance events</p>
|
||||
<p>This is an automated email. Please do not reply.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textBody = `
|
||||
Hi ${firstName || 'there'}!
|
||||
|
||||
Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address.
|
||||
|
||||
Option 1: Click this link to verify
|
||||
${verificationLink}
|
||||
|
||||
Option 2: Enter this verification code
|
||||
${verificationCode}
|
||||
|
||||
This code will expire in 24 hours.
|
||||
|
||||
Didn't create an account? You can safely ignore this email.
|
||||
|
||||
---
|
||||
spotlight.cam - P2P video exchange for dance events
|
||||
This is an automated email. Please do not reply.
|
||||
`;
|
||||
|
||||
return sendEmail({ to: email, subject, htmlBody, textBody });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email with link and code
|
||||
* @param {string} email - User email
|
||||
* @param {string} firstName - User first name
|
||||
* @param {string} resetToken - Unique reset token
|
||||
*/
|
||||
async function sendPasswordResetEmail(email, firstName, resetToken) {
|
||||
const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
|
||||
|
||||
const subject = 'Reset your spotlight.cam password';
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.button { display: inline-block; background: #667eea; color: white; padding: 14px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: 600; }
|
||||
.footer { text-align: center; color: #666; font-size: 14px; margin-top: 30px; }
|
||||
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 Password Reset</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Hi ${firstName || 'there'}! 👋</h2>
|
||||
<p>We received a request to reset your password for your spotlight.cam account.</p>
|
||||
|
||||
<a href="${resetLink}" class="button">Reset Password</a>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">This link will expire in 1 hour.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Security Notice</strong><br>
|
||||
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>spotlight.cam - P2P video exchange for dance events</p>
|
||||
<p>This is an automated email. Please do not reply.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textBody = `
|
||||
Hi ${firstName || 'there'}!
|
||||
|
||||
We received a request to reset your password for your spotlight.cam account.
|
||||
|
||||
Click this link to reset your password:
|
||||
${resetLink}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
⚠️ Security Notice
|
||||
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
||||
|
||||
---
|
||||
spotlight.cam - P2P video exchange for dance events
|
||||
This is an automated email. Please do not reply.
|
||||
`;
|
||||
|
||||
return sendEmail({ to: email, subject, htmlBody, textBody });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email after successful verification
|
||||
* @param {string} email - User email
|
||||
* @param {string} firstName - User first name
|
||||
*/
|
||||
async function sendWelcomeEmail(email, firstName) {
|
||||
const subject = 'Welcome to spotlight.cam! 🎉';
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.button { display: inline-block; background: #667eea; color: white; padding: 14px 30px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: 600; }
|
||||
.feature { background: white; padding: 15px; margin: 10px 0; border-radius: 6px; border-left: 4px solid #667eea; }
|
||||
.footer { text-align: center; color: #666; font-size: 14px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎉 Welcome to spotlight.cam!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Hi ${firstName || 'there'}! 👋</h2>
|
||||
<p>Your email has been verified! You're all set to start using spotlight.cam.</p>
|
||||
|
||||
<h3>What you can do now:</h3>
|
||||
|
||||
<div class="feature">
|
||||
<strong>🎪 Join Events</strong><br>
|
||||
Browse upcoming dance events and join event chat rooms to meet other dancers.
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<strong>💬 Match & Chat</strong><br>
|
||||
Connect with event participants for video collaborations.
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<strong>🎥 Share Videos P2P</strong><br>
|
||||
Exchange dance videos directly with your partners using WebRTC - no server uploads!
|
||||
</div>
|
||||
|
||||
<a href="${process.env.FRONTEND_URL}/events" class="button">Explore Events</a>
|
||||
|
||||
<p>Happy dancing! 💃🕺</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>spotlight.cam - P2P video exchange for dance events</p>
|
||||
<p>Questions? Check out our FAQ or contact support.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textBody = `
|
||||
Hi ${firstName || 'there'}!
|
||||
|
||||
Your email has been verified! You're all set to start using spotlight.cam.
|
||||
|
||||
What you can do now:
|
||||
|
||||
🎪 Join Events
|
||||
Browse upcoming dance events and join event chat rooms to meet other dancers.
|
||||
|
||||
💬 Match & Chat
|
||||
Connect with event participants for video collaborations.
|
||||
|
||||
🎥 Share Videos P2P
|
||||
Exchange dance videos directly with your partners using WebRTC - no server uploads!
|
||||
|
||||
Visit: ${process.env.FRONTEND_URL}/events
|
||||
|
||||
Happy dancing! 💃🕺
|
||||
|
||||
---
|
||||
spotlight.cam - P2P video exchange for dance events
|
||||
Questions? Check out our FAQ or contact support.
|
||||
`;
|
||||
|
||||
return sendEmail({ to: email, subject, htmlBody, textBody });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendEmail,
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendWelcomeEmail,
|
||||
};
|
||||
Reference in New Issue
Block a user