Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/src/lib/authorization/ownership.ts
Original file line number Diff line number Diff line change
@@ -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<TUserIdColumn extends AnyColumn<{ data: string }>>(
userId: string,
userIdColumn: TUserIdColumn,
) {
return eq(userIdColumn, userId);
}
13 changes: 7 additions & 6 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
Expand All @@ -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,
Expand All @@ -77,7 +78,7 @@ export class NotificationsService {
.where(
and(
eq(userNotifications.id, notificationId),
eq(userNotifications.userId, userId),
ownedBy(userId, userNotifications.userId),
),
)
.returning();
Expand All @@ -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),
),
Expand All @@ -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,
),
)
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 6 additions & 5 deletions backend/src/modules/savedJobs/savedJobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -10,14 +11,14 @@ export class SavedJobsService {

async getAll(userId: string): Promise<SavedJob[]> {
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<SavedJob | undefined> {
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)),
});
}

Expand All @@ -27,7 +28,7 @@ export class SavedJobsService {
): Promise<SavedJob> {
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) {
Expand Down Expand Up @@ -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]) {
Expand All @@ -72,6 +73,6 @@ export class SavedJobsService {
async delete(userId: string, jobId: string): Promise<void> {
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)));
}
}
6 changes: 3 additions & 3 deletions backend/src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,7 +30,7 @@ export class UsersService {

async getPreferences(userId: string): Promise<UserPreferences | undefined> {
return this.tx.query.userPreferences.findFirst({
where: (p, { eq }) => eq(p.userId, userId),
where: (p) => ownedBy(userId, p.userId),
});
}

Expand All @@ -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]) {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/keywords.routes.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });
Expand Down
43 changes: 43 additions & 0 deletions backend/tests/unit/lib/authorization/ownership.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("drizzle-orm")>();
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");
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof import("drizzle-orm")>();
return {
...actual,
and: drizzleMocks.and,
eq: drizzleMocks.eq,
};
});

import type {
NewUserNotification,
SavedJob,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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(),
});
Expand Down Expand Up @@ -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" },
]);
});
});
Loading
Loading