Skip to content

Commit 982788c

Browse files
committed
feat: add integration and unit tests for authentication middleware and session management
1 parent 7927c8c commit 982788c

6 files changed

Lines changed: 408 additions & 1 deletion

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import request from "supertest";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("iron-session", () => ({
5+
getIronSession: vi.fn(),
6+
}));
7+
8+
import { getIronSession } from "iron-session";
9+
import { createJobsApiApp } from "../../../src/app";
10+
11+
const protectedRoutes = [
12+
{ method: "get" as const, path: "/saved-jobs" },
13+
{ method: "get" as const, path: "/users/profile" },
14+
{ method: "get" as const, path: "/jobs/search?keywords=dev" },
15+
{ method: "get" as const, path: "/keywords" },
16+
];
17+
18+
describe("Integration - Auth Guards (requireAuth)", () => {
19+
let app: ReturnType<typeof createJobsApiApp>;
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
vi.mocked(getIronSession).mockResolvedValue({
24+
userId: undefined,
25+
save: vi.fn(),
26+
destroy: vi.fn(),
27+
} as any);
28+
app = createJobsApiApp();
29+
});
30+
31+
it.each(protectedRoutes)(
32+
"bloqueia $method $path sem sessão autenticada",
33+
async ({ method, path }) => {
34+
const res = await request(app)[method](path).expect(401);
35+
36+
expect(res.body).toEqual({ message: "Não autenticado." });
37+
},
38+
);
39+
40+
it("permite /auth/login sem exigir autenticação prévia", async () => {
41+
const res = await request(app)
42+
.post("/auth/login")
43+
.send({ email: "invalido", password: "x" });
44+
45+
expect(res.status).not.toBe(401);
46+
expect(res.body).not.toEqual({ message: "Não autenticado." });
47+
});
48+
49+
it("permite /health sem autenticação", async () => {
50+
await request(app).get("/health").expect(200);
51+
});
52+
});

backend/tests/integration/routes/auth.routes.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,5 +558,64 @@ describe("Integration - Auth Routes", () => {
558558

559559
expect(res.body).toHaveProperty("error", "Não autenticado");
560560
});
561+
562+
it("invalida sessão e retorna 401 quando usuário foi removido", async () => {
563+
const session = {
564+
userId: "user-deleted",
565+
save: vi.fn(),
566+
destroy: vi.fn().mockResolvedValue(undefined),
567+
};
568+
vi.mocked(getIronSession).mockResolvedValue(session as any);
569+
mockCredentialsService.findById.mockResolvedValueOnce(null);
570+
571+
const res = await request(app).get(`${BASE}/me`).expect(401);
572+
573+
expect(res.body).toHaveProperty("error", "Não autenticado");
574+
expect(session.destroy).toHaveBeenCalled();
575+
});
576+
});
577+
578+
// ── Fluxo de sessão (login → validação → logout) ─────────────────────────
579+
// Não há endpoint de refresh de token; a sessão é validada via GET /auth/me.
580+
581+
describe("fluxo de sessão", () => {
582+
it("login seguido de /me retorna o mesmo usuário autenticado", async () => {
583+
vi.mocked(getIronSession).mockResolvedValue(
584+
fixtureCredentialsSession as any,
585+
);
586+
587+
await request(app).post(`${BASE}/login`).send(loginPayload).expect(200);
588+
589+
vi.mocked(getIronSession).mockResolvedValue({
590+
userId: fixtureUser.id,
591+
save: vi.fn(),
592+
destroy: vi.fn(),
593+
} as any);
594+
595+
const meRes = await request(app).get(`${BASE}/me`).expect(200);
596+
597+
expect(meRes.body.user).toHaveProperty("id", fixtureUser.id);
598+
expect(fixtureCredentialsSession.userId).toBe(fixtureUser.id);
599+
});
600+
601+
it("logout invalida sessão e /me retorna 401 em seguida", async () => {
602+
const session = {
603+
userId: fixtureUser.id,
604+
role: fixtureUser.role,
605+
save: vi.fn().mockResolvedValue(undefined),
606+
destroy: vi.fn().mockResolvedValue(undefined),
607+
};
608+
vi.mocked(getIronSession).mockResolvedValue(session as any);
609+
610+
await request(app).post(`${BASE}/logout`).expect(200);
611+
expect(session.destroy).toHaveBeenCalled();
612+
613+
session.userId = undefined;
614+
vi.mocked(getIronSession).mockResolvedValue(session as any);
615+
616+
const meRes = await request(app).get(`${BASE}/me`).expect(401);
617+
618+
expect(meRes.body).toHaveProperty("error", "Não autenticado");
619+
});
561620
});
562621
});

