506 lines
15 KiB
JavaScript
506 lines
15 KiB
JavaScript
const mockTokensInstance = {
|
|
secretSync: jest.fn().mockReturnValue('mock-secret'),
|
|
create: jest.fn().mockReturnValue('mock-token-123'),
|
|
verify: jest.fn().mockReturnValue(true)
|
|
};
|
|
|
|
jest.mock('csrf', () => {
|
|
return jest.fn().mockImplementation(() => mockTokensInstance);
|
|
});
|
|
|
|
jest.mock('cookie-parser', () => {
|
|
return jest.fn().mockReturnValue((req, res, next) => next());
|
|
});
|
|
|
|
const { csrfProtection, generateCSRFToken, getCSRFToken } = require('../../../middleware/csrf');
|
|
|
|
describe('CSRF Middleware', () => {
|
|
let req, res, next;
|
|
|
|
beforeEach(() => {
|
|
req = {
|
|
method: 'POST',
|
|
headers: {},
|
|
body: {},
|
|
query: {},
|
|
cookies: {}
|
|
};
|
|
res = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn(),
|
|
cookie: jest.fn(),
|
|
set: jest.fn(),
|
|
locals: {}
|
|
};
|
|
next = jest.fn();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('csrfProtection', () => {
|
|
describe('Safe methods', () => {
|
|
it('should skip CSRF protection for GET requests', () => {
|
|
req.method = 'GET';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip CSRF protection for HEAD requests', () => {
|
|
req.method = 'HEAD';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip CSRF protection for OPTIONS requests', () => {
|
|
req.method = 'OPTIONS';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Token validation', () => {
|
|
beforeEach(() => {
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
});
|
|
|
|
it('should validate token from x-csrf-token header', () => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should validate token from request body', () => {
|
|
req.body.csrfToken = 'mock-token-123';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should validate token from query parameters', () => {
|
|
req.query.csrfToken = 'mock-token-123';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should prefer header token over body token', () => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.body.csrfToken = 'different-token';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should prefer header token over query token', () => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.query.csrfToken = 'different-token';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should prefer body token over query token', () => {
|
|
req.body.csrfToken = 'mock-token-123';
|
|
req.query.csrfToken = 'different-token';
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Missing tokens', () => {
|
|
it('should return 403 when no token provided', () => {
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 403 when no cookie token provided', () => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = {};
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 403 when cookies object is missing', () => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = undefined;
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 403 when both tokens are missing', () => {
|
|
req.cookies = {};
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Token mismatch', () => {
|
|
it('should return 403 when tokens do not match', () => {
|
|
req.headers['x-csrf-token'] = 'token-from-header';
|
|
req.cookies = { 'csrf-token': 'token-from-cookie' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 403 when header token is empty but cookie exists', () => {
|
|
req.headers['x-csrf-token'] = '';
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 403 when cookie token is empty but header exists', () => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = { 'csrf-token': '' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_MISMATCH'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Token verification', () => {
|
|
beforeEach(() => {
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
});
|
|
|
|
it('should return 403 when token verification fails', () => {
|
|
mockTokensInstance.verify.mockReturnValue(false);
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: 'Invalid CSRF token',
|
|
code: 'CSRF_TOKEN_INVALID'
|
|
});
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call next when token verification succeeds', () => {
|
|
mockTokensInstance.verify.mockReturnValue(true);
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
it('should handle case-insensitive HTTP methods', () => {
|
|
req.method = 'post';
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle PUT requests', () => {
|
|
req.method = 'PUT';
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle DELETE requests', () => {
|
|
req.method = 'DELETE';
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle PATCH requests', () => {
|
|
req.method = 'PATCH';
|
|
req.headers['x-csrf-token'] = 'mock-token-123';
|
|
req.cookies = { 'csrf-token': 'mock-token-123' };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('generateCSRFToken', () => {
|
|
it('should generate token and set cookie with proper options', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set secure flag to false in dev environment', () => {
|
|
process.env.NODE_ENV = 'dev';
|
|
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: false,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should set secure flag to true in non-dev environment', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should set token in response header', () => {
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'mock-token-123');
|
|
});
|
|
|
|
it('should make token available in res.locals', () => {
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(res.locals.csrfToken).toBe('mock-token-123');
|
|
});
|
|
|
|
it('should call next after setting up token', () => {
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle test environment', () => {
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should handle undefined NODE_ENV', () => {
|
|
delete process.env.NODE_ENV;
|
|
|
|
generateCSRFToken(req, res, next);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getCSRFToken', () => {
|
|
it('should generate token and return it in response', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
getCSRFToken(req, res);
|
|
|
|
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
|
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'mock-token-123' });
|
|
});
|
|
|
|
it('should set token in cookie with proper options', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
getCSRFToken(req, res);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should set secure flag to false in dev environment', () => {
|
|
process.env.NODE_ENV = 'dev';
|
|
|
|
getCSRFToken(req, res);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: false,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should set secure flag to true in production environment', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
getCSRFToken(req, res);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should handle test environment', () => {
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
getCSRFToken(req, res);
|
|
|
|
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict',
|
|
maxAge: 60 * 60 * 1000
|
|
});
|
|
});
|
|
|
|
it('should generate new token each time', () => {
|
|
mockTokensInstance.create
|
|
.mockReturnValueOnce('token-1')
|
|
.mockReturnValueOnce('token-2');
|
|
|
|
getCSRFToken(req, res);
|
|
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-1' });
|
|
|
|
getCSRFToken(req, res);
|
|
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-2' });
|
|
});
|
|
});
|
|
|
|
describe('Integration scenarios', () => {
|
|
it('should handle complete CSRF flow', () => {
|
|
// First, generate a token
|
|
generateCSRFToken(req, res, next);
|
|
const generatedToken = res.locals.csrfToken;
|
|
|
|
// Reset mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Now test protection with the generated token
|
|
req.method = 'POST';
|
|
req.headers['x-csrf-token'] = generatedToken;
|
|
req.cookies = { 'csrf-token': generatedToken };
|
|
|
|
csrfProtection(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalled();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle token generation endpoint flow', () => {
|
|
getCSRFToken(req, res);
|
|
|
|
const tokenFromResponse = res.json.mock.calls[0][0].csrfToken;
|
|
const cookieCall = res.cookie.mock.calls[0];
|
|
|
|
expect(cookieCall[0]).toBe('csrf-token');
|
|
expect(cookieCall[1]).toBe(tokenFromResponse);
|
|
expect(tokenFromResponse).toBe('mock-token-123');
|
|
});
|
|
});
|
|
}); |