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
6 changes: 3 additions & 3 deletions .github/workflows/linting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"

- run: npm ci

Expand All @@ -25,7 +25,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"

- run: npm ci

Expand All @@ -41,7 +41,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"

- run: npm ci

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"

- run: npm ci

Expand Down
14 changes: 14 additions & 0 deletions infra/controller.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as cookie from "cookie";
import session from "models/session";
import {
InternalServerError,
MethodNotAllowedError,
Expand Down Expand Up @@ -27,11 +29,23 @@ function onErrorHandler(error, request, response) {
response.status(publicErrorObject.statusCode).json(publicErrorObject);
}

async function setSessionCookie(sessionToken, response) {
const setCookie = cookie.serialize("session_id", sessionToken, {
path: "/",
maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000,
secure: process.env.NODE_ENV === "production",
httpOnly: true,
});

response.setHeader("Set-Cookie", setCookie);
}

const controller = {
errorHandlers: {
onNoMatch: onNoMatchHandler,
onError: onErrorHandler,
},
setSessionCookie,
};

export default controller;
59 changes: 59 additions & 0 deletions models/session.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import crypto from "node:crypto";
import database from "infra/database.js";
import { UnauthorizedError } from "infra/errors";

const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; // 30 Days

async function findOneValidByToken(sessionToken) {
const sessionFound = await runSelectQuery(sessionToken);

return sessionFound;

async function runSelectQuery(sessionToken) {
const results = await database.query({
text: `
SELECT
*
FROM
sessions
WHERE
token = $1
AND expires_at > NOW()
LIMIT
1
;`,
values: [sessionToken],
});

if (results.rowCount === 0) {
throw new UnauthorizedError({
message: "Usuário não possui sessão ativa.",
action: "Verifique se este usuario está logado e tente novamente.",
});
}

return results.rows[0];
}
}

async function create(userId) {
const token = crypto.randomBytes(48).toString("hex");
const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS);
Expand All @@ -27,9 +60,35 @@ async function create(userId) {
}
}

async function renew(sessionId) {
const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS);
const renewedSessionObject = runUpdateQuery(sessionId, expiresAt);
return renewedSessionObject;

async function runUpdateQuery(sessionId, expiresAt) {
const results = await database.query({
text: `
UPDATE
sessions
SET
expires_at = $2,
updated_at = NOW()
WHERE
id = $1
RETURNING
*
;`,
values: [sessionId, expiresAt],
});
return results.rows[0];
}
}

const session = {
create,
EXPIRATION_IN_MILLISECONDS,
findOneValidByToken,
renew,
};

export default session;
32 changes: 32 additions & 0 deletions models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@ import database from "infra/database.js";
import password from "models/password.js";
import { ValidationError, NotFoundError } from "infra/errors.js";

async function findOneById(id) {
const userFound = await runSelectQuery(id);

return userFound;

async function runSelectQuery(id) {
const results = await database.query({
text: `
SELECT
*
FROM
users
WHERE
id = $1
LIMIT
1
;`,
values: [id],
});

if (results.rowCount === 0) {
throw new NotFoundError({
message: "O username informado não foi encontrado no sistema.",
action: "Verifique se o username está digitado corretamente.",
});
}

return results.rows[0];
}
}

