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
14 changes: 14 additions & 0 deletions infra/controller.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as cookie from "cookie";
import session from "models/session";

import {
InternalServerError,
MethodNotAllowedError,
Expand Down Expand Up @@ -27,11 +30,22 @@ function onErrorHandler(error, request, response) {
response.status(puclicErrorObjcet.statusCode).json(puclicErrorObjcet);
}

async function setSessionCookie(sessionToken, response) {
const setCookie = cookie.serialize("session_id", sessionToken, {
path: "/",
maxAge: session.EXPIRATION_IN_MILISECONS / 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;
62 changes: 61 additions & 1 deletion models/session.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,45 @@
import crypto from "node:crypto";
import database from "infra/database";
import { UnauthorizedError } from "infra/errors";

const EXPIRATION_IN_MILISECONS = 60 * 60 * 24 * 30 * 1000; // 30 days

function getExpiresAt() {
return new Date(Date.now() + EXPIRATION_IN_MILISECONS);
}

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

async function runSelectQuery(sessionToken) {
const result = await database.query({
text: `
SELECT
*
FROM
sessions
WHERE
token = $1
AND expires_at > NOW()
LIMIT
1
;`,
values: [sessionToken],
});
if (result.rowCount === 0) {
throw new UnauthorizedError({
message: "User do not have a valid session.",
action: "Check if user is logged in and try again.",
});
}
return result.rows[0];
}
}

async function create(userId) {
const token = crypto.randomBytes(48).toString("hex");
const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILISECONS);
const expiresAt = getExpiresAt();
const newSession = await runInsertQuery(token, userId, expiresAt);
return newSession;

Expand All @@ -25,9 +59,35 @@ async function create(userId) {
}
}

async function renew(sessionId) {
const expiresAt = getExpiresAt();
const renewedSessionObject = await runUpdateQuery(sessionId, expiresAt);
return renewedSessionObject;

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

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

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

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

async function runSelectQuery(id) {
const result = await database.query({
text: `
SELECT
*
FROM
users
WHERE
id = $1
LIMIT
1
`,
values: [id],
});
if (result.rowCount === 0) {
throw new NotFoundError({
message: "The informed id was not found in the system",
action: "Check if the id is typed correctly",
});
}
return result.rows[0];
}
}

async function findOneByUsername(username) {
const userFound = await runSelectQuery(username);
return userFound;
Expand Down Expand Up @@ -182,9 +210,10 @@ async function hashPasswordInObject(userInputValues) {

const user = {
create,
update,
findOneById,
findOneByUsername,
findOneByEmail,
update,
};

export default user;
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,4 @@
import { createRouter } from "next-connect";
import * as cookie from "cookie";
import controller from "infra/controller";

import authentication from "models/authentication";
Expand All @@ -18,13 +17,8 @@ async function postHandler(request, response) {
userInputValues.password,
);
const newSession = await session.create(authenticatedUser.id);
const setCookie = cookie.serialize("session_id", newSession.token, {
path: "/",
maxAge: session.EXPIRATION_IN_MILISECONS / 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);
}
26 changes: 26 additions & 0 deletions pages/api/v1/user/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createRouter } from "next-connect";
import controller from "infra/controller";
import user from "models/user";
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, max-age=0, must-revalidate",
);
return response.status(200).json(userFound);
}
186 changes: 186 additions & 0 deletions test/integration/api/v1/user/get.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { version as uuidVersion } from "uuid";
import setCookieParser from "set-cookie-parser";

import orchestrator from "test/orchestrator";
import session from "models/session";

beforeAll(async () => {
await orchestrator.waitAllServices();
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}`,
},
});

const responseBody = await response.json();
const caheControl = response.headers.get("Cache-Control");

expect(response.status).toBe(200);
expect(caheControl).toBe(
"no-store, no-cache, max-age=0, must-revalidate",
);
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.updated_at > sessionObject.updated_at,
).toEqual(true);

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

const expiresAt = new Date(renewedSessionObject.expires_at);
const updatedAt = new Date(renewedSessionObject.updated_at);
expiresAt.setMilliseconds(0);
updatedAt.setMilliseconds(0);
expect(expiresAt - updatedAt).toEqual(session.EXPIRATION_IN_MILISECONS);

// Set-cookie header assertions
const parsedCookie = setCookieParser(response, { map: true });
expect(parsedCookie.session_id).toEqual({
name: "session_id",
httpOnly: true,
path: "/",
maxAge: session.EXPIRATION_IN_MILISECONS / 1000,
value: renewedSessionObject.token,
});
});

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

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

const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({
name: "UnauthorizedError",
message: "User do not have a valid session.",
action: "Check if user is logged in and try again.",
status_code: 401,
});
});

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

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}`,
},
});

const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({
name: "UnauthorizedError",
message: "User do not have a valid session.",
action: "Check if user is logged in and try again.",
status_code: 401,
});
});

test("With valid session about to expire", async () => {
jest.useFakeTimers({
now: new Date(Date.now() - session.EXPIRATION_IN_MILISECONS + 100),
});

const createdUser = await orchestrator.createUser({
username: "UserWithAboutToExpireSession",
});
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}`,
},
});

const responseBody = await response.json();

expect(response.status).toBe(200);
expect(responseBody).toEqual({
id: createdUser.id,
username: "UserWithAboutToExpireSession",
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.updated_at > sessionObject.updated_at,
).toEqual(true);

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

const expiresAt = new Date(renewedSessionObject.expires_at);
const updatedAt = new Date(renewedSessionObject.updated_at);
expiresAt.setMilliseconds(0);
updatedAt.setMilliseconds(0);
expect(expiresAt - updatedAt).toEqual(session.EXPIRATION_IN_MILISECONS);

// Set-cookie header assertions
const parsedCookie = setCookieParser(response, { map: true });
expect(parsedCookie.session_id).toEqual({
name: "session_id",
httpOnly: true,
path: "/",
maxAge: session.EXPIRATION_IN_MILISECONS / 1000,
value: renewedSessionObject.token,
});
});
});
});
Loading