fix(#411): complete 2026-02-17 remediation sweep
Apply RLS context at task service boundaries, harden orchestrator/web integration and session startup behavior, re-enable targeted frontend tests, and lock vulnerable transitive dependencies so QA and security gates pass cleanly.
This commit is contained in:
@@ -25,6 +25,8 @@ describe("TasksController", () => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
};
|
||||
return true;
|
||||
@@ -46,6 +48,8 @@ describe("TasksController", () => {
|
||||
const mockRequest = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
};
|
||||
@@ -132,13 +136,16 @@ describe("TasksController", () => {
|
||||
|
||||
mockTasksService.findAll.mockResolvedValue(paginatedResult);
|
||||
|
||||
const result = await controller.findAll(query, mockWorkspaceId);
|
||||
const result = await controller.findAll(query, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(result).toEqual(paginatedResult);
|
||||
expect(service.findAll).toHaveBeenCalledWith({
|
||||
...query,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
expect(service.findAll).toHaveBeenCalledWith(
|
||||
{
|
||||
...query,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
mockUserId
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract workspaceId from request.user if not in query", async () => {
|
||||
@@ -149,12 +156,13 @@ describe("TasksController", () => {
|
||||
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
|
||||
});
|
||||
|
||||
await controller.findAll(query as any, mockWorkspaceId);
|
||||
await controller.findAll(query as any, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(service.findAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: mockWorkspaceId,
|
||||
})
|
||||
}),
|
||||
mockUserId
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -163,10 +171,10 @@ describe("TasksController", () => {
|
||||
it("should return a task by id", async () => {
|
||||
mockTasksService.findOne.mockResolvedValue(mockTask);
|
||||
|
||||
const result = await controller.findOne(mockTaskId, mockWorkspaceId);
|
||||
const result = await controller.findOne(mockTaskId, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(result).toEqual(mockTask);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
|
||||
it("should throw error if workspaceId not found", async () => {
|
||||
@@ -175,10 +183,10 @@ describe("TasksController", () => {
|
||||
// We can test that the controller properly uses the provided workspaceId instead
|
||||
mockTasksService.findOne.mockResolvedValue(mockTask);
|
||||
|
||||
const result = await controller.findOne(mockTaskId, mockWorkspaceId);
|
||||
const result = await controller.findOne(mockTaskId, mockWorkspaceId, mockRequest.user);
|
||||
|
||||
expect(result).toEqual(mockTask);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -53,8 +53,12 @@ export class TasksController {
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(@Query() query: QueryTasksDto, @Workspace() workspaceId: string) {
|
||||
return this.tasksService.findAll(Object.assign({}, query, { workspaceId }));
|
||||
async findAll(
|
||||
@Query() query: QueryTasksDto,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.tasksService.findAll(Object.assign({}, query, { workspaceId }), user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +68,12 @@ export class TasksController {
|
||||
*/
|
||||
@Get(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(@Param("id") id: string, @Workspace() workspaceId: string) {
|
||||
return this.tasksService.findOne(id, workspaceId);
|
||||
async findOne(
|
||||
@Param("id") id: string,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.tasksService.findOne(id, workspaceId, user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("TasksService", () => {
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
withWorkspaceContext: vi.fn(),
|
||||
};
|
||||
|
||||
const mockActivityService = {
|
||||
@@ -75,6 +76,9 @@ describe("TasksService", () => {
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
mockPrismaService.withWorkspaceContext.mockImplementation(async (_userId, _workspaceId, fn) => {
|
||||
return fn(mockPrismaService as unknown as PrismaService);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
@@ -95,6 +99,11 @@ describe("TasksService", () => {
|
||||
const result = await service.create(mockWorkspaceId, mockUserId, createDto);
|
||||
|
||||
expect(result).toEqual(mockTask);
|
||||
expect(prisma.withWorkspaceContext).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockWorkspaceId,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(prisma.task.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
title: createDto.title,
|
||||
@@ -177,6 +186,29 @@ describe("TasksService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use workspace context when userId is provided", async () => {
|
||||
mockPrismaService.task.findMany.mockResolvedValue([mockTask]);
|
||||
mockPrismaService.task.count.mockResolvedValue(1);
|
||||
|
||||
await service.findAll({ workspaceId: mockWorkspaceId }, mockUserId);
|
||||
|
||||
expect(prisma.withWorkspaceContext).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockWorkspaceId,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback to direct Prisma access when userId is missing", async () => {
|
||||
mockPrismaService.task.findMany.mockResolvedValue([mockTask]);
|
||||
mockPrismaService.task.count.mockResolvedValue(1);
|
||||
|
||||
await service.findAll({ workspaceId: mockWorkspaceId });
|
||||
|
||||
expect(prisma.withWorkspaceContext).not.toHaveBeenCalled();
|
||||
expect(prisma.task.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
mockPrismaService.task.findMany.mockResolvedValue([mockTask]);
|
||||
mockPrismaService.task.count.mockResolvedValue(1);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, Task } from "@prisma/client";
|
||||
import { Prisma, Task, TaskStatus, TaskPriority, type PrismaClient } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ActivityService } from "../activity/activity.service";
|
||||
import { TaskStatus, TaskPriority } from "@prisma/client";
|
||||
import type { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from "./dto";
|
||||
|
||||
type TaskWithRelations = Task & {
|
||||
@@ -24,6 +23,18 @@ export class TasksService {
|
||||
private readonly activityService: ActivityService
|
||||
) {}
|
||||
|
||||
private async withWorkspaceContextIfAvailable<T>(
|
||||
workspaceId: string | undefined,
|
||||
userId: string | undefined,
|
||||
fn: (client: PrismaClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (workspaceId && userId && typeof this.prisma.withWorkspaceContext === "function") {
|
||||
return this.prisma.withWorkspaceContext(userId, workspaceId, fn);
|
||||
}
|
||||
|
||||
return fn(this.prisma);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
@@ -66,19 +77,21 @@ export class TasksService {
|
||||
data.completedAt = new Date();
|
||||
}
|
||||
|
||||
const task = await this.prisma.task.create({
|
||||
data,
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
const task = await this.withWorkspaceContextIfAvailable(workspaceId, userId, async (client) => {
|
||||
return client.task.create({
|
||||
data,
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Log activity
|
||||
@@ -92,7 +105,10 @@ export class TasksService {
|
||||
/**
|
||||
* Get paginated tasks with filters
|
||||
*/
|
||||
async findAll(query: QueryTasksDto): Promise<{
|
||||
async findAll(
|
||||
query: QueryTasksDto,
|
||||
userId?: string
|
||||
): Promise<{
|
||||
data: Omit<TaskWithRelations, "subtasks">[];
|
||||
meta: {
|
||||
total: number;
|
||||
@@ -143,28 +159,34 @@ export class TasksService {
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.task.findMany({
|
||||
where,
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.task.count({ where }),
|
||||
]);
|
||||
const [data, total] = await this.withWorkspaceContextIfAvailable(
|
||||
query.workspaceId,
|
||||
userId,
|
||||
async (client) => {
|
||||
return Promise.all([
|
||||
client.task.findMany({
|
||||
where,
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
client.task.count({ where }),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
@@ -180,30 +202,32 @@ export class TasksService {
|
||||
/**
|
||||
* Get a single task by ID
|
||||
*/
|
||||
async findOne(id: string, workspaceId: string): Promise<TaskWithRelations> {
|
||||
const task = await this.prisma.task.findUnique({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
async findOne(id: string, workspaceId: string, userId?: string): Promise<TaskWithRelations> {
|
||||
const task = await this.withWorkspaceContextIfAvailable(workspaceId, userId, async (client) => {
|
||||
return client.task.findUnique({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
subtasks: {
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
subtasks: {
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
@@ -222,82 +246,89 @@ export class TasksService {
|
||||
userId: string,
|
||||
updateTaskDto: UpdateTaskDto
|
||||
): Promise<Omit<TaskWithRelations, "subtasks">> {
|
||||
// Verify task exists
|
||||
const existingTask = await this.prisma.task.findUnique({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
const { task, existingTask } = await this.withWorkspaceContextIfAvailable(
|
||||
workspaceId,
|
||||
userId,
|
||||
async (client) => {
|
||||
const existingTask = await client.task.findUnique({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
throw new NotFoundException(`Task with ID ${id} not found`);
|
||||
}
|
||||
if (!existingTask) {
|
||||
throw new NotFoundException(`Task with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Build update data - only include defined fields
|
||||
const data: Prisma.TaskUpdateInput = {};
|
||||
// Build update data - only include defined fields
|
||||
const data: Prisma.TaskUpdateInput = {};
|
||||
|
||||
if (updateTaskDto.title !== undefined) {
|
||||
data.title = updateTaskDto.title;
|
||||
}
|
||||
if (updateTaskDto.description !== undefined) {
|
||||
data.description = updateTaskDto.description;
|
||||
}
|
||||
if (updateTaskDto.status !== undefined) {
|
||||
data.status = updateTaskDto.status;
|
||||
}
|
||||
if (updateTaskDto.priority !== undefined) {
|
||||
data.priority = updateTaskDto.priority;
|
||||
}
|
||||
if (updateTaskDto.dueDate !== undefined) {
|
||||
data.dueDate = updateTaskDto.dueDate;
|
||||
}
|
||||
if (updateTaskDto.sortOrder !== undefined) {
|
||||
data.sortOrder = updateTaskDto.sortOrder;
|
||||
}
|
||||
if (updateTaskDto.metadata !== undefined) {
|
||||
data.metadata = updateTaskDto.metadata as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
if (updateTaskDto.assigneeId !== undefined && updateTaskDto.assigneeId !== null) {
|
||||
data.assignee = { connect: { id: updateTaskDto.assigneeId } };
|
||||
}
|
||||
if (updateTaskDto.projectId !== undefined && updateTaskDto.projectId !== null) {
|
||||
data.project = { connect: { id: updateTaskDto.projectId } };
|
||||
}
|
||||
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
|
||||
data.parent = { connect: { id: updateTaskDto.parentId } };
|
||||
}
|
||||
if (updateTaskDto.title !== undefined) {
|
||||
data.title = updateTaskDto.title;
|
||||
}
|
||||
if (updateTaskDto.description !== undefined) {
|
||||
data.description = updateTaskDto.description;
|
||||
}
|
||||
if (updateTaskDto.status !== undefined) {
|
||||
data.status = updateTaskDto.status;
|
||||
}
|
||||
if (updateTaskDto.priority !== undefined) {
|
||||
data.priority = updateTaskDto.priority;
|
||||
}
|
||||
if (updateTaskDto.dueDate !== undefined) {
|
||||
data.dueDate = updateTaskDto.dueDate;
|
||||
}
|
||||
if (updateTaskDto.sortOrder !== undefined) {
|
||||
data.sortOrder = updateTaskDto.sortOrder;
|
||||
}
|
||||
if (updateTaskDto.metadata !== undefined) {
|
||||
data.metadata = updateTaskDto.metadata as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
if (updateTaskDto.assigneeId !== undefined && updateTaskDto.assigneeId !== null) {
|
||||
data.assignee = { connect: { id: updateTaskDto.assigneeId } };
|
||||
}
|
||||
if (updateTaskDto.projectId !== undefined && updateTaskDto.projectId !== null) {
|
||||
data.project = { connect: { id: updateTaskDto.projectId } };
|
||||
}
|
||||
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
|
||||
data.parent = { connect: { id: updateTaskDto.parentId } };
|
||||
}
|
||||
|
||||
// Handle completedAt based on status changes
|
||||
if (updateTaskDto.status) {
|
||||
if (
|
||||
updateTaskDto.status === TaskStatus.COMPLETED &&
|
||||
existingTask.status !== TaskStatus.COMPLETED
|
||||
) {
|
||||
data.completedAt = new Date();
|
||||
} else if (
|
||||
updateTaskDto.status !== TaskStatus.COMPLETED &&
|
||||
existingTask.status === TaskStatus.COMPLETED
|
||||
) {
|
||||
data.completedAt = null;
|
||||
// Handle completedAt based on status changes
|
||||
if (updateTaskDto.status) {
|
||||
if (
|
||||
updateTaskDto.status === TaskStatus.COMPLETED &&
|
||||
existingTask.status !== TaskStatus.COMPLETED
|
||||
) {
|
||||
data.completedAt = new Date();
|
||||
} else if (
|
||||
updateTaskDto.status !== TaskStatus.COMPLETED &&
|
||||
existingTask.status === TaskStatus.COMPLETED
|
||||
) {
|
||||
data.completedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const task = await client.task.update({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
data,
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { task, existingTask };
|
||||
}
|
||||
}
|
||||
|
||||
const task = await this.prisma.task.update({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
data,
|
||||
include: {
|
||||
assignee: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
creator: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Log activities
|
||||
await this.activityService.logTaskUpdated(workspaceId, userId, id, {
|
||||
@@ -332,20 +363,23 @@ export class TasksService {
|
||||
* Delete a task
|
||||
*/
|
||||
async remove(id: string, workspaceId: string, userId: string): Promise<void> {
|
||||
// Verify task exists
|
||||
const task = await this.prisma.task.findUnique({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
const task = await this.withWorkspaceContextIfAvailable(workspaceId, userId, async (client) => {
|
||||
const task = await client.task.findUnique({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Task with ID ${id} not found`);
|
||||
}
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Task with ID ${id} not found`);
|
||||
}
|
||||
|
||||
await this.prisma.task.delete({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
await client.task.delete({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return task;
|
||||
});
|
||||
|
||||
// Log activity
|
||||
|
||||
Reference in New Issue
Block a user