* Increase coverage * Add comprehensive LLM development documentation - Add CLAUDE.md as main documentation index - Create 8 detailed documentation files in docs/: - architecture.md: Tech stack, data models, auth system - directory-structure.md: Complete file tree with paths - backend-patterns.md: Module architecture and patterns - database.md: Models, migrations, and workflows - development-workflow.md: Setup and daily development - code-conventions.md: Style guide and best practices - testing.md: Test organization and patterns - common-tasks.md: How-to guides for frequent tasks - Update .gitignore to allow project-level CLAUDE.md - 4,285 lines of comprehensive documentation - Organized for easy navigation with cross-links - LLM-optimized with absolute paths and code examples * fixup! Add comprehensive LLM development documentation
289 lines
9.4 KiB
JavaScript
289 lines
9.4 KiB
JavaScript
const { requireAuth } = require('../../../middleware/auth');
|
||
const { User } = require('../../../models');
|
||
const { createApiToken } = require('../../../modules/users/apiTokenService');
|
||
|
||
describe('Auth Middleware', () => {
|
||
let req, res, next;
|
||
|
||
beforeEach(() => {
|
||
req = {
|
||
path: '/api/tasks',
|
||
session: {},
|
||
};
|
||
res = {
|
||
status: jest.fn().mockReturnThis(),
|
||
json: jest.fn(),
|
||
};
|
||
next = jest.fn();
|
||
});
|
||
|
||
it('should skip authentication for health check', async () => {
|
||
req.path = '/api/health';
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should skip authentication for login route', async () => {
|
||
req.path = '/api/login';
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should skip authentication for current_user route', async () => {
|
||
req.path = '/api/current_user';
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 401 if no session', async () => {
|
||
req.session = null;
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.json).toHaveBeenCalledWith({
|
||
error: 'Authentication required',
|
||
});
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 401 if no userId in session', async () => {
|
||
req.session = {};
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.json).toHaveBeenCalledWith({
|
||
error: 'Authentication required',
|
||
});
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 401 and destroy session if user not found', async () => {
|
||
const bcrypt = require('bcrypt');
|
||
const user = await User.create({
|
||
email: 'test@example.com',
|
||
password_digest: await bcrypt.hash('password123', 10),
|
||
});
|
||
|
||
req.session = {
|
||
userId: user.id + 1, // Non-existent user ID
|
||
destroy: jest.fn(),
|
||
};
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(req.session.destroy).toHaveBeenCalled();
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should set currentUser and call next for valid session', async () => {
|
||
const bcrypt = require('bcrypt');
|
||
const user = await User.create({
|
||
email: 'test@example.com',
|
||
password_digest: await bcrypt.hash('password123', 10),
|
||
});
|
||
|
||
req.session = {
|
||
userId: user.id,
|
||
};
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(req.currentUser).toBeDefined();
|
||
expect(req.currentUser.id).toBe(user.id);
|
||
expect(req.currentUser.email).toBe(user.email);
|
||
expect(next).toHaveBeenCalled();
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should handle database errors', async () => {
|
||
// Mock console.error to suppress expected error log in test output
|
||
const originalConsoleError = console.error;
|
||
console.error = jest.fn();
|
||
|
||
// Mock User.findByPk to throw an error
|
||
const originalFindByPk = User.findByPk;
|
||
User.findByPk = jest
|
||
.fn()
|
||
.mockRejectedValue(new Error('Database connection error'));
|
||
|
||
req.session = {
|
||
userId: 123,
|
||
destroy: jest.fn(),
|
||
};
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(500);
|
||
expect(res.json).toHaveBeenCalledWith({
|
||
error: 'Authentication error',
|
||
});
|
||
expect(next).not.toHaveBeenCalled();
|
||
|
||
// Restore original methods
|
||
User.findByPk = originalFindByPk;
|
||
console.error = originalConsoleError;
|
||
});
|
||
|
||
// --- API Token (Bearer) authentication ---
|
||
|
||
describe('Bearer token authentication', () => {
|
||
let user;
|
||
|
||
beforeEach(async () => {
|
||
const { sequelize } = require('../../../models');
|
||
await sequelize.query('DELETE FROM api_tokens');
|
||
const bcrypt = require('bcrypt');
|
||
user = await User.create({
|
||
email: 'token-user@example.com',
|
||
password_digest: await bcrypt.hash('password123', 10),
|
||
});
|
||
// No session – forces the bearer path
|
||
req.session = null;
|
||
req.headers = {};
|
||
});
|
||
|
||
it('should authenticate with a valid Bearer token', async () => {
|
||
const { rawToken } = await createApiToken({
|
||
userId: user.id,
|
||
name: 'test',
|
||
});
|
||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
expect(req.currentUser.id).toBe(user.id);
|
||
expect(req.authToken).toBeDefined();
|
||
});
|
||
|
||
it('should return 401 for an invalid Bearer token', async () => {
|
||
req.headers = { authorization: 'Bearer invalid-token-value' };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.json).toHaveBeenCalledWith({
|
||
error: 'Invalid or expired API token',
|
||
});
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 401 for a revoked token', async () => {
|
||
const { rawToken, tokenRecord } = await createApiToken({
|
||
userId: user.id,
|
||
name: 'revoked',
|
||
});
|
||
await tokenRecord.update({ revoked_at: new Date() });
|
||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.json).toHaveBeenCalledWith({
|
||
error: 'Invalid or expired API token',
|
||
});
|
||
});
|
||
|
||
it('should return 401 for an expired token', async () => {
|
||
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||
const { rawToken } = await createApiToken({
|
||
userId: user.id,
|
||
name: 'expired',
|
||
expiresAt: pastDate,
|
||
});
|
||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(res.json).toHaveBeenCalledWith({
|
||
error: 'Invalid or expired API token',
|
||
});
|
||
});
|
||
|
||
it('should return 401 when Authorization header has no token value', async () => {
|
||
req.headers = { authorization: 'Bearer ' };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 401 for non-Bearer scheme', async () => {
|
||
req.headers = { authorization: 'Basic abc123' };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 401 when token user no longer exists', async () => {
|
||
const originalConsoleError = console.error;
|
||
console.error = jest.fn();
|
||
|
||
const { rawToken } = await createApiToken({
|
||
userId: user.id,
|
||
name: 'orphan',
|
||
});
|
||
// Destroying the user cascade-deletes associated tokens,
|
||
// so the token lookup itself fails rather than the user lookup
|
||
await user.destroy();
|
||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(401);
|
||
expect(next).not.toHaveBeenCalled();
|
||
|
||
console.error = originalConsoleError;
|
||
});
|
||
|
||
it('should update last_used_at when token has not been used recently', async () => {
|
||
const { rawToken, tokenRecord } = await createApiToken({
|
||
userId: user.id,
|
||
name: 'fresh',
|
||
});
|
||
// Ensure last_used_at is old enough to trigger update
|
||
await tokenRecord.update({
|
||
last_used_at: new Date(Date.now() - 10 * 60 * 1000),
|
||
});
|
||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
// Give the fire-and-forget update a moment to complete
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
await tokenRecord.reload();
|
||
// last_used_at should be updated to roughly now
|
||
expect(
|
||
Date.now() - tokenRecord.last_used_at.getTime()
|
||
).toBeLessThan(5000);
|
||
});
|
||
|
||
it('should skip health check even with no session and no token', async () => {
|
||
req.path = '/api/health';
|
||
req.headers = {};
|
||
|
||
await requireAuth(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|