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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,4 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*

prisma/generated/
.vercel
27 changes: 26 additions & 1 deletion src/controller/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,39 @@ export const login = async (req: Request, res: Response) => {
return;
}
const session = await sessionModel.create(user.id);
new CookieResponse(res).setCookie("session", session.id);
response.setCookie("session", session.id);
response.created({ message: "Usuário autenticado!" });
} catch (error) {
console.error("Erro insperado ao efetuar login: ", error);
response.internalServerError();
}
};

export const logout = async (req: Request, res: Response) => {
const response = new CookieResponse(res);
const sessionId = req.cookies.session;
if (!sessionId) {
response.unauthorized("Faça o login.");
return;
}
try {
response.clearCookie("session");
const isLogouted = await sessionModel.invalidate(sessionId);
if (!isLogouted) {
response.unauthorized(
"Verifique se está logado e tente novamente.",
"Sessão expirada.",
);
return;
}

response.success({ message: "Sessão encerrada!" });
} catch (error) {
console.error("Erro insperado para realizar o logout: ", error);
response.internalServerError();
}
};

export const getUser = async (
req: Request,
res: Response,
Expand Down
2 changes: 1 addition & 1 deletion src/models/customResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class CustomResponse {
this.response = response;
}

errorHandler(error: any) {
private errorHandler(error: any) {
if (error.name === "ZodError") {
const { issues } = error;
return setErrorMessage(issues);
Expand Down
45 changes: 34 additions & 11 deletions src/models/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { env } from "~/config/env.js";
import { prisma } from "~/infra/database.js";
import { BaseModel } from "./base.js";
import { UserDelegate, UserSessionDelegate } from "@/generated/models.js";
import { UserSessionDelegate } from "@/generated/models.js";
import { userModel, UserModelClass } from "./user.js";

export type AuthenticatedUser = {
Expand Down Expand Up @@ -30,11 +30,15 @@ class SessionModel extends BaseModel<UserSessionDelegate> {
this.userModel = userModel;
}

async create(user_id: string): Promise<UserSession> {
const id = randomUUID();
private getExpiresAt() {
const now = new Date();
const expires_at = new Date(now.getTime() + env.INACTIVITY_TIMEOUT);
return { now, expires_at };
}

async create(user_id: string): Promise<UserSession> {
const id = randomUUID();
const { now, expires_at } = this.getExpiresAt();
return await this.createOne({
id,
user_id,
Expand All @@ -43,22 +47,33 @@ class SessionModel extends BaseModel<UserSessionDelegate> {
});
}

async validate(sessionId: string): Promise<AuthenticatedUser | false> {
async findOneValid(sessionId: string): Promise<UserSession | false> {
const sessionObject: UserSession = await this.findUnique({
where: { id: sessionId },
});
if (!sessionObject) return false;

const user = await this.userModel.findOne(sessionObject.user_id);
if (!user) return false;
if (!sessionObject) return false;

const now = new Date();
const lastActive = new Date(String(sessionObject.last_active_at));
const isExceededTime = now.getTime() > sessionObject.expires_at.getTime();

if (isExceededTime) return false;

if (now.getTime() - lastActive.getTime() > env.INACTIVITY_TIMEOUT)
return false;
return sessionObject;
}

async validate(sessionId: string): Promise<AuthenticatedUser | false> {
const sessionObject = await this.findOneValid(sessionId);
if (!sessionObject) return false;

await this.updateOne({ id: sessionId }, { last_active_at: now });
const { now, expires_at } = this.getExpiresAt();
await this.updateOne(
{ id: sessionId },
{ last_active_at: now, expires_at },
);

const user = await this.userModel.findOne(sessionObject.user_id);
if (!user) return false;

const { email, id, name, created_at, updated_at } = user;
const secureObjectValue: AuthenticatedUser = {
Expand All @@ -70,6 +85,14 @@ class SessionModel extends BaseModel<UserSessionDelegate> {
};
return secureObjectValue;
}

async invalidate(sessionId: string) {
const sessionObject = await this.findOneValid(sessionId);
if (!sessionObject) return false;

await this.updateOne({ id: sessionId }, { expires_at: new Date() });
return true;
}
}

export const sessionModel = new SessionModel(prisma.userSession, userModel);
3 changes: 2 additions & 1 deletion src/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ const router = Router();
router.get("/", index);

router.post("/login", userCtrl.login);
router.post("/user", userCtrl.register);
router.get("/user", userCtrl.getUser);
router.post("/user", userCtrl.register);
router.delete("/user", userCtrl.logout);

router.get("/tasks", authenticate, taskCtrl.getAllTasks);
router.get("/tasks/:id", authenticate, taskCtrl.getOneTask);
Expand Down
109 changes: 109 additions & 0 deletions tests/controller/user/delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { API, createSession, createUser } from "$/orchestrator.js";
import { env } from "~/config/env.js";

describe("DELETE `/user`", () => {
describe("Usuário anônimo", () => {
test("Tentando fazer a request", async () => {
const response = await fetch(API.user, {
method: "DELETE",
});

expect(response.ok).toBe(false);
expect(response.status).toBe(401);

const responseBody = await response.json();
expect(responseBody).toEqual({
action: "Faça o login.",
message: "Não autorizado.",
});
});
});

describe("Usuário padrão", () => {
test("Com sessão válida", async () => {
const defaultUser = await createUser();
const defaultSession = await createSession(defaultUser);

const response = await fetch(API.user, {
method: "DELETE",
headers: {
Cookie: `session=${defaultSession.id}`,
},
});

expect(response.ok).toBe(true);
expect(response.status).toBe(200);

const responseBody = await response.json();
expect(responseBody).toEqual({ message: "Sessão encerrada!" });
});

test("Com sessão quase expirada", async () => {
const horaAtual = new Date();
const horaSessaoExpirada = new Date(
horaAtual.getTime() - env.INACTIVITY_TIMEOUT / 2,
);
vi.setSystemTime(horaSessaoExpirada);

const newUser = await createUser();
const almostExpiredSession = await createSession(newUser);

vi.useRealTimers();
const response = await fetch(API.user, {
method: "DELETE",
headers: {
Cookie: `session=${almostExpiredSession.id}`,
},
});

expect(response.ok).toBe(true);
expect(response.status).toBe(200);

const responseBody = await response.json();
expect(responseBody).toEqual({ message: "Sessão encerrada!" });
});

test("Com sessão expirada", async () => {
const horaAtual = new Date();
const horaSessaoExpirada = new Date(
horaAtual.getTime() - env.INACTIVITY_TIMEOUT,
);
vi.setSystemTime(horaSessaoExpirada);

const newUser = await createUser();
const expiredSession = await createSession(newUser);

vi.useRealTimers();
const response = await fetch(API.user, {
method: "DELETE",
headers: {
Cookie: `session=${expiredSession.id}`,
},
});

expect(response.ok).toBe(false);
expect(response.status).toBe(401);

const responseBody = await response.json();
expect(responseBody).toEqual({
action: "Verifique se está logado e tente novamente.",
message: "Sessão expirada.",
});

const response2 = await fetch(API.user, {
headers: {
Cookie: `session=${expiredSession.id}`,
},
});

expect(response.ok).toBe(false);
expect(response.status).toBe(401);

const response2Body = await response2.json();
expect(response2Body).toEqual({
action: "Por favor, realize o login novamente.",
message: "Sessão inválida ou expirada.",
});
});
});
});
Loading