* 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
332 lines
12 KiB
JavaScript
332 lines
12 KiB
JavaScript
const {
|
|
getAccess,
|
|
getSharedUidsForUser,
|
|
ownershipOrPermissionWhere,
|
|
ACCESS,
|
|
} = require('../../../services/permissionsService');
|
|
const {
|
|
User,
|
|
Project,
|
|
Task,
|
|
Note,
|
|
Permission,
|
|
sequelize,
|
|
} = require('../../../models');
|
|
const bcrypt = require('bcrypt');
|
|
|
|
describe('permissionsService', () => {
|
|
let owner, otherUser, adminUser;
|
|
|
|
beforeEach(async () => {
|
|
await sequelize.query('DELETE FROM permissions');
|
|
await sequelize.query('DELETE FROM roles');
|
|
const hash = await bcrypt.hash('pass', 10);
|
|
// First user created gets admin role automatically via afterCreate hook
|
|
adminUser = await User.create({
|
|
email: 'admin@test.com',
|
|
password_digest: hash,
|
|
});
|
|
owner = await User.create({
|
|
email: 'owner@test.com',
|
|
password_digest: hash,
|
|
});
|
|
otherUser = await User.create({
|
|
email: 'other@test.com',
|
|
password_digest: hash,
|
|
});
|
|
});
|
|
|
|
describe('getAccess', () => {
|
|
// --- Projects ---
|
|
|
|
it('should return rw for project owner', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
const access = await getAccess(owner.id, 'project', project.uid);
|
|
expect(access).toBe('rw');
|
|
});
|
|
|
|
it('should return none for non-owner without permission', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
const access = await getAccess(
|
|
otherUser.id,
|
|
'project',
|
|
project.uid
|
|
);
|
|
expect(access).toBe('none');
|
|
});
|
|
|
|
it('should return admin for admin user', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
const access = await getAccess(
|
|
adminUser.uid,
|
|
'project',
|
|
project.uid
|
|
);
|
|
expect(access).toBe('admin');
|
|
});
|
|
|
|
it('should return none for non-existent project', async () => {
|
|
const access = await getAccess(
|
|
owner.id,
|
|
'project',
|
|
'nonexistent-uid'
|
|
);
|
|
expect(access).toBe('none');
|
|
});
|
|
|
|
it('should return shared permission level for project', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'ro',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
const access = await getAccess(
|
|
otherUser.id,
|
|
'project',
|
|
project.uid
|
|
);
|
|
expect(access).toBe('ro');
|
|
});
|
|
|
|
// --- Tasks ---
|
|
|
|
it('should return rw for task owner', async () => {
|
|
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
|
const access = await getAccess(owner.id, 'task', task.uid);
|
|
expect(access).toBe('rw');
|
|
});
|
|
|
|
it('should return none for non-owner task without permission', async () => {
|
|
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
|
const access = await getAccess(otherUser.id, 'task', task.uid);
|
|
expect(access).toBe('none');
|
|
});
|
|
|
|
it('should return none for non-existent task', async () => {
|
|
const access = await getAccess(owner.id, 'task', 'no-such-task');
|
|
expect(access).toBe('none');
|
|
});
|
|
|
|
it('should inherit task access from parent project permission', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
const task = await Task.create({
|
|
name: 'T1',
|
|
user_id: owner.id,
|
|
project_id: project.id,
|
|
});
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'rw',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
const access = await getAccess(otherUser.id, 'task', task.uid);
|
|
expect(access).toBe('rw');
|
|
});
|
|
|
|
it('should return shared permission for directly shared task', async () => {
|
|
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'task',
|
|
resource_uid: task.uid,
|
|
access_level: 'ro',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
const access = await getAccess(otherUser.id, 'task', task.uid);
|
|
expect(access).toBe('ro');
|
|
});
|
|
|
|
// --- Notes ---
|
|
|
|
it('should return rw for note owner', async () => {
|
|
const note = await Note.create({ title: 'N1', user_id: owner.id });
|
|
const access = await getAccess(owner.id, 'note', note.uid);
|
|
expect(access).toBe('rw');
|
|
});
|
|
|
|
it('should return none for non-owner note without permission', async () => {
|
|
const note = await Note.create({ title: 'N1', user_id: owner.id });
|
|
const access = await getAccess(otherUser.id, 'note', note.uid);
|
|
expect(access).toBe('none');
|
|
});
|
|
|
|
it('should inherit note access from parent project permission', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
const note = await Note.create({
|
|
title: 'N1',
|
|
user_id: owner.id,
|
|
project_id: project.id,
|
|
});
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'ro',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
const access = await getAccess(otherUser.id, 'note', note.uid);
|
|
expect(access).toBe('ro');
|
|
});
|
|
});
|
|
|
|
describe('getSharedUidsForUser', () => {
|
|
it('should return empty array when no permissions exist', async () => {
|
|
const uids = await getSharedUidsForUser('project', otherUser.id);
|
|
expect(uids).toEqual([]);
|
|
});
|
|
|
|
it('should return shared resource uids', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'ro',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
const uids = await getSharedUidsForUser('project', otherUser.id);
|
|
expect(uids).toContain(project.uid);
|
|
});
|
|
|
|
it('should deduplicate uids', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
// Create two permissions for the same resource (different propagation)
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'ro',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
const uids = await getSharedUidsForUser('project', otherUser.id);
|
|
const uniqueUids = [...new Set(uids)];
|
|
expect(uids.length).toBe(uniqueUids.length);
|
|
});
|
|
});
|
|
|
|
describe('ownershipOrPermissionWhere', () => {
|
|
it('should include user_id condition for owned resources', async () => {
|
|
const where = await ownershipOrPermissionWhere('project', owner.id);
|
|
expect(where).toBeDefined();
|
|
// Should contain an Op.or with user_id condition
|
|
const orKey = Object.getOwnPropertySymbols(where)[0];
|
|
expect(orKey).toBeDefined();
|
|
const conditions = where[orKey];
|
|
expect(conditions.some((c) => c.user_id === owner.id)).toBe(true);
|
|
});
|
|
|
|
it('should include shared resource uids when permissions exist', async () => {
|
|
const project = await Project.create({
|
|
name: 'Shared',
|
|
user_id: owner.id,
|
|
});
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'rw',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
|
|
const where = await ownershipOrPermissionWhere(
|
|
'project',
|
|
otherUser.id
|
|
);
|
|
const orKey = Object.getOwnPropertySymbols(where)[0];
|
|
const conditions = where[orKey];
|
|
// Should have a uid IN condition with the shared project uid
|
|
const uidCondition = conditions.find((c) => c.uid);
|
|
expect(uidCondition).toBeDefined();
|
|
});
|
|
|
|
it('should use cache when provided', async () => {
|
|
const cache = new Map();
|
|
const where1 = await ownershipOrPermissionWhere(
|
|
'project',
|
|
owner.id,
|
|
cache
|
|
);
|
|
const where2 = await ownershipOrPermissionWhere(
|
|
'project',
|
|
owner.id,
|
|
cache
|
|
);
|
|
expect(where1).toBe(where2); // same reference from cache
|
|
});
|
|
|
|
it('should include tasks from shared projects for task resource type', async () => {
|
|
const project = await Project.create({
|
|
name: 'P1',
|
|
user_id: owner.id,
|
|
});
|
|
await Task.create({
|
|
name: 'T1',
|
|
user_id: owner.id,
|
|
project_id: project.id,
|
|
});
|
|
await Permission.create({
|
|
user_id: otherUser.id,
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
access_level: 'rw',
|
|
propagation: 'direct',
|
|
granted_by_user_id: owner.id,
|
|
});
|
|
|
|
const where = await ownershipOrPermissionWhere(
|
|
'task',
|
|
otherUser.id
|
|
);
|
|
const orKey = Object.getOwnPropertySymbols(where)[0];
|
|
const conditions = where[orKey];
|
|
// Should have a project_id IN condition
|
|
const projectCondition = conditions.find((c) => c.project_id);
|
|
expect(projectCondition).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('ACCESS constants', () => {
|
|
it('should export expected access levels', () => {
|
|
expect(ACCESS.NONE).toBe('none');
|
|
expect(ACCESS.RO).toBe('ro');
|
|
expect(ACCESS.RW).toBe('rw');
|
|
expect(ACCESS.ADMIN).toBe('admin');
|
|
});
|
|
});
|
|
});
|