async function findOneByUsername(username) {
const userFound = await runSelectQuery(username);
return userFound;
Expand Down Expand Up @@ -184,6 +215,7 @@ async function hashPasswordInObject(userInputValues) {

const user = {
create,
findOneById,
findOneByUsername,
findOneByEmail,
update,
Expand Down
10 changes: 2 additions & 8 deletions pages/api/v1/sessions/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createRouter } from "next-connect";
import * as cookie from "cookie";

import controller from "infra/controller";
import authentication from "models/authentication";
import session from "models/session";
Expand All @@ -20,13 +20,7 @@ async function postHandler(request, response) {

const newSession = await session.create(authenticatedUser.id);

const setCookie = cookie.serialize("session_id", newSession.token, {
path: "/",
maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000,
secure: process.env.NODE_ENV === "production",
httpOnly: true,
});
response.setHeader("Set-Cookie", setCookie);
controller.setSessionCookie(newSession.token, response);

return response.status(201).json(newSession);
}
25 changes: 25 additions & 0 deletions pages/api/v1/user/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createRouter } from "next-connect";
import controller from "infra/controller.js";
import user from "models/user.js";
import session from "models/session";

const router = createRouter();

router.get(getHandler);

export default router.handler(controller.errorHandlers);

async function getHandler(request, response) {
const sessionToken = request.cookies.session_id;

const sessionObject = await session.findOneValidByToken(sessionToken);
const renewedSessionObject = await session.renew(sessionObject.id);

controller.setSessionCookie(renewedSessionObject.token, response);

const userFound = await user.findOneById(sessionObject.user_id);

response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");

return response.status(200).json(userFound);
}
121 changes: 121 additions & 0 deletions tests/integration/api/v1/user/get.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { version as uuidVersion } from "uuid";
import orchestrator from "tests/orchestrator.js";
import session from "models/session";
import setCookieParser from "set-cookie-parser";

beforeAll(async () => {
await orchestrator.waitForAllServices();
await orchestrator.clearDatabase();
await orchestrator.runPendingMigrations();
});

describe("GET api/v1/user", () => {
describe("Default user", () => {
test("With valid session", async () => {
const createdUser = await orchestrator.createUser({
username: "UserWithValidSession",
});

const sessionObject = await orchestrator.createSession(createdUser.id);

const response = await fetch("http://localhost:3000/api/v1/user", {
headers: {
Cookie: `session_id=${sessionObject.token}`,
},
});
expect(response.status).toBe(200);

const cacheControl = response.headers.get("Cache-Control");
expect(cacheControl).toBe("no-store, no-cache, must-revalidate");

const responseBody = await response.json();
expect(responseBody).toEqual({
id: createdUser.id,
username: "UserWithValidSession",
email: createdUser.email,
password: createdUser.password,
created_at: createdUser.created_at.toISOString(),
updated_at: createdUser.updated_at.toISOString(),
});
expect(uuidVersion(responseBody.id)).toBe(4);
expect(Date.parse(responseBody.created_at)).not.toBeNaN();
expect(Date.parse(responseBody.updated_at)).not.toBeNaN();

// Session renewal assertions
const renewedSessionObject = await session.findOneValidByToken(
sessionObject.token,
);

expect(
renewedSessionObject.expires_at > sessionObject.expires_at,
).toEqual(true);
expect(
renewedSessionObject.updated_at > sessionObject.updated_at,
).toEqual(true);

// Set-Cookie assertions
const parsedSetCookie = setCookieParser(response, {
map: true,
});

expect(parsedSetCookie.session_id).toEqual({
name: "session_id",
value: sessionObject.token,
maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000,
path: "/",
httpOnly: true,
});
});

test("With nonexistent session", async () => {
const nonexistentToken =
"b7483614a37a7f37b4f2283cd30613ac7922c534fd2e1932bd4abb251b4bc4915a2f521653f27fb4470624920971508c";

const response = await fetch("http://localhost:3000/api/v1/user", {
headers: {
cookie: `session_id=${nonexistentToken}`,
},
});

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

const responseBody = await response.json();

expect(responseBody).toEqual({
name: "UnauthorizedError",
message: "Usuário não possui sessão ativa.",
action: "Verifique se este usuario está logado e tente novamente.",
status_code: 401,
});
});

test("With expired session", async () => {
jest.useFakeTimers({
now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS),
});

const createdUser = await orchestrator.createUser({
username: "UserWithExpiredSession",
});

const sessionObject = await orchestrator.createSession(createdUser.id);

jest.useRealTimers();

const response = await fetch("http://localhost:3000/api/v1/user", {
headers: {
Cookie: `session_id=${sessionObject.token}`,
},
});
expect(response.status).toBe(401);

const responseBody = await response.json();
expect(responseBody).toEqual({
name: "UnauthorizedError",
message: "Usuário não possui sessão ativa.",
action: "Verifique se este usuario está logado e tente novamente.",
status_code: 401,
});
});
});
});
6 changes: 6 additions & 0 deletions tests/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { faker } from "@faker-js/faker";
import database from "infra/database.js";
import migrator from "models/migrator.js";
import user from "models/user";
import session from "models/session";

async function waitForAllServices() {
await waitForWebServer();
Expand Down Expand Up @@ -40,11 +41,16 @@ async function createUser(userObject) {
});
}

async function createSession(userId) {
return await session.create(userId);
}

const orchestrator = {
waitForAllServices,
clearDatabase,
runPendingMigrations,
createUser,
createSession,
};

export default orchestrator;