diff --git a/backend/src/lib/authorization/ownership.ts b/backend/src/lib/authorization/ownership.ts new file mode 100644 index 0000000..eb605b1 --- /dev/null +++ b/backend/src/lib/authorization/ownership.ts @@ -0,0 +1,19 @@ +import { eq, type AnyColumn } from "drizzle-orm"; +import { AppError } from "../errors"; + +export function assertOwnsResource( + sessionUserId: string, + resourceUserId: string, + notFoundMessage = "Recurso não encontrado.", +): void { + if (sessionUserId !== resourceUserId) { + throw AppError.notFound(notFoundMessage); + } +} + +export function ownedBy>( + userId: string, + userIdColumn: TUserIdColumn, +) { + return eq(userIdColumn, userId); +} diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 14e67f1..ea713a3 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -2,6 +2,7 @@ import { and, count, desc, eq, isNull } from "drizzle-orm"; import { db } from "../../db/client"; import { NewUserNotification, SavedJob, userNotifications } from "../../db/schema"; import { DB } from "../../db/types/types"; +import { ownedBy } from "../../lib/authorization/ownership"; import { AppError } from "../../lib/errors"; import { jobNotificationIdentity, @@ -27,7 +28,7 @@ export class NotificationsService { constructor(private readonly tx: DB = db) {} async list(userId: string, filters: ListNotificationsQuery) { - const conditions = [eq(userNotifications.userId, userId)]; + const conditions = [ownedBy(userId, userNotifications.userId)]; if (filters.channel) { conditions.push(eq(userNotifications.channel, filters.channel)); @@ -50,7 +51,7 @@ export class NotificationsService { .from(userNotifications) .where( and( - eq(userNotifications.userId, userId), + ownedBy(userId, userNotifications.userId), filters.channel ? eq(userNotifications.channel, filters.channel) : undefined, @@ -77,7 +78,7 @@ export class NotificationsService { .where( and( eq(userNotifications.id, notificationId), - eq(userNotifications.userId, userId), + ownedBy(userId, userNotifications.userId), ), ) .returning(); @@ -95,7 +96,7 @@ export class NotificationsService { .set({ readAt: new Date() }) .where( and( - eq(userNotifications.userId, userId), + ownedBy(userId, userNotifications.userId), channel ? eq(userNotifications.channel, channel) : undefined, isNull(userNotifications.readAt), ), @@ -110,7 +111,7 @@ export class NotificationsService { .delete(userNotifications) .where( and( - eq(userNotifications.userId, userId), + ownedBy(userId, userNotifications.userId), channel ? eq(userNotifications.channel, channel) : undefined, ), ) @@ -181,7 +182,7 @@ export class NotificationsService { const existing = await this.tx.query.userNotifications.findFirst({ where: (notification, { and, eq }) => and( - eq(notification.userId, userId), + ownedBy(userId, notification.userId), eq(notification.type, "high_match"), eq(notification.entityType, "job"), eq(notification.entityId, entityId), diff --git a/backend/src/modules/savedJobs/savedJobs.service.ts b/backend/src/modules/savedJobs/savedJobs.service.ts index bbd27d9..eb08a1f 100644 --- a/backend/src/modules/savedJobs/savedJobs.service.ts +++ b/backend/src/modules/savedJobs/savedJobs.service.ts @@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm"; import { db } from "../../db/client"; import { NewSavedJob, SavedJob, savedJobs } from "../../db/schema"; import { DB } from "../../db/types/types"; +import { ownedBy } from "../../lib/authorization/ownership"; import { AppError } from "../../lib/errors"; import { NotificationsService } from "../notifications/notifications.service"; @@ -10,14 +11,14 @@ export class SavedJobsService { async getAll(userId: string): Promise { return this.tx.query.savedJobs.findMany({ - where: (j, { eq }) => eq(j.userId, userId), + where: (j) => ownedBy(userId, j.userId), orderBy: (j, { desc }) => desc(j.createdAt), }); } async getById(userId: string, jobId: string): Promise { return this.tx.query.savedJobs.findFirst({ - where: (j, { and, eq }) => and(eq(j.userId, userId), eq(j.id, jobId)), + where: (j, { and, eq }) => and(ownedBy(userId, j.userId), eq(j.id, jobId)), }); } @@ -27,7 +28,7 @@ export class SavedJobsService { ): Promise { const existing = await this.tx.query.savedJobs.findFirst({ where: (j, { and, eq }) => - and(eq(j.userId, userId), eq(j.jobLink, data.jobLink)), + and(ownedBy(userId, j.userId), eq(j.jobLink, data.jobLink)), }); if (existing) { @@ -55,7 +56,7 @@ export class SavedJobsService { const result = await this.tx .update(savedJobs) .set({ ...data, updatedAt: new Date() }) - .where(and(eq(savedJobs.id, jobId), eq(savedJobs.userId, userId))) + .where(and(eq(savedJobs.id, jobId), ownedBy(userId, savedJobs.userId))) .returning(); if (!result[0]) { @@ -72,6 +73,6 @@ export class SavedJobsService { async delete(userId: string, jobId: string): Promise { await this.tx .delete(savedJobs) - .where(and(eq(savedJobs.id, jobId), eq(savedJobs.userId, userId))); + .where(and(eq(savedJobs.id, jobId), ownedBy(userId, savedJobs.userId))); } } diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 67931de..e1c92f0 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; import { db } from "../../db/client"; import { User, UserPreferences, userPreferences, users } from "../../db/schema"; import { DB } from "../../db/types/types"; +import { ownedBy } from "../../lib/authorization/ownership"; import { AppError } from "../../lib/errors"; import { UpdateProfileData } from "../types/user.types"; import { UpdatePreferencesData } from "./schemas/user.schemas"; @@ -30,7 +30,7 @@ export class UsersService { async getPreferences(userId: string): Promise { return this.tx.query.userPreferences.findFirst({ - where: (p, { eq }) => eq(p.userId, userId), + where: (p) => ownedBy(userId, p.userId), }); } @@ -52,7 +52,7 @@ export class UsersService { const result = await this.tx .update(userPreferences) .set({ ...data, updatedAt: new Date() }) - .where(eq(userPreferences.userId, userId)) + .where(ownedBy(userId, userPreferences.userId)) .returning(); if (!result[0]) { diff --git a/backend/src/routes/keywords.routes.ts b/backend/src/routes/keywords.routes.ts index 4329586..5837df4 100644 --- a/backend/src/routes/keywords.routes.ts +++ b/backend/src/routes/keywords.routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; -import { eq } from "drizzle-orm"; import { db } from "../db/client"; import { keywords } from "../db/schema"; +import { ownedBy } from "../lib/authorization/ownership"; import { getCache } from "../lib/cache"; import { publish } from "../lib/kwsync"; @@ -25,7 +25,7 @@ keywordsRoutes.get("/", async (req, res) => { const rows = await db .select({ keyword: keywords.keyword, source: keywords.source }) .from(keywords) - .where(eq(keywords.userId, userId)) + .where(ownedBy(userId, keywords.userId)) .orderBy(keywords.createdAt); return res.json({ ok: true, keywords: rows }); diff --git a/backend/tests/unit/lib/authorization/ownership.test.ts b/backend/tests/unit/lib/authorization/ownership.test.ts new file mode 100644 index 0000000..539527f --- /dev/null +++ b/backend/tests/unit/lib/authorization/ownership.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + eq: vi.fn(), +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + eq: mocks.eq, + }; +}); + +import { + assertOwnsResource, + ownedBy, +} from "../../../../src/lib/authorization/ownership"; + +describe("ownership authorization helpers", () => { + it("permite recurso do usuário autenticado", () => { + expect(() => { + assertOwnsResource("user-1", "user-1", "Vaga"); + }).not.toThrow(); + }); + + it("retorna 404 quando o recurso pertence a outro usuário", () => { + expect(() => { + assertOwnsResource("user-1", "user-2", "Vaga não encontrada"); + }).toThrow(expect.objectContaining({ + code: "NOT_FOUND", + statusCode: 404, + message: "Vaga não encontrada", + })); + }); + + it("cria filtro Drizzle para userId", () => { + mocks.eq.mockReturnValue("owned-by-user-1"); + + expect(ownedBy("user-1", "user_id" as any)).toBe("owned-by-user-1"); + expect(mocks.eq).toHaveBeenCalledWith("user_id", "user-1"); + }); +}); diff --git a/backend/tests/unit/modules/notifications/notifications.service.test.ts b/backend/tests/unit/modules/notifications/notifications.service.test.ts index 6649547..651d74d 100644 --- a/backend/tests/unit/modules/notifications/notifications.service.test.ts +++ b/backend/tests/unit/modules/notifications/notifications.service.test.ts @@ -1,4 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; + +const drizzleMocks = vi.hoisted(() => ({ + and: vi.fn(), + eq: vi.fn(), +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + and: drizzleMocks.and, + eq: drizzleMocks.eq, + }; +}); + import type { NewUserNotification, SavedJob, @@ -94,6 +109,11 @@ describe("NotificationsService", () => { beforeEach(() => { tx = makeTx(); service = new NotificationsService(tx as any); + drizzleMocks.and.mockImplementation((...conditions) => conditions); + drizzleMocks.eq.mockImplementation((column, value) => ({ + column, + value, + })); }); it("lista notificações com filtros de canal e não lidas", async () => { @@ -113,6 +133,11 @@ describe("NotificationsService", () => { }); expect(listBuilder.limit).toHaveBeenCalledWith(10); expect(tx.select).toHaveBeenCalledTimes(2); + expect(listBuilder.where).toHaveBeenCalledWith( + expect.arrayContaining([ + { column: expect.anything(), value: "user-1" }, + ]), + ); }); it("usa unreadCount zero quando a consulta agregada não retorna linhas", async () => { @@ -155,6 +180,10 @@ describe("NotificationsService", () => { service.markRead("user-1", "notification-1"), ).resolves.toBe(notification); expect(update.set).toHaveBeenCalledWith({ readAt: expect.any(Date) }); + expect(update.where).toHaveBeenCalledWith([ + { column: expect.anything(), value: "notification-1" }, + { column: expect.anything(), value: "user-1" }, + ]); }); it("lança not found ao marcar como lida quando não encontra a notificação", async () => { @@ -176,6 +205,12 @@ describe("NotificationsService", () => { await expect(service.markAllRead("user-1", "message")).resolves.toEqual({ updated: 2, }); + expect(update.where).toHaveBeenCalledWith( + expect.arrayContaining([ + { column: expect.anything(), value: "user-1" }, + { column: expect.anything(), value: "message" }, + ]), + ); }); it("limpa notificações de um canal", async () => { @@ -187,6 +222,12 @@ describe("NotificationsService", () => { }); expect(tx.delete).toHaveBeenCalled(); + expect(deleteQuery.where).toHaveBeenCalledWith( + expect.arrayContaining([ + { column: expect.anything(), value: "user-1" }, + { column: expect.anything(), value: "notification" }, + ]), + ); expect(deleteQuery.returning).toHaveBeenCalledWith({ id: expect.anything(), }); @@ -326,4 +367,32 @@ describe("NotificationsService", () => { }), ); }); + + it("deduplica alto match usando userId para impedir colisão cross-user", async () => { + tx.query.userNotifications.findFirst.mockResolvedValue(notification); + + const result = await service.createHighMatchIfMissing("user-1", { + id: "job-1", + title: "Backend Developer", + company: "Candidate", + link: "https://jobs.test/1", + matchScore: 95, + }); + + expect(result).toBe(notification); + + const where = tx.query.userNotifications.findFirst.mock.calls[0][0].where; + const table = { + userId: "userNotifications.userId", + type: "userNotifications.type", + entityType: "userNotifications.entityType", + entityId: "userNotifications.entityId", + }; + expect(where(table, drizzleMocks)).toEqual([ + { column: "userNotifications.userId", value: "user-1" }, + { column: "userNotifications.type", value: "high_match" }, + { column: "userNotifications.entityType", value: "job" }, + { column: "userNotifications.entityId", value: "job-1" }, + ]); + }); }); diff --git a/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts b/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts index dfa5913..8247dc4 100644 --- a/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts +++ b/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts @@ -1,5 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const drizzleMocks = vi.hoisted(() => ({ + and: vi.fn(), + eq: vi.fn(), +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + and: drizzleMocks.and, + eq: drizzleMocks.eq, + }; +}); + // ─── Fixtures ───────────────────────────────────────────────────────────────── const mockJob = { @@ -42,6 +56,11 @@ describe("SavedJobsService", () => { beforeEach(() => { tx = makeMockTx(); service = new SavedJobsService(tx as any); + drizzleMocks.and.mockImplementation((...conditions) => conditions); + drizzleMocks.eq.mockImplementation((column, value) => ({ + column, + value, + })); }); // ── getAll ───────────────────────────────────────────────────────────────── @@ -76,6 +95,19 @@ describe("SavedJobsService", () => { expect(result).toEqual(mockJob); }); + it("monta filtro com jobId e userId para impedir leitura cross-user", async () => { + tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); + + await service.getById("user-1", "job-1"); + + const where = tx.query.savedJobs.findFirst.mock.calls[0][0].where; + const table = { userId: "savedJobs.userId", id: "savedJobs.id" }; + expect(where(table, drizzleMocks)).toEqual([ + { column: "savedJobs.userId", value: "user-1" }, + { column: "savedJobs.id", value: "job-1" }, + ]); + }); + it("retorna undefined quando vaga não existe", async () => { tx.query.savedJobs.findFirst.mockResolvedValue(undefined); @@ -137,6 +169,32 @@ describe("SavedJobsService", () => { expect(tx.insert).not.toHaveBeenCalled(); }); + + it("verifica duplicidade por jobLink apenas dentro do mesmo usuário", async () => { + tx.query.savedJobs.findFirst.mockResolvedValue(undefined); + tx.insert.mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ ...mockJob, ...newJobData }]), + }), + }); + tx.insert.mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: "notification-1" }]), + }), + }); + + await service.create("user-1", newJobData); + + const where = tx.query.savedJobs.findFirst.mock.calls[0][0].where; + const table = { + userId: "savedJobs.userId", + jobLink: "savedJobs.jobLink", + }; + expect(where(table, drizzleMocks)).toEqual([ + { column: "savedJobs.userId", value: "user-1" }, + { column: "savedJobs.jobLink", value: newJobData.jobLink }, + ]); + }); }); // ── update ───────────────────────────────────────────────────────────────── @@ -202,6 +260,23 @@ describe("SavedJobsService", () => { expect(setArg.updatedAt).toBeInstanceOf(Date); }); + it("atualiza usando jobId e userId para impedir alteração cross-user", async () => { + const whereMock = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockJob]), + }); + tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); + tx.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ where: whereMock }), + }); + + await service.update("user-1", "job-1", { jobTitle: "X" }); + + expect(whereMock).toHaveBeenCalledWith([ + { column: expect.anything(), value: "job-1" }, + { column: expect.anything(), value: "user-1" }, + ]); + }); + it("cria notificação quando o status da vaga muda", async () => { const previousJob = { ...mockJob, status: "saved" }; const updatedJob = { ...mockJob, status: "applied" }; @@ -252,5 +327,17 @@ describe("SavedJobsService", () => { await expect(service.delete("user-1", "job-1")).resolves.toBeUndefined(); expect(tx.delete).toHaveBeenCalledOnce(); }); + + it("deleta usando jobId e userId para impedir remoção cross-user", async () => { + const whereMock = vi.fn().mockResolvedValue(undefined); + tx.delete.mockReturnValue({ where: whereMock }); + + await service.delete("user-1", "job-1"); + + expect(whereMock).toHaveBeenCalledWith([ + { column: expect.anything(), value: "job-1" }, + { column: expect.anything(), value: "user-1" }, + ]); + }); }); });