Skip to content
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 @@ -30,11 +32,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;
69 changes: 67 additions & 2 deletions models/session.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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

Expand All @@ -20,14 +21,78 @@ async function create(userId) {
($1, $2, $3)
RETURNING
*
`,
;`,
values: [token, userId, expiresAt],
});

return result.rows[0];
}
}

const session = { create, EXPIRATION_IN_MILLISECONDS };
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 an active session.",
action: "Verify if you are logged in and try again.",
});
}

return result.rows[0];
}
}

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

return renewedSessionObject;

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

return result.rows[0];
}
}

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

export default session;
41 changes: 39 additions & 2 deletions models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ async function create(userInputValues) {
}
}

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 id provided was not found.",
action: "Try a different id.",
});
}

return result.rows[0];
}
}

async function findOneByUsername(username) {
const userFound = await runSelectQuery(username);

Expand Down Expand Up @@ -128,7 +159,7 @@ async function update(username, userInputValues) {
id = $1
RETURNING
*
`,
;`,
values: [
userWithNewValues.id,
userWithNewValues.username,
Expand Down Expand Up @@ -193,6 +224,12 @@ async function hashPasswordInObject(userInputValues) {
userInputValues.password = hashedPassword;
}

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

export default user;
10 changes: 1 addition & 9 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.js";
import authentication from "models/authentication.js";
import session from "models/session.js";
Expand All @@ -20,14 +19,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);
}
27 changes: 27 additions & 0 deletions pages/api/v1/user/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createRouter } from "next-connect";
import controller from "infra/controller";
import session from "models/session";
import user from "models/user";

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);
await session.renew(sessionObject.id);
controller.setSessionCookie(sessionObject.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);
}
2 changes: 1 addition & 1 deletion tests/integration/api/v1/migrations/get.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";

beforeAll(async () => {
await orchestrator.waitForAllServices();
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/v1/migrations/post.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";

beforeAll(async () => {
await orchestrator.waitForAllServices();
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/v1/sessions/post.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { version as uuidVersion } from "uuid";
import setCookieParser from "set-cookie-parser";
import orchestrator from "infra/scripts/orchestrator.js";
import orchestrator from "tests/orchestrator.js";
import session from "models/session.js";

beforeAll(async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/v1/status/get.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";

beforeAll(async () => {
await orchestrator.waitForAllServices();
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/v1/status/post.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";

beforeAll(async () => {
await orchestrator.waitForAllServices();
Expand Down
117 changes: 117 additions & 0 deletions tests/integration/api/v1/user/get.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { version as uuidVersion } from "uuid";
import setCookieParser from "set-cookie-parser";
import orchestrator from "tests/orchestrator.js";
import session from "models/session";

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

describe("GET to 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, max-age=0, 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();

// Test session renewal
const renewedSessionObject = await session.findOneValidByToken(
sessionObject.token,
);
expect(renewedSessionObject.expires_at > sessionObject.expires_at).toBe(
true,
);
expect(renewedSessionObject.updated_at > sessionObject.updated_at).toBe(
true,
);

// Test Set-Cookie
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 =
"883f4782ed6c87d7f30ab3351f4614591ddeb148ae73a214d9f05b848d53f4377c973559aba52e907263835ba5dc7a97";

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: "User do not have an active session.",
action: "Verify if you are logged in and try again.",
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: "User do not have an active session.",
action: "Verify if you are logged in and try again.",
status_code: 401,
});
});
});
});
2 changes: 1 addition & 1 deletion tests/integration/api/v1/users/[username]/get.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";
import { version as uuidVersion } from "uuid";

beforeAll(async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/v1/users/[username]/patch.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";
import password from "models/password";
import { version as uuidVersion } from "uuid";

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/api/v1/users/post.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import orchestrator from "infra/scripts/orchestrator";
import orchestrator from "tests/orchestrator.js";
import { version as uuidVersion } from "uuid";
import user from "models/user.js";
import password from "models/password.js";
Expand Down
Loading