Skip to content

Commit f99edbd

Browse files
authored
feat(PAV-10): implementa policies de ownership por usuário (#191)
- Centraliza helper de ownership para filtros por usuário. - Refatora saved jobs, notifications, preferences e keywords para usar a policy. - Adiciona testes unitários cobrindo ownership e casos cross-user.
2 parents 48ed9ad + c9cfd38 commit f99edbd

8 files changed

Lines changed: 236 additions & 16 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { eq, type AnyColumn } from "drizzle-orm";
2+
import { AppError } from "../errors";
3+
4+
export function assertOwnsResource(
5+
sessionUserId: string,
6+
resourceUserId: string,
7+
notFoundMessage = "Recurso não encontrado.",
8+
): void {
9+
if (sessionUserId !== resourceUserId) {
10+
throw AppError.notFound(notFoundMessage);
11+
}
12+
}
13+
14+
export function ownedBy<TUserIdColumn extends AnyColumn<{ data: string }>>(
15+
userId: string,
16+
userIdColumn: TUserIdColumn,
17+
) {
18+
return eq(userIdColumn, userId);
19+
}

backend/src/modules/notifications/notifications.service.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { and, count, desc, eq, isNull } from "drizzle-orm";
22
import { db } from "../../db/client";
33
import { NewUserNotification, SavedJob, userNotifications } from "../../db/schema";
44
import { DB } from "../../db/types/types";
5+
import { ownedBy } from "../../lib/authorization/ownership";
56
import { AppError } from "../../lib/errors";
67
import {
78
jobNotificationIdentity,
@@ -27,7 +28,7 @@ export class NotificationsService {
2728
constructor(private readonly tx: DB = db) {}
2829

2930
async list(userId: string, filters: ListNotificationsQuery) {
30-
const conditions = [eq(userNotifications.userId, userId)];
31+
const conditions = [ownedBy(userId, userNotifications.userId)];
3132

3233
if (filters.channel) {
3334
conditions.push(eq(userNotifications.channel, filters.channel));
@@ -50,7 +51,7 @@ export class NotificationsService {
5051
.from(userNotifications)
5152
.where(
5253
and(
53-
eq(userNotifications.userId, userId),
54+
ownedBy(userId, userNotifications.userId),
5455
filters.channel
5556
? eq(userNotifications.channel, filters.channel)
5657
: undefined,
@@ -77,7 +78,7 @@ export class NotificationsService {
7778
.where(
7879
and(
7980
eq(userNotifications.id, notificationId),
80-
eq(userNotifications.userId, userId),
81+
ownedBy(userId, userNotifications.userId),
8182
),
8283
)
8384
.returning();
@@ -95,7 +96,7 @@ export class NotificationsService {
9596
.set({ readAt: new Date() })
9697
.where(
9798
and(
98-
eq(userNotifications.userId, userId),
99+
ownedBy(userId, userNotifications.userId),
99100
channel ? eq(userNotifications.channel, channel) : undefined,
100101
isNull(userNotifications.readAt),
101102
),
@@ -110,7 +111,7 @@ export class NotificationsService {
110111
.delete(userNotifications)
111112
.where(
112113
and(
113-
eq(userNotifications.userId, userId),
114+
ownedBy(userId, userNotifications.userId),
114115
channel ? eq(userNotifications.channel, channel) : undefined,
115116
),
116117
)
@@ -181,7 +182,7 @@ export class NotificationsService {
181182
const existing = await this.tx.query.userNotifications.findFirst({
182183
where: (notification, { and, eq }) =>
183184
and(
184-
eq(notification.userId, userId),
185+
ownedBy(userId, notification.userId),
185186
eq(notification.type, "high_match"),
186187
eq(notification.entityType, "job"),
187188
eq(notification.entityId, entityId),

backend/src/modules/savedJobs/savedJobs.service.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm";
22
import { db } from "../../db/client";
33
import { NewSavedJob, SavedJob, savedJobs } from "../../db/schema";
44
import { DB } from "../../db/types/types";
5+
import { ownedBy } from "../../lib/authorization/ownership";
56
import { AppError } from "../../lib/errors";
67
import { NotificationsService } from "../notifications/notifications.service";
78

@@ -10,14 +11,14 @@ export class SavedJobsService {
1011

1112
async getAll(userId: string): Promise<SavedJob[]> {
1213
return this.tx.query.savedJobs.findMany({
13-
where: (j, { eq }) => eq(j.userId, userId),
14+
where: (j) => ownedBy(userId, j.userId),
1415
orderBy: (j, { desc }) => desc(j.createdAt),
1516
});
1617
}
1718

1819
async getById(userId: string, jobId: string): Promise<SavedJob | undefined> {
1920
return this.tx.query.savedJobs.findFirst({
20-
where: (j, { and, eq }) => and(eq(j.userId, userId), eq(j.id, jobId)),
21+
where: (j, { and, eq }) => and(ownedBy(userId, j.userId), eq(j.id, jobId)),
2122
});
2223
}
2324

@@ -27,7 +28,7 @@ export class SavedJobsService {
2728
): Promise<SavedJob> {
2829
const existing = await this.tx.query.savedJobs.findFirst({
2930
where: (j, { and, eq }) =>
30-
and(eq(j.userId, userId), eq(j.jobLink, data.jobLink)),
31+
and(ownedBy(userId, j.userId), eq(j.jobLink, data.jobLink)),
3132
});
3233

3334
if (existing) {
@@ -55,7 +56,7 @@ export class SavedJobsService {
5556
const result = await this.tx
5657
.update(savedJobs)
5758
.set({ ...data, updatedAt: new Date() })
58-
.where(and(eq(savedJobs.id, jobId), eq(savedJobs.userId, userId)))
59+
.where(and(eq(savedJobs.id, jobId), ownedBy(userId, savedJobs.userId)))
5960
.returning();
6061

6162
if (!result[0]) {
@@ -72,6 +73,6 @@ export class SavedJobsService {
7273
async delete(userId: string, jobId: string): Promise<void> {
7374
await this.tx
7475
.delete(savedJobs)
75-
.where(and(eq(savedJobs.id, jobId), eq(savedJobs.userId, userId)));
76+
.where(and(eq(savedJobs.id, jobId), ownedBy(userId, savedJobs.userId)));
7677
}
7778
}

backend/src/modules/users/users.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { eq } from "drizzle-orm";
21
import { db } from "../../db/client";
32
import { User, UserPreferences, userPreferences, users } from "../../db/schema";
43
import { DB } from "../../db/types/types";
4+
import { ownedBy } from "../../lib/authorization/ownership";
55
import { AppError } from "../../lib/errors";
66
import { UpdateProfileData } from "../types/user.types";
77
import { UpdatePreferencesData } from "./schemas/user.schemas";
@@ -30,7 +30,7 @@ export class UsersService {
3030

3131
async getPreferences(userId: string): Promise<UserPreferences | undefined> {
3232
return this.tx.query.userPreferences.findFirst({
33-
where: (p, { eq }) => eq(p.userId, userId),
33+
where: (p) => ownedBy(userId, p.userId),
3434
});
3535
}
3636

@@ -52,7 +52,7 @@ export class UsersService {
5252
const result = await this.tx
5353
.update(userPreferences)
5454
.set({ ...data, updatedAt: new Date() })
55-
.where(eq(userPreferences.userId, userId))
55+
.where(ownedBy(userId, userPreferences.userId))
5656
.returning();
5757

5858
if (!result[0]) {

backend/src/routes/keywords.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Router } from "express";
2-
import { eq } from "drizzle-orm";
32
import { db } from "../db/client";
43
import { keywords } from "../db/schema";
4+
import { ownedBy } from "../lib/authorization/ownership";
55
import { getCache } from "../lib/cache";
66
import { publish } from "../lib/kwsync";
77

@@ -25,7 +25,7 @@ keywordsRoutes.get("/", async (req, res) => {
2525
const rows = await db
2626
.select({ keyword: keywords.keyword, source: keywords.source })
2727
.from(keywords)
28-
.where(eq(keywords.userId, userId))
28+
.where(ownedBy(userId, keywords.userId))
2929
.orderBy(keywords.createdAt);
3030

3131
return res.json({ ok: true, keywords: rows });
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
eq: vi.fn(),
5+
}));
6+
7+
vi.mock("drizzle-orm", async (importOriginal) => {
8+
const actual = await importOriginal<typeof import("drizzle-orm")>();
9+
return {
10+
...actual,
11+
eq: mocks.eq,
12+
};
13+
});
14+
15+
import {
16+
assertOwnsResource,
17+
ownedBy,
18+
} from "../../../../src/lib/authorization/ownership";
19+
20+
describe("ownership authorization helpers", () => {
21+
it("permite recurso do usuário autenticado", () => {
22+
expect(() => {
23+
assertOwnsResource("user-1", "user-1", "Vaga");
24+
}).not.toThrow();
25+
});
26+
27+
it("retorna 404 quando o recurso pertence a outro usuário", () => {
28+
expect(() => {
29+
assertOwnsResource("user-1", "user-2", "Vaga não encontrada");
30+
}).toThrow(expect.objectContaining({
31+
code: "NOT_FOUND",
32+
statusCode: 404,
33+
message: "Vaga não encontrada",
34+
}));
35+
});
36+
37+
it("cria filtro Drizzle para userId", () => {
38+
mocks.eq.mockReturnValue("owned-by-user-1");
39+
40+
expect(ownedBy("user-1", "user_id" as any)).toBe("owned-by-user-1");
41+
expect(mocks.eq).toHaveBeenCalledWith("user_id", "user-1");
42+
});
43+
});

backend/tests/unit/modules/notifications/notifications.service.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const drizzleMocks = vi.hoisted(() => ({
4+
and: vi.fn(),
5+
eq: vi.fn(),
6+
}));
7+
8+
vi.mock("drizzle-orm", async (importOriginal) => {
9+
const actual = await importOriginal<typeof import("drizzle-orm")>();
10+
return {
11+
...actual,
12+
and: drizzleMocks.and,
13+
eq: drizzleMocks.eq,
14+
};
15+
});
16+
217
import type {
318
NewUserNotification,
419
SavedJob,
@@ -94,6 +109,11 @@ describe("NotificationsService", () => {
94109
beforeEach(() => {
95110
tx = makeTx();
96111
service = new NotificationsService(tx as any);
112+
drizzleMocks.and.mockImplementation((...conditions) => conditions);
113+
drizzleMocks.eq.mockImplementation((column, value) => ({
114+
column,
115+
value,
116+
}));
97117
});
98118

99119
it("lista notificações com filtros de canal e não lidas", async () => {
@@ -113,6 +133,11 @@ describe("NotificationsService", () => {
113133
});
114134
expect(listBuilder.limit).toHaveBeenCalledWith(10);
115135
expect(tx.select).toHaveBeenCalledTimes(2);
136+
expect(listBuilder.where).toHaveBeenCalledWith(
137+
expect.arrayContaining([
138+
{ column: expect.anything(), value: "user-1" },
139+
]),
140+
);
116141
});
117142

118143
it("usa unreadCount zero quando a consulta agregada não retorna linhas", async () => {
@@ -155,6 +180,10 @@ describe("NotificationsService", () => {
155180
service.markRead("user-1", "notification-1"),
156181
).resolves.toBe(notification);
157182
expect(update.set).toHaveBeenCalledWith({ readAt: expect.any(Date) });
183+
expect(update.where).toHaveBeenCalledWith([
184+
{ column: expect.anything(), value: "notification-1" },
185+
{ column: expect.anything(), value: "user-1" },
186+
]);
158187
});
159188

160189
it("lança not found ao marcar como lida quando não encontra a notificação", async () => {
@@ -176,6 +205,12 @@ describe("NotificationsService", () => {
176205
await expect(service.markAllRead("user-1", "message")).resolves.toEqual({
177206
updated: 2,
178207
});
208+
expect(update.where).toHaveBeenCalledWith(
209+
expect.arrayContaining([
210+
{ column: expect.anything(), value: "user-1" },
211+
{ column: expect.anything(), value: "message" },
212+
]),
213+
);
179214
});
180215

181216
it("limpa notificações de um canal", async () => {
@@ -187,6 +222,12 @@ describe("NotificationsService", () => {
187222
});
188223

189224
expect(tx.delete).toHaveBeenCalled();
225+
expect(deleteQuery.where).toHaveBeenCalledWith(
226+
expect.arrayContaining([
227+
{ column: expect.anything(), value: "user-1" },
228+
{ column: expect.anything(), value: "notification" },
229+
]),
230+
);
190231
expect(deleteQuery.returning).toHaveBeenCalledWith({
191232
id: expect.anything(),
192233
});
@@ -326,4 +367,32 @@ describe("NotificationsService", () => {
326367
}),
327368
);
328369
});
370+
371+
it("deduplica alto match usando userId para impedir colisão cross-user", async () => {
372+
tx.query.userNotifications.findFirst.mockResolvedValue(notification);
373+
374+
const result = await service.createHighMatchIfMissing("user-1", {
375+
id: "job-1",
376+
title: "Backend Developer",
377+
company: "Candidate",
378+
link: "https://jobs.test/1",
379+
matchScore: 95,
380+
});
381+
382+
expect(result).toBe(notification);
383+
384+
const where = tx.query.userNotifications.findFirst.mock.calls[0][0].where;
385+
const table = {
386+
userId: "userNotifications.userId",
387+
type: "userNotifications.type",
388+
entityType: "userNotifications.entityType",
389+
entityId: "userNotifications.entityId",
390+
};
391+
expect(where(table, drizzleMocks)).toEqual([
392+
{ column: "userNotifications.userId", value: "user-1" },
393+
{ column: "userNotifications.type", value: "high_match" },
394+
{ column: "userNotifications.entityType", value: "job" },
395+
{ column: "userNotifications.entityId", value: "job-1" },
396+
]);
397+
});
329398
});

0 commit comments

Comments
 (0)