diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 58c5415..f2ada0e 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -18,6 +18,13 @@ const requireAuth = async (req, res, next) => { return res.status(401).json({ error: 'User not found' }); } + // Debug logging to verify correct user is authenticated + if (req.path.includes('/tasks') && req.method === 'GET') { + console.log( + `[AUTH DEBUG] ${req.method} ${req.path} - User: ${user.email} (ID: ${user.id})` + ); + } + req.currentUser = user; next(); } catch (error) { diff --git a/backend/routes/task-events.js b/backend/routes/task-events.js index 16a26df..3e01d3a 100644 --- a/backend/routes/task-events.js +++ b/backend/routes/task-events.js @@ -16,8 +16,21 @@ router.get('/task/:uid/timeline', async (req, res) => { if (!isValidUid(req.params.uid)) return res.status(400).json({ error: 'Invalid UID' }); + const permissionsService = require('../services/permissionsService'); + + // Check if user has access to the task (either owns it or has access through shared project) + const access = await permissionsService.getAccess( + req.currentUser.id, + 'task', + req.params.uid + ); + + if (access === 'none') { + return res.status(404).json({ error: 'Task not found' }); + } + const task = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, + where: { uid: req.params.uid }, }); if (!task) { @@ -39,8 +52,21 @@ router.get('/task/:uid/completion-time', async (req, res) => { if (!isValidUid(req.params.uid)) return res.status(400).json({ error: 'Invalid UID' }); + const permissionsService = require('../services/permissionsService'); + + // Check if user has access to the task (either owns it or has access through shared project) + const access = await permissionsService.getAccess( + req.currentUser.id, + 'task', + req.params.uid + ); + + if (access === 'none') { + return res.status(404).json({ error: 'Task not found' }); + } + const task = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, + where: { uid: req.params.uid }, }); if (!task) { diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 445dedc..52bebcb 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -838,24 +838,28 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { const tasksDueToday = await Task.findAll({ where: { - ...visibleTasksWhere, - status: { - [Op.notIn]: [ - Task.STATUS.DONE, - Task.STATUS.ARCHIVED, - 'done', - 'archived', - ], - }, - parent_task_id: null, // Exclude subtasks - recurring_parent_id: null, // Exclude recurring instances - [Op.or]: [ - { due_date: { [Op.lte]: todayBounds.end } }, - sequelize.literal(`EXISTS ( - SELECT 1 FROM projects - WHERE projects.id = Task.project_id + [Op.and]: [ + visibleTasksWhere, + { + status: { + [Op.notIn]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }, + parent_task_id: null, // Exclude subtasks + recurring_parent_id: null, // Exclude recurring instances + [Op.or]: [ + { due_date: { [Op.lte]: todayBounds.end } }, + sequelize.literal(`EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = Task.project_id AND projects.due_date_at <= '${todayBounds.end.toISOString()}' )`), + ], + }, ], }, include: [ diff --git a/backend/services/permissionsService.js b/backend/services/permissionsService.js index 3d971fd..f55935e 100644 --- a/backend/services/permissionsService.js +++ b/backend/services/permissionsService.js @@ -29,19 +29,57 @@ async function getAccess(userId, resourceType, resourceUid) { } else if (resourceType === 'task') { const t = await Task.findOne({ where: { uid: resourceUid }, - attributes: ['user_id'], + attributes: ['user_id', 'project_id'], raw: true, }); if (!t) return ACCESS.NONE; if (t.user_id === userId) return ACCESS.RW; + + // Check if user has access through the parent project + if (t.project_id) { + const project = await Project.findOne({ + where: { id: t.project_id }, + attributes: ['uid'], + raw: true, + }); + if (project) { + const projectAccess = await getAccess( + userId, + 'project', + project.uid + ); + if (projectAccess !== ACCESS.NONE) { + return projectAccess; // Inherit access from project + } + } + } } else if (resourceType === 'note') { const n = await Note.findOne({ where: { uid: resourceUid }, - attributes: ['user_id'], + attributes: ['user_id', 'project_id'], raw: true, }); if (!n) return ACCESS.NONE; if (n.user_id === userId) return ACCESS.RW; + + // Check if user has access through the parent project + if (n.project_id) { + const project = await Project.findOne({ + where: { id: n.project_id }, + attributes: ['uid'], + raw: true, + }); + if (project) { + const projectAccess = await getAccess( + userId, + 'project', + project.uid + ); + if (projectAccess !== ACCESS.NONE) { + return projectAccess; // Inherit access from project + } + } + } } // shared @@ -59,11 +97,83 @@ async function getAccess(userId, resourceType, resourceUid) { async function ownershipOrPermissionWhere(resourceType, userId) { // Admin users can see all resources - if (await isAdmin(userId)) { + // Note: isAdmin expects a UID, but we might receive a numeric ID + // Get the user's UID if we received a numeric ID + let userUid = userId; + if (typeof userId === 'number' || !isNaN(parseInt(userId))) { + const { User } = require('../models'); + const user = await User.findByPk(userId, { + attributes: ['uid', 'email'], + }); + if (user) { + userUid = user.uid; + console.log( + `[PERMISSIONS DEBUG] User lookup: ID=${userId}, UID=${userUid}, Email=${user.email}` + ); + } + } + + const isUserAdmin = await isAdmin(userUid); + console.log( + `[PERMISSIONS DEBUG] Resource: ${resourceType}, UserId: ${userId}, IsAdmin: ${isUserAdmin}` + ); + + if (isUserAdmin) { + console.log( + `[PERMISSIONS DEBUG] User is admin, returning empty where clause (all resources visible)` + ); return {}; // empty where clause = no restriction } const sharedUids = await getSharedUidsForUser(resourceType, userId); + console.log( + `[PERMISSIONS DEBUG] Shared ${resourceType} UIDs for user ${userId}:`, + sharedUids + ); + + // For tasks and notes, also include items from shared projects + if (resourceType === 'task' || resourceType === 'note') { + const sharedProjectUids = await getSharedUidsForUser('project', userId); + console.log( + `[PERMISSIONS DEBUG] Shared project UIDs for user ${userId}:`, + sharedProjectUids + ); + + // Get the project IDs for shared projects + let sharedProjectIds = []; + if (sharedProjectUids.length > 0) { + const projects = await Project.findAll({ + where: { uid: { [Op.in]: sharedProjectUids } }, + attributes: ['id'], + raw: true, + }); + sharedProjectIds = projects.map((p) => p.id); + console.log( + `[PERMISSIONS DEBUG] Shared project IDs for user ${userId}:`, + sharedProjectIds + ); + } + + const conditions = [ + { user_id: userId }, // Items owned by user + ]; + + if (sharedUids.length > 0) { + conditions.push({ uid: { [Op.in]: sharedUids } }); // Items directly shared with user + } + + if (sharedProjectIds.length > 0) { + conditions.push({ project_id: { [Op.in]: sharedProjectIds } }); // Items in shared projects + } + + console.log( + `[PERMISSIONS DEBUG] Final where conditions for ${resourceType}:`, + JSON.stringify(conditions) + ); + return { [Op.or]: conditions }; + } + + // For other resource types (projects, etc.), use the original logic return { [Op.or]: [ { user_id: userId }, diff --git a/backend/tests/integration/project-sharing.test.js b/backend/tests/integration/project-sharing.test.js new file mode 100644 index 0000000..947e342 --- /dev/null +++ b/backend/tests/integration/project-sharing.test.js @@ -0,0 +1,346 @@ +const request = require('supertest'); +const app = require('../../app'); +const { + User, + Project, + Task, + Note, + Permission, + sequelize, +} = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); + +describe('Project Sharing Integration Tests', () => { + let ownerUser, sharedUser, ownerAgent, sharedUserAgent, project; + + beforeEach(async () => { + // Create test users using test helper + ownerUser = await createTestUser({ + email: `owner_${Date.now()}@test.com`, + name: 'Owner', + timezone: 'UTC', + }); + + sharedUser = await createTestUser({ + email: `shared_${Date.now()}@test.com`, + name: 'Shared User', + timezone: 'UTC', + }); + + // Create agents for both users (maintains sessions) + ownerAgent = request.agent(app); + sharedUserAgent = request.agent(app); + + // Login as owner + await ownerAgent + .post('/api/login') + .send({ email: ownerUser.email, password: 'password123' }); + + // Login as shared user + await sharedUserAgent + .post('/api/login') + .send({ email: sharedUser.email, password: 'password123' }); + + // Create a project as owner + const projectResponse = await ownerAgent.post('/api/project').send({ + name: 'Shared Test Project', + description: 'Project for sharing tests', + }); + project = projectResponse.body; + + // Share the project with read-write access + await ownerAgent.post('/api/shares').send({ + resource_type: 'project', + resource_uid: project.uid, + target_user_email: sharedUser.email, + access_level: 'rw', + }); + }); + + afterAll(async () => { + await sequelize.close(); + }); + + describe('Issue 1: Task and Note Visibility in Shared Projects', () => { + test('shared user should see tasks in shared project', async () => { + // Owner creates a task in the shared project + const taskResponse = await ownerAgent.post('/api/task').send({ + name: 'Task by owner in shared project', + project_id: project.id, + priority: 1, + status: 0, + }); + const taskInSharedProject = taskResponse.body; + + // Shared user should see this task + const response = await sharedUserAgent.get('/api/tasks'); + + expect(response.status).toBe(200); + expect(response.body.tasks).toBeDefined(); + + const foundTask = response.body.tasks.find( + (t) => t.id === taskInSharedProject.id + ); + expect(foundTask).toBeDefined(); + expect(foundTask.name).toBe('Task by owner in shared project'); + }); + + test('shared user should see notes in shared project', async () => { + // Owner creates a note in the shared project + const noteResponse = await ownerAgent.post('/api/note').send({ + title: 'Note by owner in shared project', + content: 'This note should be visible to shared user', + project_uid: project.uid, + }); + const noteInSharedProject = noteResponse.body; + + // Shared user should see this note + const response = await sharedUserAgent.get('/api/notes'); + + expect(response.status).toBe(200); + expect(response.body).toBeDefined(); + + const foundNote = response.body.find( + (n) => n.id === noteInSharedProject.id + ); + expect(foundNote).toBeDefined(); + expect(foundNote.title).toBe('Note by owner in shared project'); + }); + + test('shared user should NOT see tasks in non-shared projects', async () => { + // Create another project (not shared) + const privateProjectResponse = await ownerAgent + .post('/api/project') + .send({ + name: 'Private Project', + description: 'This should not be visible', + }); + const privateProject = privateProjectResponse.body; + + // Create task in private project + const privateTaskResponse = await ownerAgent + .post('/api/task') + .send({ + name: 'Private task', + project_id: privateProject.id, + priority: 1, + status: 0, + }); + const privateTask = privateTaskResponse.body; + + // Shared user should NOT see this task + const response = await sharedUserAgent.get('/api/tasks'); + + expect(response.status).toBe(200); + const foundTask = response.body.tasks.find( + (t) => t.id === privateTask.id + ); + expect(foundTask).toBeUndefined(); + }); + + test('shared user should see tasks due today in shared projects', async () => { + // Create a task due today in shared project + const today = new Date().toISOString().split('T')[0]; + const taskDueTodayResponse = await ownerAgent + .post('/api/task') + .send({ + name: 'Task due today in shared project', + project_id: project.id, + due_date: today, + priority: 1, + status: 0, + }); + const taskDueToday = taskDueTodayResponse.body; + + // Fetch today's tasks as shared user + const response = await sharedUserAgent.get('/api/tasks?type=today'); + + expect(response.status).toBe(200); + expect(response.body.tasks).toBeDefined(); + + // Check if the task appears in the main tasks or metrics + const allTasks = [ + ...response.body.tasks, + ...(response.body.metrics?.tasks_due_today || []), + ]; + + const foundTask = allTasks.find((t) => t.id === taskDueToday.id); + expect(foundTask).toBeDefined(); + expect(foundTask.name).toBe('Task due today in shared project'); + }); + }); + + describe('Issue 2: Task and Note Creation in Shared Projects', () => { + test('shared user with RW access can create tasks in shared project', async () => { + const response = await sharedUserAgent.post('/api/task').send({ + name: 'Task created by shared user', + project_id: project.id, + priority: 1, + status: 0, + }); + + expect(response.status).toBe(201); + expect(response.body).toBeDefined(); + expect(response.body.name).toBe('Task created by shared user'); + expect(response.body.project_id).toBe(project.id); + }); + + test('shared user with RW access can create notes in shared project', async () => { + const response = await sharedUserAgent.post('/api/note').send({ + title: 'Note created by shared user', + content: 'Content of the note', + project_uid: project.uid, + }); + + expect(response.status).toBe(201); + expect(response.body).toBeDefined(); + expect(response.body.title).toBe('Note created by shared user'); + }); + + test('shared user with RO access cannot create tasks', async () => { + // Change permission to read-only + await Permission.update( + { access_level: 'ro' }, + { + where: { + resource_uid: project.uid, + user_id: sharedUser.id, + }, + } + ); + + const response = await sharedUserAgent.post('/api/task').send({ + name: 'Task that should fail', + project_id: project.id, + priority: 1, + status: 0, + }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden'); + }); + + test('shared user with RO access cannot create notes', async () => { + // Change permission to read-only + await Permission.update( + { access_level: 'ro' }, + { + where: { + resource_uid: project.uid, + user_id: sharedUser.id, + }, + } + ); + + const response = await sharedUserAgent.post('/api/note').send({ + title: 'Note that should fail', + content: 'Content', + project_uid: project.uid, + }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden'); + }); + }); + + describe('Task Timeline Access', () => { + test('shared user can access task timeline in shared project', async () => { + // Create a task in the shared project + const taskResponse = await ownerAgent.post('/api/task').send({ + name: 'Task with timeline', + project_id: project.id, + priority: 1, + status: 0, + }); + const taskInSharedProject = taskResponse.body; + + // Shared user should be able to access the timeline + const response = await sharedUserAgent.get( + `/api/task/${taskInSharedProject.uid}/timeline` + ); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + }); + + test('shared user cannot access task timeline in non-shared project', async () => { + // Create a private project and task + const privateProjectResponse = await ownerAgent + .post('/api/project') + .send({ + name: 'Private Project for Timeline', + }); + const privateProject = privateProjectResponse.body; + + const privateTaskResponse = await ownerAgent + .post('/api/task') + .send({ + name: 'Private task', + project_id: privateProject.id, + priority: 1, + status: 0, + }); + const privateTask = privateTaskResponse.body; + + // Shared user should NOT access this timeline + const response = await sharedUserAgent.get( + `/api/task/${privateTask.uid}/timeline` + ); + + expect(response.status).toBe(404); + }); + + test('shared user can access completion time analytics', async () => { + // Create a task in the shared project + const taskResponse = await ownerAgent.post('/api/task').send({ + name: 'Task for completion analytics', + project_id: project.id, + priority: 1, + status: 0, + }); + const taskInSharedProject = taskResponse.body; + + const response = await sharedUserAgent.get( + `/api/task/${taskInSharedProject.uid}/completion-time` + ); + + // Should return 404 if not completed, or 200 with data if completed + expect([200, 404]).toContain(response.status); + }); + }); + + describe('Owner sees their own tasks correctly', () => { + test('owner should see all their tasks including those in shared projects', async () => { + // Create a task in the shared project + await ownerAgent.post('/api/task').send({ + name: 'Owner task in shared project', + project_id: project.id, + priority: 1, + status: 0, + }); + + const response = await ownerAgent.get('/api/tasks'); + + expect(response.status).toBe(200); + expect(response.body.tasks).toBeDefined(); + expect(response.body.tasks.length).toBeGreaterThan(0); + }); + + test('owner should see tasks due today including shared project tasks', async () => { + const today = new Date().toISOString().split('T')[0]; + + await ownerAgent.post('/api/task').send({ + name: 'Owner task due today', + project_id: project.id, + due_date: today, + priority: 1, + status: 0, + }); + + const response = await ownerAgent.get('/api/tasks?type=today'); + + expect(response.status).toBe(200); + expect(response.body.tasks).toBeDefined(); + }); + }); +}); diff --git a/backend/tests/integration/users.test.js b/backend/tests/integration/users.test.js index 65acc61..78da05e 100644 --- a/backend/tests/integration/users.test.js +++ b/backend/tests/integration/users.test.js @@ -8,13 +8,13 @@ describe('Users Routes', () => { beforeEach(async () => { user = await createTestUser({ - email: 'test@example.com', + email: `test_${Date.now()}@example.com`, }); // Create authenticated agent agent = request.agent(app); await agent.post('/api/login').send({ - email: 'test@example.com', + email: user.email, password: 'password123', }); }); diff --git a/backend/tests/unit/services/parentChildRelationship.test.js b/backend/tests/unit/services/parentChildRelationship.test.js index 8469cfb..c2943c9 100644 --- a/backend/tests/unit/services/parentChildRelationship.test.js +++ b/backend/tests/unit/services/parentChildRelationship.test.js @@ -466,13 +466,13 @@ describe('Parent-Child Relationship Functionality', () => { const uniqueDueDates = [...new Set(dueDates)]; expect(uniqueDueDates.length).toBe(dueDates.length); - // Verify children have sequential due dates (within tolerance for floating point) + // Verify children have sequential due dates (within tolerance for DST transitions) const sortedDueDates = dueDates.sort(); for (let i = 1; i < sortedDueDates.length; i++) { const dayDiff = (sortedDueDates[i] - sortedDueDates[i - 1]) / (24 * 60 * 60 * 1000); - expect(Math.abs(dayDiff - 1)).toBeLessThan(0.001); // Each task should be ~1 day apart + expect(Math.abs(dayDiff - 1)).toBeLessThan(0.05); // Each task should be ~1 day apart (allowing for DST) } }); diff --git a/package-lock.json b/package-lock.json index bb90ecc..bbeeea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tududi", - "version": "v0.84.1-rc1", + "version": "v0.84.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tududi", - "version": "v0.84.1-rc1", + "version": "v0.84.1", "license": "ISC", "dependencies": { "@heroicons/react": "^2.1.5", @@ -89,6 +89,7 @@ "sequelize-cli": "~6.6.2", "style-loader": "^4.0.0", "supertest": "~7.1.1", + "supertest-session": "^5.0.1", "swc-loader": "^0.2.6", "tailwindcss": "^3.4.13", "ts-jest": "^29.0.0", @@ -17934,6 +17935,21 @@ "node": ">=14.18.0" } }, + "node_modules/supertest-session": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/supertest-session/-/supertest-session-5.0.1.tgz", + "integrity": "sha512-RpR8tGQZGreQsOCiW3YMSPKMwPlAB8lA0Jyat+8VUSJaYvLHTMqhMW6gooJ2htzjr3w/kgqJTQDnmuFenzA9JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookiejar": "^2.1.2", + "methods": "^1.1.2", + "object-assign": "^4.0.1" + }, + "peerDependencies": { + "supertest": ">= 3.1.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 6c4c52b..27bcbd2 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "sequelize-cli": "~6.6.2", "style-loader": "^4.0.0", "supertest": "~7.1.1", + "supertest-session": "^5.0.1", "swc-loader": "^0.2.6", "tailwindcss": "^3.4.13", "ts-jest": "^29.0.0",