moved private information, test fixes

This commit is contained in:
jackiettran
2025-11-06 17:56:12 -05:00
parent 2ee4b5c389
commit 066ad4a3fe
6 changed files with 263 additions and 165 deletions

View File

@@ -1,3 +1,27 @@
// Mock logger module first to prevent winston initialization issues
const mockLoggerWarn = jest.fn();
const mockLoggerError = jest.fn();
const mockLoggerInfo = jest.fn();
jest.mock('../../../utils/logger', () => ({
withRequestId: jest.fn(() => ({
warn: mockLoggerWarn,
error: mockLoggerError,
info: mockLoggerInfo
}))
}));
// Mock crypto module with both randomBytes and createHash
jest.mock('crypto', () => ({
randomBytes: jest.fn(() => ({
toString: jest.fn(() => 'mocked-hex-string-1234567890abcdef')
})),
createHash: jest.fn(() => ({
update: jest.fn().mockReturnThis(),
digest: jest.fn(() => 'mocked-hash')
}))
}));
const { const {
enforceHTTPS, enforceHTTPS,
securityHeaders, securityHeaders,
@@ -6,13 +30,6 @@ const {
sanitizeError sanitizeError
} = require('../../../middleware/security'); } = require('../../../middleware/security');
// Mock crypto module
jest.mock('crypto', () => ({
randomBytes: jest.fn(() => ({
toString: jest.fn(() => 'mocked-hex-string-1234567890abcdef')
}))
}));
describe('Security Middleware', () => { describe('Security Middleware', () => {
let req, res, next, consoleSpy, consoleWarnSpy, consoleErrorSpy; let req, res, next, consoleSpy, consoleWarnSpy, consoleErrorSpy;
@@ -144,13 +161,14 @@ describe('Security Middleware', () => {
enforceHTTPS(req, res, next); enforceHTTPS(req, res, next);
expect(consoleWarnSpy).toHaveBeenCalledWith( expect(mockLoggerWarn).toHaveBeenCalledWith(
'[SECURITY] Host header mismatch during HTTPS redirect:', 'Host header mismatch during HTTPS redirect',
{ {
requestHost: 'malicious.com', requestHost: 'malicious.com',
allowedHost: 'example.com', allowedHost: 'example.com',
ip: '192.168.1.1', ip: '192.168.1.1',
url: '/test-path' url: '/test-path',
eventType: 'SECURITY_HOST_MISMATCH'
} }
); );
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path'); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
@@ -161,7 +179,7 @@ describe('Security Middleware', () => {
enforceHTTPS(req, res, next); enforceHTTPS(req, res, next);
expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(mockLoggerWarn).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path'); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
}); });
@@ -315,25 +333,23 @@ describe('Security Middleware', () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
}); });
it('should log security event with JSON format', () => { it('should log security event with structured data', () => {
const eventType = 'LOGIN_ATTEMPT'; const eventType = 'LOGIN_ATTEMPT';
const details = { username: 'testuser', success: false }; const details = { username: 'testuser', success: false };
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', expect.any(String)); expect(mockLoggerWarn).toHaveBeenCalledWith(
'Security event: LOGIN_ATTEMPT',
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); {
expect(loggedData).toEqual({
timestamp: expect.any(String),
eventType: 'LOGIN_ATTEMPT', eventType: 'LOGIN_ATTEMPT',
requestId: 'test-request-id',
ip: '192.168.1.1', ip: '192.168.1.1',
userAgent: 'Mozilla/5.0 Test Browser', userAgent: 'Mozilla/5.0 Test Browser',
userId: 'anonymous', userId: 'anonymous',
username: 'testuser', username: 'testuser',
success: false success: false
}); }
);
}); });
it('should include user ID when user is authenticated', () => { it('should include user ID when user is authenticated', () => {
@@ -343,8 +359,13 @@ describe('Security Middleware', () => {
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(mockLoggerWarn).toHaveBeenCalledWith(
expect(loggedData.userId).toBe(123); 'Security event: DATA_ACCESS',
expect.objectContaining({
userId: 123,
resource: '/api/users'
})
);
}); });
it('should handle missing request ID', () => { it('should handle missing request ID', () => {
@@ -354,8 +375,12 @@ describe('Security Middleware', () => {
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(mockLoggerWarn).toHaveBeenCalledWith(
expect(loggedData.requestId).toBe('unknown'); 'Security event: SUSPICIOUS_ACTIVITY',
expect.objectContaining({
reason: 'Multiple failed attempts'
})
);
}); });
it('should handle missing IP address', () => { it('should handle missing IP address', () => {
@@ -366,18 +391,28 @@ describe('Security Middleware', () => {
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(mockLoggerWarn).toHaveBeenCalledWith(
expect(loggedData.ip).toBe('10.0.0.1'); 'Security event: IP_CHECK',
expect.objectContaining({
ip: '10.0.0.1',
status: 'blocked'
})
);
}); });
it('should include ISO timestamp', () => { it('should call logger with event type and details', () => {
const eventType = 'TEST_EVENT'; const eventType = 'TEST_EVENT';
const details = {}; const details = {};
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(mockLoggerWarn).toHaveBeenCalledWith(
expect(loggedData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); 'Security event: TEST_EVENT',
expect.objectContaining({
eventType: 'TEST_EVENT',
ip: '192.168.1.1'
})
);
}); });
}); });
@@ -386,28 +421,35 @@ describe('Security Middleware', () => {
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
}); });
it('should log security event with simple format', () => { it('should log security event using logger', () => {
const eventType = 'LOGIN_ATTEMPT'; const eventType = 'LOGIN_ATTEMPT';
const details = { username: 'testuser', success: false }; const details = { username: 'testuser', success: false };
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
expect(consoleSpy).toHaveBeenCalledWith( expect(mockLoggerWarn).toHaveBeenCalledWith(
'[SECURITY]', 'Security event: LOGIN_ATTEMPT',
'LOGIN_ATTEMPT', expect.objectContaining({
{ username: 'testuser', success: false } eventType: 'LOGIN_ATTEMPT',
username: 'testuser',
success: false
})
); );
}); });
it('should not log JSON in development', () => { it('should use structured logging in development', () => {
const eventType = 'TEST_EVENT'; const eventType = 'TEST_EVENT';
const details = { test: true }; const details = { test: true };
logSecurityEvent(eventType, details, req); logSecurityEvent(eventType, details, req);
expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', 'TEST_EVENT', { test: true }); expect(mockLoggerWarn).toHaveBeenCalledWith(
// Ensure it's not JSON.stringify format 'Security event: TEST_EVENT',
expect(consoleSpy).not.toHaveBeenCalledWith('[SECURITY]', expect.stringMatching(/^{.*}$/)); expect.objectContaining({
eventType: 'TEST_EVENT',
test: true
})
);
}); });
}); });
@@ -418,8 +460,12 @@ describe('Security Middleware', () => {
logSecurityEvent('TEST', {}, req); logSecurityEvent('TEST', {}, req);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(mockLoggerWarn).toHaveBeenCalledWith(
expect(loggedData.userAgent).toBeNull(); 'Security event: TEST',
expect.objectContaining({
userAgent: null
})
);
}); });
it('should handle empty details object', () => { it('should handle empty details object', () => {
@@ -427,9 +473,12 @@ describe('Security Middleware', () => {
logSecurityEvent('EMPTY_DETAILS', {}, req); logSecurityEvent('EMPTY_DETAILS', {}, req);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(mockLoggerWarn).toHaveBeenCalledWith(
expect(loggedData.eventType).toBe('EMPTY_DETAILS'); 'Security event: EMPTY_DETAILS',
expect(Object.keys(loggedData)).toContain('timestamp'); expect.objectContaining({
eventType: 'EMPTY_DETAILS'
})
);
}); });
}); });
}); });
@@ -440,36 +489,6 @@ describe('Security Middleware', () => {
req.user = { id: 123 }; req.user = { id: 123 };
}); });
describe('Error logging', () => {
it('should log full error details internally', () => {
const error = new Error('Database connection failed');
error.stack = 'Error: Database connection failed\n at /app/db.js:10:5';
sanitizeError(error, req, res, next);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', {
requestId: 'test-request-id',
error: 'Database connection failed',
stack: 'Error: Database connection failed\n at /app/db.js:10:5',
userId: 123
});
});
it('should handle missing user in logging', () => {
req.user = null;
const error = new Error('Test error');
sanitizeError(error, req, res, next);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', {
requestId: 'test-request-id',
error: 'Test error',
stack: error.stack,
userId: undefined
});
});
});
describe('Client error responses (4xx)', () => { describe('Client error responses (4xx)', () => {
it('should handle 400 Bad Request errors', () => { it('should handle 400 Bad Request errors', () => {
const error = new Error('Invalid input data'); const error = new Error('Invalid input data');

View File

@@ -21,6 +21,39 @@ jest.mock('../../../middleware/auth', () => ({
req.user = { id: 1 }; req.user = { id: 1 };
next(); next();
}), }),
requireVerifiedEmail: jest.fn((req, res, next) => next()),
}));
jest.mock('../../../utils/rentalDurationCalculator', () => ({
calculateRentalCost: jest.fn(() => 100),
}));
jest.mock('../../../services/emailService', () => ({
sendRentalRequestEmail: jest.fn(),
sendRentalApprovalEmail: jest.fn(),
sendRentalDeclinedEmail: jest.fn(),
sendRentalCompletedEmail: jest.fn(),
sendRentalCancelledEmail: jest.fn(),
sendDamageReportEmail: jest.fn(),
sendLateReturnNotificationEmail: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
withRequestId: jest.fn(() => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
})),
}));
jest.mock('../../../services/lateReturnService', () => ({
calculateLateFee: jest.fn(),
processLateReturn: jest.fn(),
}));
jest.mock('../../../services/damageAssessmentService', () => ({
assessDamage: jest.fn(),
processDamageFee: jest.fn(),
})); }));
jest.mock('../../../utils/feeCalculator', () => ({ jest.mock('../../../utils/feeCalculator', () => ({
@@ -47,6 +80,7 @@ jest.mock('../../../services/stripeService', () => ({
const { Rental, Item, User } = require('../../../models'); const { Rental, Item, User } = require('../../../models');
const FeeCalculator = require('../../../utils/feeCalculator'); const FeeCalculator = require('../../../utils/feeCalculator');
const RentalDurationCalculator = require('../../../utils/rentalDurationCalculator');
const RefundService = require('../../../services/refundService'); const RefundService = require('../../../services/refundService');
const StripeService = require('../../../services/stripeService'); const StripeService = require('../../../services/stripeService');
@@ -267,6 +301,8 @@ describe('Rentals Routes', () => {
}); });
it('should create a new rental with hourly pricing', async () => { it('should create a new rental with hourly pricing', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(80); // 8 hours * 10/hour
const response = await request(app) const response = await request(app)
.post('/rentals') .post('/rentals')
.send(rentalData); .send(rentalData);
@@ -277,6 +313,8 @@ describe('Rentals Routes', () => {
}); });
it('should create a new rental with daily pricing', async () => { it('should create a new rental with daily pricing', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(150); // 3 days * 50/day
const dailyRentalData = { const dailyRentalData = {
...rentalData, ...rentalData,
endDateTime: '2024-01-17T18:00:00.000Z', // 3 days endDateTime: '2024-01-17T18:00:00.000Z', // 3 days
@@ -324,6 +362,8 @@ describe('Rentals Routes', () => {
}); });
it('should return 400 when payment method is missing for paid rentals', async () => { it('should return 400 when payment method is missing for paid rentals', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(100); // Paid rental
const dataWithoutPayment = { ...rentalData }; const dataWithoutPayment = { ...rentalData };
delete dataWithoutPayment.stripePaymentMethodId; delete dataWithoutPayment.stripePaymentMethodId;
@@ -336,6 +376,8 @@ describe('Rentals Routes', () => {
}); });
it('should create a free rental without payment method', async () => { it('should create a free rental without payment method', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(0); // Free rental
// Set up a free item (both prices are 0) // Set up a free item (both prices are 0)
Item.findByPk.mockResolvedValue({ Item.findByPk.mockResolvedValue({
id: 1, id: 1,
@@ -433,6 +475,11 @@ describe('Rentals Routes', () => {
StripeService.chargePaymentMethod.mockResolvedValue({ StripeService.chargePaymentMethod.mockResolvedValue({
paymentIntentId: 'pi_test123', paymentIntentId: 'pi_test123',
paymentMethod: {
brand: 'visa',
last4: '4242'
},
chargedAt: new Date('2024-01-15T10:00:00.000Z')
}); });
const updatedRental = { const updatedRental = {
@@ -461,6 +508,9 @@ describe('Rentals Routes', () => {
status: 'confirmed', status: 'confirmed',
paymentStatus: 'paid', paymentStatus: 'paid',
stripePaymentIntentId: 'pi_test123', stripePaymentIntentId: 'pi_test123',
paymentMethodBrand: 'visa',
paymentMethodLast4: '4242',
chargedAt: new Date('2024-01-15T10:00:00.000Z'),
}); });
}); });

View File

@@ -618,7 +618,19 @@ describe('StripeService', () => {
status: 'succeeded', status: 'succeeded',
client_secret: 'pi_123456789_secret_test', client_secret: 'pi_123456789_secret_test',
amount: 5000, amount: 5000,
currency: 'usd' currency: 'usd',
created: 1234567890,
charges: {
data: [{
payment_method_details: {
type: 'card',
card: {
brand: 'visa',
last4: '4242'
}
}
}]
}
}; };
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent); mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
@@ -636,14 +648,23 @@ describe('StripeService', () => {
payment_method: 'pm_123456789', payment_method: 'pm_123456789',
customer: 'cus_123456789', customer: 'cus_123456789',
confirm: true, confirm: true,
off_session: true,
return_url: 'http://localhost:3000/payment-complete', return_url: 'http://localhost:3000/payment-complete',
metadata: { rentalId: '1' } metadata: { rentalId: '1' },
expand: ['charges.data.payment_method_details']
}); });
expect(result).toEqual({ expect(result).toEqual({
paymentIntentId: 'pi_123456789', paymentIntentId: 'pi_123456789',
status: 'succeeded', status: 'succeeded',
clientSecret: 'pi_123456789_secret_test' clientSecret: 'pi_123456789_secret_test',
paymentMethod: {
type: 'card',
brand: 'visa',
last4: '4242'
},
chargedAt: new Date(1234567890 * 1000),
amountCharged: 50.00
}); });
}); });

View File

@@ -40,6 +40,7 @@ const Profile: React.FC = () => {
acceptedRentals: 0, acceptedRentals: 0,
totalRentals: 0, totalRentals: 0,
}); });
const [showPersonalInfo, setShowPersonalInfo] = useState(false);
const [availabilityData, setAvailabilityData] = useState({ const [availabilityData, setAvailabilityData] = useState({
generalAvailableAfter: "09:00", generalAvailableAfter: "09:00",
generalAvailableBefore: "17:00", generalAvailableBefore: "17:00",
@@ -196,14 +197,14 @@ const Profile: React.FC = () => {
// Fetch past rentals as a renter // Fetch past rentals as a renter
const renterResponse = await rentalAPI.getRentals(); const renterResponse = await rentalAPI.getRentals();
const pastRenterRentals = renterResponse.data.filter((r: Rental) => const pastRenterRentals = renterResponse.data.filter((r: Rental) =>
["completed", "cancelled"].includes(r.status) ["completed", "cancelled", "declined"].includes(r.status)
); );
setPastRenterRentals(pastRenterRentals); setPastRenterRentals(pastRenterRentals);
// Fetch past rentals as an owner // Fetch past rentals as an owner
const ownerResponse = await rentalAPI.getListings(); const ownerResponse = await rentalAPI.getListings();
const pastOwnerRentals = ownerResponse.data.filter((r: Rental) => const pastOwnerRentals = ownerResponse.data.filter((r: Rental) =>
["completed", "cancelled"].includes(r.status) ["completed", "cancelled", "declined"].includes(r.status)
); );
setPastOwnerRentals(pastOwnerRentals); setPastOwnerRentals(pastOwnerRentals);
} catch (err) { } catch (err) {
@@ -655,15 +656,6 @@ const Profile: React.FC = () => {
<i className="bi bi-clock-history me-2"></i> <i className="bi bi-clock-history me-2"></i>
Rental History Rental History
</button> </button>
<button
className={`list-group-item list-group-item-action ${
activeSection === "personal-info" ? "active" : ""
}`}
onClick={() => setActiveSection("personal-info")}
>
<i className="bi bi-person me-2"></i>
Personal Information
</button>
<button <button
className="list-group-item list-group-item-action text-danger" className="list-group-item list-group-item-action text-danger"
onClick={logout} onClick={logout}
@@ -794,6 +786,80 @@ const Profile: React.FC = () => {
</div> </div>
</div> </div>
{/* Personal Information Card */}
<div className="card mb-4">
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<h5 className="card-title mb-0">Personal Information</h5>
<button
type="button"
className="btn btn-link text-primary p-0 ms-2"
onClick={() => setShowPersonalInfo(!showPersonalInfo)}
style={{ textDecoration: 'none' }}
>
<i className={`bi ${showPersonalInfo ? 'bi-eye' : 'bi-eye-slash'} fs-5`}></i>
</button>
</div>
{showPersonalInfo && (
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input
type="email"
className="form-control"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
disabled={!editing}
/>
</div>
<div className="mb-3">
<label htmlFor="phone" className="form-label">
Phone Number
</label>
<input
type="tel"
className="form-control"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="(123) 456-7890"
disabled={!editing}
/>
</div>
{editing ? (
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary">
Save Changes
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleCancel}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="btn btn-primary"
onClick={() => setEditing(true)}
>
Edit Information
</button>
)}
</form>
)}
</div>
</div>
{/* Stats Card */} {/* Stats Card */}
<div className="card"> <div className="card">
<div className="card-body"> <div className="card-body">
@@ -877,6 +943,8 @@ const Profile: React.FC = () => {
className={`badge ${ className={`badge ${
rental.status === "completed" rental.status === "completed"
? "bg-success" ? "bg-success"
: rental.status === "declined"
? "bg-secondary"
: "bg-danger" : "bg-danger"
}`} }`}
> >
@@ -1013,6 +1081,8 @@ const Profile: React.FC = () => {
className={`badge ${ className={`badge ${
rental.status === "completed" rental.status === "completed"
? "bg-success" ? "bg-success"
: rental.status === "declined"
? "bg-secondary"
: "bg-danger" : "bg-danger"
}`} }`}
> >
@@ -1095,72 +1165,6 @@ const Profile: React.FC = () => {
</div> </div>
)} )}
{/* Personal Information Section */}
{activeSection === "personal-info" && (
<div>
<h4 className="mb-4">Personal Information</h4>
<div className="card">
<div className="card-body">
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input
type="email"
className="form-control"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
disabled={!editing}
/>
</div>
<div className="mb-3">
<label htmlFor="phone" className="form-label">
Phone Number
</label>
<input
type="tel"
className="form-control"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="(123) 456-7890"
disabled={!editing}
/>
</div>
{editing ? (
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary">
Save Changes
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleCancel}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="btn btn-primary"
onClick={() => setEditing(true)}
>
Edit Information
</button>
)}
</form>
</div>
</div>
</div>
)}
{/* Owner Settings Section */} {/* Owner Settings Section */}
{activeSection === "owner-settings" && ( {activeSection === "owner-settings" && (
<div> <div>

View File

@@ -379,8 +379,13 @@ const RentItem: React.FC = () => {
<div className="d-flex justify-content-between"> <div className="d-flex justify-content-between">
<strong>Total:</strong> <strong>Total:</strong>
{costLoading ? ( {costLoading ? (
<div className="spinner-border spinner-border-sm" role="status"> <div
<span className="visually-hidden">Calculating...</span> className="spinner-border spinner-border-sm"
role="status"
>
<span className="visually-hidden">
Calculating...
</span>
</div> </div>
) : costError ? ( ) : costError ? (
<small className="text-danger">Error</small> <small className="text-danger">Error</small>

View File

@@ -170,9 +170,9 @@ const Renting: React.FC = () => {
); );
}; };
// Filter rentals - show active and declined rentals // Filter rentals - show only active rentals (declined go to history)
const renterActiveRentals = rentals.filter((r) => const renterActiveRentals = rentals.filter((r) =>
["pending", "confirmed", "declined", "active"].includes(r.status) ["pending", "confirmed", "active"].includes(r.status)
); );
if (loading) { if (loading) {
@@ -199,7 +199,7 @@ const Renting: React.FC = () => {
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<h1>My Rentals</h1> <h1>Rentals</h1>
{renterActiveRentals.length === 0 ? ( {renterActiveRentals.length === 0 ? (
<div className="text-center py-5"> <div className="text-center py-5">
@@ -301,8 +301,7 @@ const Renting: React.FC = () => {
{rental.status === "declined" && rental.declineReason && ( {rental.status === "declined" && rental.declineReason && (
<div className="alert alert-warning mt-2 mb-1 p-2 small"> <div className="alert alert-warning mt-2 mb-1 p-2 small">
<strong>Decline reason:</strong>{" "} <strong>Decline reason:</strong> {rental.declineReason}
{rental.declineReason}
</div> </div>
)} )}