backend/tests/integration/routes/savedJobs.routes.test.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ describe("Integration - SavedJobs Routes", () => {
117117
userId: undefined,
118118
} as any);
119119

120-
await request(app).get(BASE).expect(401);
120+
const res = await request(app).get(BASE).expect(401);
121+
122+
expect(res.body).toEqual({ message: "Não autenticado." });
121123
});
122124

123125
it("retorna 500 quando getAll lança erro", async () => {
@@ -151,6 +153,18 @@ describe("Integration - SavedJobs Routes", () => {
151153
await request(app).get(`${BASE}/inexistente`).expect(404);
152154
});
153155

156+
it("retorna 404 quando vaga pertence a outro usuário (ownership)", async () => {
157+
mockSavedJobsService.getById.mockResolvedValueOnce(undefined);
158+
159+
const res = await request(app).get(`${BASE}/job-outro-user`).expect(404);
160+
161+
expect(res.body).toHaveProperty("error", "Vaga não encontrada");
162+
expect(mockSavedJobsService.getById).toHaveBeenCalledWith(
163+
"user_abc",
164+
"job-outro-user",
165+
);
166+
});
167+
154168
it("retorna 401 quando sessão não tem userId", async () => {
155169
vi.mocked(getIronSession).mockResolvedValueOnce({
156170
userId: undefined,
@@ -228,6 +242,78 @@ describe("Integration - SavedJobs Routes", () => {
228242
});
229243
});
230244

245+
// ── PATCH /:id — transições de status (eventos de candidatura) ─────────────
246+
247+
describe("PATCH /:id — transições de status", () => {
248+
it.each([
249+
"applied",
250+
"interviewing",
251+
"rejected",
252+
"accepted",
253+
] as const)("atualiza status para %s", async (status) => {
254+
mockSavedJobsService.update.mockResolvedValueOnce({
255+
...fixtureJob,
256+
status,
257+
});
258+
259+
const res = await request(app)
260+
.patch(`${BASE}/job-1`)
261+
.send({ status })
262+
.expect(200);
263+
264+
expect(res.body.status).toBe(status);
265+
expect(mockSavedJobsService.update).toHaveBeenCalledWith(
266+
"user_abc",
267+
"job-1",
268+
expect.objectContaining({ status }),
269+
);
270+
});
271+
272+
it("persiste appliedAt ao registrar candidatura enviada", async () => {
273+
const appliedAt = "2024-06-15T10:00:00.000Z";
274+
mockSavedJobsService.update.mockResolvedValueOnce({
275+
...fixtureJob,
276+
status: "applied",
277+
appliedAt,
278+
});
279+
280+
const res = await request(app)
281+
.patch(`${BASE}/job-1`)
282+
.send({ status: "applied", appliedAt })
283+
.expect(200);
284+
285+
expect(res.body.status).toBe("applied");
286+
expect(mockSavedJobsService.update).toHaveBeenCalledWith(
287+
"user_abc",
288+
"job-1",
289+
expect.objectContaining({
290+
status: "applied",
291+
appliedAt: expect.any(Date),
292+
}),
293+
);
294+
});
295+
296+
it("simula pipeline completo saved → applied → interviewing → accepted", async () => {
297+
const pipeline = ["applied", "interviewing", "accepted"] as const;
298+
299+
for (const status of pipeline) {
300+
mockSavedJobsService.update.mockResolvedValueOnce({
301+
...fixtureJob,
302+
status,
303+
});
304+
305+
const res = await request(app)
306+
.patch(`${BASE}/job-1`)
307+
.send({ status })
308+
.expect(200);
309+
310+
expect(res.body.status).toBe(status);
311+
}
312+
313+
expect(mockSavedJobsService.update).toHaveBeenCalledTimes(pipeline.length);
314+
});
315+
});
316+
231317
// ── PATCH /:id ────────────────────────────────────────────────────────────
232318

233319
describe("PATCH /:id", () => {
@@ -324,5 +410,14 @@ describe("Integration - SavedJobs Routes", () => {
324410

325411
await request(app).delete(`${BASE}/job-1`).expect(500);
326412
});
413+
414+
it("retorna 204 mesmo quando vaga não pertence ao usuário (ownership silencioso)", async () => {
415+
await request(app).delete(`${BASE}/job-outro-user`).expect(204);
416+
417+
expect(mockSavedJobsService.delete).toHaveBeenCalledWith(
418+
"user_abc",
419+
"job-outro-user",
420+
);
421+
});
327422
});
328423
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { NextFunction, Request, Response } from "express";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { requireAuth } from "../../../src/middleware/requireAuth";
4+
5+
function makeMocks(session: Request["session"] | undefined) {
6+
const req = { session } as Request;
7+
const json = vi.fn();
8+
const status = vi.fn(() => ({ json }));
9+
const res = { status, json } as unknown as Response;
10+
const next = vi.fn() as NextFunction;
11+
12+
return { req, res, next, json, status };
13+
}
14+
15+
describe("requireAuth", () => {
16+
it("retorna 401 quando sessão não tem userId", () => {
17+
const { req, res, next, status, json } = makeMocks({} as Request["session"]);
18+
19+
requireAuth(req, res, next);
20+
21+
expect(status).toHaveBeenCalledWith(401);
22+
expect(json).toHaveBeenCalledWith({ message: "Não autenticado." });
23+
expect(next).not.toHaveBeenCalled();
24+
});
25+
26+
it("retorna 401 quando sessão é undefined", () => {
27+
const { req, res, next, status, json } = makeMocks(undefined);
28+
29+
requireAuth(req, res, next);
30+
31+
expect(status).toHaveBeenCalledWith(401);
32+
expect(json).toHaveBeenCalledWith({ message: "Não autenticado." });
33+
expect(next).not.toHaveBeenCalled();
34+
});
35+
36+
it("chama next quando userId está presente na sessão", () => {
37+
const { req, res, next, status } = makeMocks({
38+
userId: "user-1",
39+
} as Request["session"]);
40+
41+
requireAuth(req, res, next);
42+
43+
expect(next).toHaveBeenCalledOnce();
44+
expect(status).not.toHaveBeenCalled();
45+
});
46+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { NextFunction, Request, Response } from "express";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mockGetIronSession = vi.hoisted(() => vi.fn());
5+
6+
vi.mock("iron-session", () => ({
7+
getIronSession: mockGetIronSession,
8+
}));
9+
10+
import { withSession } from "../../../src/middleware/withSession";
11+
12+
describe("withSession", () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
});
16+
17+
it("injeta req.session e chama next", async () => {
18+
const session = {
19+
userId: "user-1",
20+
save: vi.fn(),
21+
destroy: vi.fn(),
22+
};
23+
mockGetIronSession.mockResolvedValue(session);
24+
25+
const req = {} as Request;
26+
const res = {} as Response;
27+
const next = vi.fn() as NextFunction;
28+
29+
await withSession(req, res, next);
30+
31+
expect(mockGetIronSession).toHaveBeenCalledOnce();
32+
expect(req.session).toBe(session);
33+
expect(next).toHaveBeenCalledOnce();
34+
});
35+
36+
it("propaga erro quando getIronSession falha", async () => {
37+
const error = new Error("session failure");
38+
mockGetIronSession.mockRejectedValue(error);
39+
40+
const req = {} as Request;
41+
const res = {} as Response;
42+
const next = vi.fn() as NextFunction;
43+
44+
await expect(withSession(req, res, next)).rejects.toThrow("session failure");
45+
expect(next).not.toHaveBeenCalled();
46+
});
47+
});

0 commit comments

Comments
 (0)