* 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
265 lines
8.1 KiB
JavaScript
265 lines
8.1 KiB
JavaScript
const { hasAccess } = require('../../../middleware/authorize');
|
||
const permissionsService = require('../../../services/permissionsService');
|
||
|
||
jest.mock('../../../services/permissionsService');
|
||
|
||
describe('authorize middleware – hasAccess', () => {
|
||
let req, res, next;
|
||
|
||
beforeEach(() => {
|
||
req = {
|
||
currentUser: { id: 42 },
|
||
session: { userId: 42 },
|
||
params: { uid: 'abc123' },
|
||
};
|
||
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||
next = jest.fn();
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
// --- UID resolution ---
|
||
|
||
it('should resolve uid from a function', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('ro', 'project', (r) => r.params.uid);
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
42,
|
||
'project',
|
||
'abc123'
|
||
);
|
||
expect(next).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should resolve uid from a static string', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('ro', 'project', 'static-uid');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
42,
|
||
'project',
|
||
'static-uid'
|
||
);
|
||
expect(next).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should resolve uid from an async function', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('ro', 'task', async (r) => r.params.uid);
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
42,
|
||
'task',
|
||
'abc123'
|
||
);
|
||
expect(next).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 404 when uid resolves to null', async () => {
|
||
const mw = hasAccess('ro', 'project', () => null);
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(404);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 404 when uid resolves to undefined', async () => {
|
||
const mw = hasAccess('ro', 'project', () => undefined);
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(404);
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should return 404 when uid resolves to empty string', async () => {
|
||
const mw = hasAccess('ro', 'project', () => '');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(404);
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// --- Permission hierarchy ---
|
||
|
||
it('should allow access when user has exact required level (ro)', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('ro');
|
||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should allow access when user has higher than required (rw > ro)', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should allow access when user has admin and rw is required', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('admin');
|
||
const mw = hasAccess('rw', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should deny access when user has ro but rw is required', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('ro');
|
||
const mw = hasAccess('rw', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should deny access when user has none', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('none');
|
||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(403);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||
expect(next).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// --- User ID extraction ---
|
||
|
||
it('should prefer currentUser.id over session.userId', async () => {
|
||
req.currentUser = { id: 100 };
|
||
req.session = { userId: 200 };
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
100,
|
||
'project',
|
||
'uid1'
|
||
);
|
||
});
|
||
|
||
it('should fall back to session.userId when currentUser is missing', async () => {
|
||
req.currentUser = null;
|
||
req.session = { userId: 200 };
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
200,
|
||
'project',
|
||
'uid1'
|
||
);
|
||
});
|
||
|
||
// --- Options ---
|
||
|
||
it('should use custom notFoundMessage', async () => {
|
||
const mw = hasAccess('ro', 'project', () => null, {
|
||
notFoundMessage: 'Project not found',
|
||
});
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(404);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' });
|
||
});
|
||
|
||
it('should return 404 instead of 403 when forbiddenStatus is 404', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('none');
|
||
const mw = hasAccess('rw', 'project', () => 'uid1', {
|
||
forbiddenStatus: 404,
|
||
});
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(404);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
||
});
|
||
|
||
it('should use custom notFoundMessage with forbiddenStatus 404', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('none');
|
||
const mw = hasAccess('rw', 'project', () => 'uid1', {
|
||
forbiddenStatus: 404,
|
||
notFoundMessage: 'Task not found',
|
||
});
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(res.status).toHaveBeenCalledWith(404);
|
||
expect(res.json).toHaveBeenCalledWith({ error: 'Task not found' });
|
||
});
|
||
|
||
// --- Resource types ---
|
||
|
||
it('should pass correct resource type for tasks', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('rw', 'task', () => 'task-uid');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
42,
|
||
'task',
|
||
'task-uid'
|
||
);
|
||
});
|
||
|
||
it('should pass correct resource type for notes', async () => {
|
||
permissionsService.getAccess.mockResolvedValue('rw');
|
||
const mw = hasAccess('rw', 'note', () => 'note-uid');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||
42,
|
||
'note',
|
||
'note-uid'
|
||
);
|
||
});
|
||
|
||
// --- Error handling ---
|
||
|
||
it('should forward errors from getAccess to next()', async () => {
|
||
const error = new Error('Database failure');
|
||
permissionsService.getAccess.mockRejectedValue(error);
|
||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalledWith(error);
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should forward errors from getResourceUid to next()', async () => {
|
||
const error = new Error('UID lookup failed');
|
||
const mw = hasAccess('ro', 'project', () => {
|
||
throw error;
|
||
});
|
||
|
||
await mw(req, res, next);
|
||
|
||
expect(next).toHaveBeenCalledWith(error);
|
||
expect(res.status).not.toHaveBeenCalled();
|
||
});
|
||
});
|