Merge pull request #439 from chrisvel/fix-project-sharing

Fix an issue with tasks permissions on shared project
This commit is contained in:
Chris 2025-10-22 16:29:27 +03:00 committed by GitHub
commit ba34e2f3f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 537 additions and 27 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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: [

View file

@ -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 },

View file

@ -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();
});
});
});

View file

@ -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',
});
});

View file

@ -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)
}
});

20
package-lock.json generated
View file

@ -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",

View file

@ -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",