From ab5dcc113e2f73c1fa6d94d75425e1339d00bd94 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 15:57:56 +0200 Subject: [PATCH 01/34] feat: check user roles --- @types/fastify/fastify.d.ts | 7 -- migrations/001.do.users.sql | 2 +- migrations/002.do.tasks.sql | 2 +- migrations/003.do.user_tasks.sql | 2 +- migrations/004.do.roles_and_user_roles.sql | 17 ++++ migrations/004.undo.roles_and_user_roles.sql | 7 ++ package.json | 4 +- scripts/seed-database.js | 60 --------------- src/plugins/custom/authorization.ts | 44 +++++++++++ src/plugins/custom/repository.ts | 28 ++++++- src/plugins/custom/scrypt.ts | 2 +- src/routes/api/auth/index.ts | 27 +++++-- src/routes/api/autohooks.ts | 3 +- src/routes/api/tasks/index.ts | 6 +- src/schemas/auth.ts | 6 +- scripts/migrate.js => src/scripts/migrate.ts | 22 +++--- src/scripts/seed-database.ts | 81 ++++++++++++++++++++ test/helper.ts | 4 +- test/routes/api/auth/auth.test.ts | 2 +- test/routes/api/tasks/tasks.test.ts | 36 ++++++--- 20 files changed, 250 insertions(+), 112 deletions(-) delete mode 100644 @types/fastify/fastify.d.ts create mode 100644 migrations/004.do.roles_and_user_roles.sql create mode 100644 migrations/004.undo.roles_and_user_roles.sql delete mode 100644 scripts/seed-database.js create mode 100644 src/plugins/custom/authorization.ts rename scripts/migrate.js => src/scripts/migrate.ts (62%) create mode 100644 src/scripts/seed-database.ts diff --git a/@types/fastify/fastify.d.ts b/@types/fastify/fastify.d.ts deleted file mode 100644 index f85cbfd5..00000000 --- a/@types/fastify/fastify.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Auth } from "../../src/schemas/auth.ts"; - -declare module "fastify" { - export interface FastifyRequest { - user: Auth - } -} diff --git a/migrations/001.do.users.sql b/migrations/001.do.users.sql index 66dcd900..371c393a 100644 --- a/migrations/001.do.users.sql +++ b/migrations/001.do.users.sql @@ -1,4 +1,4 @@ -CREATE TABLE users ( +CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, diff --git a/migrations/002.do.tasks.sql b/migrations/002.do.tasks.sql index 9c08a4e2..e33cf06f 100644 --- a/migrations/002.do.tasks.sql +++ b/migrations/002.do.tasks.sql @@ -1,4 +1,4 @@ -CREATE TABLE tasks ( +CREATE TABLE IF NOT EXISTS tasks ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, author_id INT NOT NULL, diff --git a/migrations/003.do.user_tasks.sql b/migrations/003.do.user_tasks.sql index 6abc7512..44a0c8e8 100644 --- a/migrations/003.do.user_tasks.sql +++ b/migrations/003.do.user_tasks.sql @@ -1,4 +1,4 @@ -CREATE TABLE user_tasks ( +CREATE TABLE IF NOT EXISTS user_tasks ( user_id INT NOT NULL, task_id INT NOT NULL, PRIMARY KEY (user_id, task_id), diff --git a/migrations/004.do.roles_and_user_roles.sql b/migrations/004.do.roles_and_user_roles.sql new file mode 100644 index 00000000..182ab0f6 --- /dev/null +++ b/migrations/004.do.roles_and_user_roles.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + role_id INT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE +); + +ALTER TABLE users +ADD COLUMN user_role_id INT, +ADD CONSTRAINT fk_user_role + FOREIGN KEY (user_role_id) REFERENCES user_roles(id) ON DELETE SET NULL; diff --git a/migrations/004.undo.roles_and_user_roles.sql b/migrations/004.undo.roles_and_user_roles.sql new file mode 100644 index 00000000..8220f3db --- /dev/null +++ b/migrations/004.undo.roles_and_user_roles.sql @@ -0,0 +1,7 @@ +ALTER TABLE users +DROP FOREIGN KEY fk_user_role, +DROP COLUMN user_role_id; + +DROP TABLE IF EXISTS user_roles; + +DROP TABLE IF EXISTS roles; diff --git a/package.json b/package.json index 85f45e4f..402baf4f 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "standalone": "node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", - "db:migrate": "node --env-file=.env scripts/migrate.js", - "db:seed": "node --env-file=.env scripts/seed-database.js" + "db:migrate": "node --loader ts-node/esm --env-file=.env src/scripts/migrate.ts", + "db:seed": "node --loader ts-node/esm --env-file=.env src/scripts/seed-database.ts" }, "keywords": [], "author": "Michelet Jean ", diff --git a/scripts/seed-database.js b/scripts/seed-database.js deleted file mode 100644 index 1c5acc93..00000000 --- a/scripts/seed-database.js +++ /dev/null @@ -1,60 +0,0 @@ -import { createConnection } from 'mysql2/promise' - -async function seed () { - const connection = await createConnection({ - multipleStatements: true, - host: process.env.MYSQL_HOST, - port: Number(process.env.MYSQL_PORT), - database: process.env.MYSQL_DATABASE, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD - }) - - try { - await truncateTables(connection) - await seedUsers(connection) - - /* c8 ignore start */ - } catch (error) { - console.error('Error seeding database:', error) - } finally { - /* c8 ignore end */ - await connection.end() - } -} - -async function truncateTables (connection) { - const [tables] = await connection.query('SHOW TABLES') - - if (tables.length > 0) { - const tableNames = tables.map((row) => row[`Tables_in_${process.env.MYSQL_DATABASE}`]) - const truncateQueries = tableNames.map((tableName) => `TRUNCATE TABLE \`${tableName}\``).join('; ') - - await connection.query('SET FOREIGN_KEY_CHECKS = 0') - try { - await connection.query(truncateQueries) - console.log('All tables have been truncated successfully.') - } finally { - await connection.query('SET FOREIGN_KEY_CHECKS = 1') - } - } -} - -async function seedUsers (connection) { - const usernames = ['basic', 'moderator', 'admin'] - - for (const username of usernames) { - // Generated hash for plain text 'password' - const hash = '918933f991bbf22eade96420811e46b4.b2e2105880b90b66bf6d6247a42a81368819a1c57c07165cf8b25df80b5752bb' - const insertUserQuery = ` - INSERT INTO users (username, password) - VALUES (?, ?) - ` - - await connection.execute(insertUserQuery, [username, hash]) - } - - console.log('Users have been seeded successfully.') -} - -seed() diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts new file mode 100644 index 00000000..e8a3e777 --- /dev/null +++ b/src/plugins/custom/authorization.ts @@ -0,0 +1,44 @@ +import fp from "fastify-plugin"; +import { FastifyReply, FastifyRequest } from "fastify"; +import { Auth } from "../../schemas/auth.js"; + +declare module "fastify" { + export interface FastifyInstance { + isModerator: typeof isModerator; + isAdmin: typeof isAdmin; + } +} + +function verifyAccess( + request: FastifyRequest, + reply: FastifyReply, + role: string +) { + if (!request.user || !(request.user as Auth).roles.includes(role)) { + reply.status(403).send("You are not authorized to access this resource."); + } +} + +async function isModerator(request: FastifyRequest, reply: FastifyReply) { + verifyAccess(request, reply, 'moderator') +} + +async function isAdmin(request: FastifyRequest, reply: FastifyReply) { + verifyAccess(request, reply, 'admin') +} + +/** + * The use of fastify-plugin is required to be able + * to export the decorators to the outer scope + * + * @see {@link https://github.com/fastify/fastify-plugin} + */ +export default fp( + async function (fastify) { + fastify.decorate("isModerator", isModerator); + fastify.decorate("isAdmin", isAdmin); + // You should name your plugins if you want to avoid name collisions + // and/or to perform dependency checks. + }, + { name: "authorization", dependencies: ["mysql"] } +); diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts index da3cd518..f4ee332b 100644 --- a/src/plugins/custom/repository.ts +++ b/src/plugins/custom/repository.ts @@ -16,6 +16,11 @@ type QuerySeparator = 'AND' | ','; type QueryOptions = { select?: string; where?: Record; + join?: { + table: string; + on: string; + type?: 'INNER' | 'LEFT' | 'RIGHT'; + }[]; }; type WriteOptions = { @@ -32,13 +37,27 @@ function createRepository(fastify: FastifyInstance) { return [clause, values] as const; }; + const buildJoinClause = (joins: QueryOptions['join']) => { + if (!joins || joins.length === 0) { + return ''; + } + + return joins + .map((join) => { + const joinType = join.type ?? 'INNER'; // Default to INNER join + return `${joinType} JOIN ${join.table} ON ${join.on}`; + }) + .join(' '); + }; + const repository = { ...fastify.mysql, find: async (table: string, opts: QueryOptions): Promise => { - const { select = '*', where = {1:1} } = opts; + const { select = '*', where = {1:1}, join } = opts; const [clause, values] = processAssignmentRecord(where, 'AND'); + const joinClause = buildJoinClause(join); - const query = `SELECT ${select} FROM ${table} WHERE ${clause} LIMIT 1`; + const query = `SELECT ${select} FROM ${table} ${joinClause} WHERE ${clause} LIMIT 1`; const [rows] = await fastify.mysql.query(query, values); if (rows.length < 1) { return null; @@ -48,10 +67,11 @@ function createRepository(fastify: FastifyInstance) { }, findMany: async (table: string, opts: QueryOptions = {}): Promise => { - const { select = '*', where = {1:1} } = opts; + const { select = '*', where = {1:1}, join } = opts; const [clause, values] = processAssignmentRecord(where, 'AND'); + const joinClause = buildJoinClause(join); - const query = `SELECT ${select} FROM ${table} WHERE ${clause}`; + const query = `SELECT ${select} FROM ${table} ${joinClause} WHERE ${clause}`; const [rows] = await fastify.mysql.query(query, values); return rows as T[]; diff --git a/src/plugins/custom/scrypt.ts b/src/plugins/custom/scrypt.ts index f643377b..5f337307 100644 --- a/src/plugins/custom/scrypt.ts +++ b/src/plugins/custom/scrypt.ts @@ -14,7 +14,7 @@ const SCRYPT_BLOCK_SIZE = 8 const SCRYPT_PARALLELIZATION = 2 const SCRYPT_MAXMEM = 128 * SCRYPT_COST * SCRYPT_BLOCK_SIZE * 2 -async function scryptHash(value: string): Promise { +export async function scryptHash(value: string): Promise { return new Promise((resolve, reject) => { const salt = randomBytes(Math.min(16, SCRYPT_KEYLEN / 2)) diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index 174a9821..9c13ae97 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -2,7 +2,7 @@ import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; -import { CredentialsSchema, Auth } from "../../../schemas/auth.js"; +import { CredentialsSchema, Credentials } from "../../../schemas/auth.js"; const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.post( @@ -24,22 +24,37 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { async function (request, reply) { const { username, password } = request.body; - const user = await fastify.repository.find('users', { - select: 'username, password', + const user = await fastify.repository.find("users", { + select: "username, password", where: { username } - }) + }); if (user) { const isPasswordValid = await fastify.compare(password, user.password); if (isPasswordValid) { - const token = fastify.jwt.sign({ username: user.username }); + const roles = await this.repository.findMany<{ name: string }>( + "roles", + { + select: "roles.name", + join: [ + { table: "user_roles", on: "roles.id = user_roles.role_id" }, + { table: "users", on: "user_roles.user_id = users.id" } + ], + where: { "users.username": username } + } + ); + + const token = fastify.jwt.sign({ + username: user.username, + roles: roles.map((role) => role.name) + }); return { token }; } } reply.status(401); - + return { message: "Invalid username or password." }; } ); diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts index 3a554d48..af7fc545 100644 --- a/src/routes/api/autohooks.ts +++ b/src/routes/api/autohooks.ts @@ -1,10 +1,9 @@ import { FastifyInstance } from "fastify"; - export default async function (fastify: FastifyInstance) { fastify.addHook("onRequest", async (request) => { if (!request.url.startsWith("/api/auth/login")) { - await request.jwtVerify(); + await request.jwtVerify() } }); } diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 8db0749f..438305ba 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -122,7 +122,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 404: Type.Object({ message: Type.String() }) }, tags: ["Tasks"] - } + }, + preHandler: fastify.isAdmin }, async function (request, reply) { const { id } = request.params; @@ -151,7 +152,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 404: Type.Object({ message: Type.String() }) }, tags: ["Tasks"] - } + }, + preHandler: fastify.isModerator }, async function (request, reply) { const { id } = request.params; diff --git a/src/schemas/auth.ts b/src/schemas/auth.ts index 83ba0b0d..eb55238f 100644 --- a/src/schemas/auth.ts +++ b/src/schemas/auth.ts @@ -5,4 +5,8 @@ export const CredentialsSchema = Type.Object({ password: Type.String() }); -export interface Auth extends Static {} +export interface Credentials extends Static {} + +export interface Auth extends Omit { + roles: string[] +} diff --git a/scripts/migrate.js b/src/scripts/migrate.ts similarity index 62% rename from scripts/migrate.js rename to src/scripts/migrate.ts index 1eab2ca4..c0de5d59 100644 --- a/scripts/migrate.js +++ b/src/scripts/migrate.ts @@ -1,24 +1,28 @@ -import mysql from 'mysql2/promise' +import mysql, { Connection } from 'mysql2/promise' import path from 'path' import Postgrator from 'postgrator' -async function doMigration () { - const connection = await mysql.createConnection({ +interface PostgratorResult { + rows: any + fields: any +} + +async function doMigration (): Promise { + const connection: Connection = await mysql.createConnection({ multipleStatements: true, host: process.env.MYSQL_HOST, - port: process.env.MYSQL_PORT, + port: Number(process.env.MYSQL_PORT), database: process.env.MYSQL_DATABASE, user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD }) const postgrator = new Postgrator({ - migrationPattern: path.join(import.meta.dirname, '../migrations', '*'), + migrationPattern: path.join(import.meta.dirname, '../migrations', '*'), driver: 'mysql', database: process.env.MYSQL_DATABASE, - execQuery: async (query) => { + execQuery: async (query: string): Promise => { const [rows, fields] = await connection.query(query) - return { rows, fields } }, schemaTable: 'schemaversion' @@ -26,8 +30,8 @@ async function doMigration () { await postgrator.migrate() - await new Promise((resolve, reject) => { - connection.end((err) => { + await new Promise((resolve, reject) => { + connection.end((err: unknown) => { if (err) { return reject(err) } diff --git a/src/scripts/seed-database.ts b/src/scripts/seed-database.ts new file mode 100644 index 00000000..3edbc012 --- /dev/null +++ b/src/scripts/seed-database.ts @@ -0,0 +1,81 @@ +import { createConnection, Connection } from 'mysql2/promise' +import { scryptHash } from '../plugins/custom/scrypt.js' + +async function seed () { + const connection: Connection = await createConnection({ + multipleStatements: true, + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + database: process.env.MYSQL_DATABASE, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + try { + await truncateTables(connection) + await seedUsers(connection) + + /* c8 ignore start */ + } catch (error) { + console.error('Error seeding database:', error) + } finally { + /* c8 ignore end */ + await connection.end() + } +} + +async function truncateTables (connection: Connection) { + const [tables]: any[] = await connection.query('SHOW TABLES') + + if (tables.length > 0) { + const tableNames = tables.map( + (row: { [key: string]: string }) => row[`Tables_in_${process.env.MYSQL_DATABASE}`] + ) + const truncateQueries = tableNames + .map((tableName: string) => `TRUNCATE TABLE \`${tableName}\``) + .join('; ') + + await connection.query('SET FOREIGN_KEY_CHECKS = 0') + try { + await connection.query(truncateQueries) + console.log('All tables have been truncated successfully.') + } finally { + await connection.query('SET FOREIGN_KEY_CHECKS = 1') + } + } +} + +async function seedUsers (connection: Connection) { + const usernames = ['basic', 'moderator', 'admin'] + const hash = await scryptHash('password123$') + + const roleAccumulator: number[] = [] + for (const username of usernames) { + const [userResult]: any[] = await connection.execute(` + INSERT INTO users (username, password) + VALUES (?, ?) + `, [username, hash]) + + const userId = (userResult as { insertId: number }).insertId + + const [roleResult]: any[] = await connection.execute(` + INSERT INTO roles (name) + VALUES (?) + `, [username]) + + const newRoleId = (roleResult as { insertId: number }).insertId + + roleAccumulator.push(newRoleId) + + for (const roleId of roleAccumulator) { + await connection.execute(` + INSERT INTO user_roles (user_id, role_id) + VALUES (?, ?) + `, [userId, roleId]) + } + } + + console.log('Users have been seeded successfully.') +} + +seed() diff --git a/test/helper.ts b/test/helper.ts index dad5f983..1b3d06f6 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -23,7 +23,7 @@ export function config() { } const tokens: Record = {} -// We will create different users with different roles +// @See src/scripts/seed-database.ts async function login(this: FastifyInstance, username: string) { if (tokens[username]) { return tokens[username] @@ -34,7 +34,7 @@ async function login(this: FastifyInstance, username: string) { url: "/api/auth/login", payload: { username, - password: "password" + password: 'password123$' } }); diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index fbb2f725..e6cc7f9d 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -10,7 +10,7 @@ test("POST /api/auth/login with valid credentials", async (t) => { url: "/api/auth/login", payload: { username: "basic", - password: "password" + password: "password123$" } }); diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index ae82067c..fe611299 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -145,17 +145,17 @@ describe('Tasks api (logged user only)', () => { }); describe('DELETE /api/tasks/:id', () => { + const taskData = { + name: "Task to Delete", + author_id: 1, + status: TaskStatus.New + }; + it("should delete an existing task", async (t) => { const app = await build(t); - - const taskData = { - name: "Task to Delete", - author_id: 1, - status: TaskStatus.New - }; const newTaskId = await createTask(app, taskData); - const res = await app.injectWithLogin("basic", { + const res = await app.injectWithLogin("admin", { method: "DELETE", url: `/api/tasks/${newTaskId}` }); @@ -169,7 +169,7 @@ describe('Tasks api (logged user only)', () => { it("should return 404 if task is not found for deletion", async (t) => { const app = await build(t); - const res = await app.injectWithLogin("basic", { + const res = await app.injectWithLogin("admin", { method: "DELETE", url: "/api/tasks/9999" }); @@ -181,7 +181,6 @@ describe('Tasks api (logged user only)', () => { }); describe('POST /api/tasks/:id/assign', () => { - it("should assign a task to a user and persist the changes", async (t) => { const app = await build(t); @@ -192,7 +191,7 @@ describe('Tasks api (logged user only)', () => { }; const newTaskId = await createTask(app, taskData); - const res = await app.injectWithLogin("basic", { + const res = await app.injectWithLogin("moderator", { method: "POST", url: `/api/tasks/${newTaskId}/assign`, payload: { @@ -217,7 +216,7 @@ describe('Tasks api (logged user only)', () => { }; const newTaskId = await createTask(app, taskData); - const res = await app.injectWithLogin("basic", { + const res = await app.injectWithLogin("moderator", { method: "POST", url: `/api/tasks/${newTaskId}/assign`, payload: {} @@ -228,11 +227,24 @@ describe('Tasks api (logged user only)', () => { const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; assert.strictEqual(updatedTask.assigned_user_id, null); }); + + it("should return 403 not moderator", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "POST", + url: "/api/tasks/1/assign", + payload: { + } + }); + + assert.strictEqual(res.statusCode, 403); + }); it("should return 404 if task is not found", async (t) => { const app = await build(t); - const res = await app.injectWithLogin("basic", { + const res = await app.injectWithLogin("moderator", { method: "POST", url: "/api/tasks/9999/assign", payload: { From 23cb2942b4107d1ffc54d988ea72b98fb5ea6fbc Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 16:09:02 +0200 Subject: [PATCH 02/34] refactor: nit --- src/scripts/seed-database.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/scripts/seed-database.ts b/src/scripts/seed-database.ts index 3edbc012..838a1765 100644 --- a/src/scripts/seed-database.ts +++ b/src/scripts/seed-database.ts @@ -49,7 +49,10 @@ async function seedUsers (connection: Connection) { const usernames = ['basic', 'moderator', 'admin'] const hash = await scryptHash('password123$') - const roleAccumulator: number[] = [] + // The goal here is to create a role hierarchy + // E.g. an admin should have all the roles + const rolesAccumulator: number[] = [] + for (const username of usernames) { const [userResult]: any[] = await connection.execute(` INSERT INTO users (username, password) @@ -65,9 +68,9 @@ async function seedUsers (connection: Connection) { const newRoleId = (roleResult as { insertId: number }).insertId - roleAccumulator.push(newRoleId) + rolesAccumulator.push(newRoleId) - for (const roleId of roleAccumulator) { + for (const roleId of rolesAccumulator) { await connection.execute(` INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) From 5b04aef18d71d16f07ac74edbc65d98effbfee8c Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 16:12:27 +0200 Subject: [PATCH 03/34] test: ensure and admin can assign and unassign a task --- package.json | 3 +- src/scripts/seed-database.ts | 2 +- test/routes/api/tasks/tasks.test.ts | 68 +++++++++++++++-------------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 79251e05..a59e41b2 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "fastify": "^5.0.0", "fastify-cli": "^7.0.0", "fastify-plugin": "^5.0.1", - "postgrator": "^7.3.0" + "postgrator": "^7.3.0", + "ts-node": "^10.9.2" }, "devDependencies": { "@types/node": "^22.5.5", diff --git a/src/scripts/seed-database.ts b/src/scripts/seed-database.ts index 838a1765..98c83e8b 100644 --- a/src/scripts/seed-database.ts +++ b/src/scripts/seed-database.ts @@ -52,7 +52,7 @@ async function seedUsers (connection: Connection) { // The goal here is to create a role hierarchy // E.g. an admin should have all the roles const rolesAccumulator: number[] = [] - + for (const username of usernames) { const [userResult]: any[] = await connection.execute(` INSERT INTO users (username, password) diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index ef4f248e..77f5ce90 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -181,51 +181,55 @@ describe('Tasks api (logged user only)', () => { it('should assign a task to a user and persist the changes', async (t) => { const app = await build(t) - const taskData = { - name: 'Task to Assign', - author_id: 1, - status: TaskStatus.New - } - const newTaskId = await createTask(app, taskData) - - const res = await app.injectWithLogin('moderator', { - method: 'POST', - url: `/api/tasks/${newTaskId}/assign`, - payload: { - userId: 2 + for (const username of ['moderator', 'admin']) { + const taskData = { + name: 'Task to Assign', + author_id: 1, + status: TaskStatus.New } - }) + const newTaskId = await createTask(app, taskData) - assert.strictEqual(res.statusCode, 200) + const res = await app.injectWithLogin(username, { + method: 'POST', + url: `/api/tasks/${newTaskId}/assign`, + payload: { + userId: 2 + } + }) - const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task - assert.strictEqual(updatedTask.assigned_user_id, 2) + assert.strictEqual(res.statusCode, 200) + + const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task + assert.strictEqual(updatedTask.assigned_user_id, 2) + } }) it('should unassign a task from a user and persist the changes', async (t) => { const app = await build(t) - const taskData = { - name: 'Task to Unassign', - author_id: 1, - assigned_user_id: 2, - status: TaskStatus.New - } - const newTaskId = await createTask(app, taskData) + for (const username of ['moderator', 'admin']) { + const taskData = { + name: 'Task to Unassign', + author_id: 1, + assigned_user_id: 2, + status: TaskStatus.New + } + const newTaskId = await createTask(app, taskData) - const res = await app.injectWithLogin('moderator', { - method: 'POST', - url: `/api/tasks/${newTaskId}/assign`, - payload: {} - }) + const res = await app.injectWithLogin(username, { + method: 'POST', + url: `/api/tasks/${newTaskId}/assign`, + payload: {} + }) - assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task - assert.strictEqual(updatedTask.assigned_user_id, null) + const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task + assert.strictEqual(updatedTask.assigned_user_id, null) + } }) - it('should return 403 not moderator', async (t) => { + it('should return 403 if not a moderator', async (t) => { const app = await build(t) const res = await app.injectWithLogin('basic', { From 70f95bee456c38f793199e87d6e22cc6630c3e01 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 16:16:16 +0200 Subject: [PATCH 04/34] fix: authorization plugin has no dependency --- src/plugins/custom/authorization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts index 8a7f239f..023d0c7d 100644 --- a/src/plugins/custom/authorization.ts +++ b/src/plugins/custom/authorization.ts @@ -40,5 +40,5 @@ export default fp( // You should name your plugins if you want to avoid name collisions // and/or to perform dependency checks. }, - { name: 'authorization', dependencies: ['mysql'] } + { name: 'authorization' } ) From 992977f280e04176d9ab3193beb84e311ef3e6c5 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 18:05:48 +0200 Subject: [PATCH 05/34] fix: update migrations dir path --- .env.example | 2 ++ docker-compose.yml | 2 +- migrations/001.do.users.sql | 2 +- migrations/002.do.tasks.sql | 2 +- migrations/003.do.user_tasks.sql | 2 +- migrations/004.do.roles.sql | 4 ++++ migrations/004.do.roles_and_user_roles.sql | 17 ----------------- migrations/004.undo.roles.sql | 1 + migrations/004.undo.roles_and_user_roles.sql | 7 ------- migrations/005.do.user_roles.sql | 7 +++++++ migrations/005.undo.user_roles.sql | 1 + src/scripts/migrate.ts | 8 ++++++-- 12 files changed, 25 insertions(+), 30 deletions(-) create mode 100644 migrations/004.do.roles.sql delete mode 100644 migrations/004.do.roles_and_user_roles.sql create mode 100644 migrations/004.undo.roles.sql delete mode 100644 migrations/004.undo.roles_and_user_roles.sql create mode 100644 migrations/005.do.user_roles.sql create mode 100644 migrations/005.undo.user_roles.sql diff --git a/.env.example b/.env.example index d6f44897..141da113 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ # @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} NODE_ENV=production +ALLOW_DROP_DATABASE=0 + # Database MYSQL_HOST=localhost MYSQL_PORT=3306 diff --git a/docker-compose.yml b/docker-compose.yml index 10830fc3..43421fb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,4 @@ services: - db_data:/var/lib/mysql volumes: - db_data: + db_data: \ No newline at end of file diff --git a/migrations/001.do.users.sql b/migrations/001.do.users.sql index 371c393a..66dcd900 100644 --- a/migrations/001.do.users.sql +++ b/migrations/001.do.users.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS users ( +CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, diff --git a/migrations/002.do.tasks.sql b/migrations/002.do.tasks.sql index e33cf06f..9c08a4e2 100644 --- a/migrations/002.do.tasks.sql +++ b/migrations/002.do.tasks.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS tasks ( +CREATE TABLE tasks ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, author_id INT NOT NULL, diff --git a/migrations/003.do.user_tasks.sql b/migrations/003.do.user_tasks.sql index 44a0c8e8..6abc7512 100644 --- a/migrations/003.do.user_tasks.sql +++ b/migrations/003.do.user_tasks.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS user_tasks ( +CREATE TABLE user_tasks ( user_id INT NOT NULL, task_id INT NOT NULL, PRIMARY KEY (user_id, task_id), diff --git a/migrations/004.do.roles.sql b/migrations/004.do.roles.sql new file mode 100644 index 00000000..0dbae96b --- /dev/null +++ b/migrations/004.do.roles.sql @@ -0,0 +1,4 @@ +CREATE TABLE roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL +); diff --git a/migrations/004.do.roles_and_user_roles.sql b/migrations/004.do.roles_and_user_roles.sql deleted file mode 100644 index 182ab0f6..00000000 --- a/migrations/004.do.roles_and_user_roles.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE IF NOT EXISTS roles ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL -); - -CREATE TABLE IF NOT EXISTS user_roles ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - role_id INT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE -); - -ALTER TABLE users -ADD COLUMN user_role_id INT, -ADD CONSTRAINT fk_user_role - FOREIGN KEY (user_role_id) REFERENCES user_roles(id) ON DELETE SET NULL; diff --git a/migrations/004.undo.roles.sql b/migrations/004.undo.roles.sql new file mode 100644 index 00000000..06e938c2 --- /dev/null +++ b/migrations/004.undo.roles.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS roles; diff --git a/migrations/004.undo.roles_and_user_roles.sql b/migrations/004.undo.roles_and_user_roles.sql deleted file mode 100644 index 8220f3db..00000000 --- a/migrations/004.undo.roles_and_user_roles.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE users -DROP FOREIGN KEY fk_user_role, -DROP COLUMN user_role_id; - -DROP TABLE IF EXISTS user_roles; - -DROP TABLE IF EXISTS roles; diff --git a/migrations/005.do.user_roles.sql b/migrations/005.do.user_roles.sql new file mode 100644 index 00000000..1ad3d932 --- /dev/null +++ b/migrations/005.do.user_roles.sql @@ -0,0 +1,7 @@ +CREATE TABLE user_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + role_id INT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE +); diff --git a/migrations/005.undo.user_roles.sql b/migrations/005.undo.user_roles.sql new file mode 100644 index 00000000..71fd1451 --- /dev/null +++ b/migrations/005.undo.user_roles.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_roles; diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 01b17766..5be4a852 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -18,7 +18,7 @@ async function doMigration (): Promise { }) const postgrator = new Postgrator({ - migrationPattern: path.join(import.meta.dirname, '../migrations', '*'), + migrationPattern: path.join(import.meta.dirname, '../../migrations', '*'), driver: 'mysql', database: process.env.MYSQL_DATABASE, execQuery: async (query: string): Promise => { @@ -30,14 +30,18 @@ async function doMigration (): Promise { await postgrator.migrate() + console.log("Migration completed!") + await new Promise((resolve, reject) => { connection.end((err: unknown) => { if (err) { return reject(err) } + resolve() }) }) } -doMigration().catch(err => console.error(err)) +doMigration() +.catch(err => console.error(err)) From cf526c0471749bcf2032d9f7fa153237b6d3d4f5 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 18:09:37 +0200 Subject: [PATCH 06/34] fix: eslint --- src/scripts/migrate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 5be4a852..1ce8c7af 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -30,7 +30,7 @@ async function doMigration (): Promise { await postgrator.migrate() - console.log("Migration completed!") + console.log('Migration completed!') await new Promise((resolve, reject) => { connection.end((err: unknown) => { @@ -44,4 +44,4 @@ async function doMigration (): Promise { } doMigration() -.catch(err => console.error(err)) + .catch(err => console.error(err)) From 6087f515bbc0b06e3bdd8f576570205a0c01991c Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 18:14:41 +0200 Subject: [PATCH 07/34] refactor: nit --- .github/workflows/ci.yml | 1 + docker-compose.yml | 2 +- src/plugins/custom/authorization.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e040d3f..f095f1ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: paths-ignore: - "docs/**" - "*.md" + - "*.example" pull_request: paths-ignore: - "docs/**" diff --git a/docker-compose.yml b/docker-compose.yml index 43421fb9..10830fc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,4 @@ services: - db_data:/var/lib/mysql volumes: - db_data: \ No newline at end of file + db_data: diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts index 023d0c7d..a3cd39ae 100644 --- a/src/plugins/custom/authorization.ts +++ b/src/plugins/custom/authorization.ts @@ -37,8 +37,8 @@ export default fp( async function (fastify) { fastify.decorate('isModerator', isModerator) fastify.decorate('isAdmin', isAdmin) - // You should name your plugins if you want to avoid name collisions - // and/or to perform dependency checks. }, + // You should name your plugins if you want to avoid name collisions + // and/or to perform dependency checks. { name: 'authorization' } ) From 9c4c09e5be0c97ea2f101f00b464faaddd7cd0ee Mon Sep 17 00:00:00 2001 From: Jean <110341611+jean-michelet@users.noreply.github.com> Date: Sat, 21 Sep 2024 21:38:51 +0200 Subject: [PATCH 08/34] Update .env.example Signed-off-by: Jean <110341611+jean-michelet@users.noreply.github.com> --- .env.example | 2 -- 1 file changed, 2 deletions(-) diff --git a/.env.example b/.env.example index 141da113..d6f44897 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,6 @@ # @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} NODE_ENV=production -ALLOW_DROP_DATABASE=0 - # Database MYSQL_HOST=localhost MYSQL_PORT=3306 From c08ffddae73a7e8957e7919015095e6c3d2a6d9a Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 28 Sep 2024 15:47:09 +0200 Subject: [PATCH 09/34] refactor: use knex --- package.json | 5 +- src/plugins/custom/repository.ts | 130 ------------------------- src/plugins/external/knex.ts | 35 +++++++ src/plugins/external/mysql.ts | 24 ----- src/plugins/external/under-pressure.ts | 8 +- src/routes/api/auth/index.ts | 30 ++---- src/routes/api/autohooks.ts | 7 ++ src/routes/api/tasks/index.ts | 50 ++++------ src/scripts/create-database.ts | 26 +++++ src/scripts/drop-database.ts | 26 +++++ test/plugins/repository.test.ts | 65 ------------- test/routes/api/tasks/tasks.test.ts | 35 +++---- 12 files changed, 145 insertions(+), 296 deletions(-) delete mode 100644 src/plugins/custom/repository.ts create mode 100644 src/plugins/external/knex.ts delete mode 100644 src/plugins/external/mysql.ts create mode 100644 src/scripts/create-database.ts create mode 100644 src/scripts/drop-database.ts delete mode 100644 test/plugins/repository.test.ts diff --git a/package.json b/package.json index a59e41b2..d7854689 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "standalone": "node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", + "db:create": "node --loader ts-node/esm --env-file=.env src/scripts/create-database.ts", + "db:drop": "node --loader ts-node/esm --env-file=.env src/scripts/drop-database.ts", "db:migrate": "node --loader ts-node/esm --env-file=.env src/scripts/migrate.ts", "db:seed": "node --loader ts-node/esm --env-file=.env src/scripts/seed-database.ts" }, @@ -29,7 +31,6 @@ "@fastify/env": "^5.0.1", "@fastify/helmet": "^12.0.0", "@fastify/jwt": "^9.0.0", - "@fastify/mysql": "^5.0.1", "@fastify/rate-limit": "^10.0.1", "@fastify/sensible": "^6.0.1", "@fastify/swagger": "^9.0.0", @@ -41,6 +42,7 @@ "fastify": "^5.0.0", "fastify-cli": "^7.0.0", "fastify-plugin": "^5.0.1", + "knex": "^3.1.0", "postgrator": "^7.3.0", "ts-node": "^10.9.2" }, @@ -48,7 +50,6 @@ "@types/node": "^22.5.5", "eslint": "^9.11.0", "fastify-tsconfig": "^2.0.0", - "mysql2": "^3.11.3", "neostandard": "^0.11.5", "tap": "^21.0.1", "typescript": "^5.6.2" diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts deleted file mode 100644 index 4a0d74b3..00000000 --- a/src/plugins/custom/repository.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { MySQLPromisePool } from '@fastify/mysql' -import { FastifyInstance } from 'fastify' -import fp from 'fastify-plugin' -import { RowDataPacket, ResultSetHeader } from 'mysql2' - -declare module 'fastify' { - export interface FastifyInstance { - repository: Repository; - } -} - -export type Repository = MySQLPromisePool & ReturnType - -type QuerySeparator = 'AND' | ',' - -type QueryOptions = { - select?: string; - where?: Record; - join?: { - table: string; - on: string; - type?: 'INNER' | 'LEFT' | 'RIGHT'; - }[]; -} - -type WriteOptions = { - data: Record; - where?: Record; -} - -function createRepository (fastify: FastifyInstance) { - const processAssignmentRecord = (record: Record, separator: QuerySeparator) => { - const keys = Object.keys(record) - const values = Object.values(record) - const clause = keys.map((key) => `${key} = ?`).join(` ${separator} `) - - return [clause, values] as const - } - - const buildJoinClause = (joins: QueryOptions['join']) => { - if (!joins || joins.length === 0) { - return '' - } - - return joins - .map((join) => { - const joinType = join.type ?? 'INNER' // Default to INNER join - return `${joinType} JOIN ${join.table} ON ${join.on}` - }) - .join(' ') - } - - const repository = { - ...fastify.mysql, - find: async (table: string, opts: QueryOptions): Promise => { - const { select = '*', where = { 1: 1 }, join } = opts - const [clause, values] = processAssignmentRecord(where, 'AND') - const joinClause = buildJoinClause(join) - - const query = `SELECT ${select} FROM ${table} ${joinClause} WHERE ${clause} LIMIT 1` - const [rows] = await fastify.mysql.query(query, values) - - if (rows.length < 1) { - return null - } - - return rows[0] as T - }, - - findMany: async (table: string, opts: QueryOptions = {}): Promise => { - const { select = '*', where = { 1: 1 }, join } = opts - const [clause, values] = processAssignmentRecord(where, 'AND') - const joinClause = buildJoinClause(join) - - const query = `SELECT ${select} FROM ${table} ${joinClause} WHERE ${clause}` - const [rows] = await fastify.mysql.query(query, values) - - return rows as T[] - }, - - create: async (table: string, opts: WriteOptions): Promise => { - const { data } = opts - const columns = Object.keys(data).join(', ') - const placeholders = Object.keys(data).map(() => '?').join(', ') - const values = Object.values(data) - - const query = `INSERT INTO ${table} (${columns}) VALUES (${placeholders})` - const [result] = await fastify.mysql.query(query, values) - - return result.insertId - }, - - update: async (table: string, opts: WriteOptions): Promise => { - const { data, where = {} } = opts - const [dataClause, dataValues] = processAssignmentRecord(data, ',') - const [whereClause, whereValues] = processAssignmentRecord(where, 'AND') - - const query = `UPDATE ${table} SET ${dataClause} WHERE ${whereClause}` - const [result] = await fastify.mysql.query(query, [...dataValues, ...whereValues]) - - return result.affectedRows - }, - - delete: async (table: string, where: Record): Promise => { - const [clause, values] = processAssignmentRecord(where, 'AND') - - const query = `DELETE FROM ${table} WHERE ${clause}` - const [result] = await fastify.mysql.query(query, values) - - return result.affectedRows - } - } - - return repository -} - -/** - * The use of fastify-plugin is required to be able - * to export the decorators to the outer scope - * - * @see {@link https://github.com/fastify/fastify-plugin} - */ -export default fp( - async function (fastify) { - fastify.decorate('repository', createRepository(fastify)) - // You should name your plugins if you want to avoid name collisions - // and/or to perform dependency checks. - }, - { name: 'repository', dependencies: ['mysql'] } -) diff --git a/src/plugins/external/knex.ts b/src/plugins/external/knex.ts new file mode 100644 index 00000000..9dd1fe58 --- /dev/null +++ b/src/plugins/external/knex.ts @@ -0,0 +1,35 @@ +import fp from 'fastify-plugin' +import { FastifyInstance } from 'fastify' +import knex, { Knex } from 'knex' + +declare module 'fastify' { + export interface FastifyInstance { + knex: Knex; + } +} + +export const autoConfig = (fastify: FastifyInstance) => { + return { + client: 'mysql2', + connection: { + host: fastify.config.MYSQL_HOST, + user: fastify.config.MYSQL_USER, + password: fastify.config.MYSQL_PASSWORD, + database: fastify.config.MYSQL_DATABASE, + port: Number(fastify.config.MYSQL_PORT) + }, + pool: { min: 2, max: 10 } + } +} + +const knexPlugin = async (fastify: FastifyInstance) => { + const db = knex(autoConfig(fastify)) + + fastify.decorate('knex', db) + + fastify.addHook('onClose', async (instance) => { + await instance.knex.destroy() + }) +} + +export default fp(knexPlugin, { name: 'knex' }) diff --git a/src/plugins/external/mysql.ts b/src/plugins/external/mysql.ts deleted file mode 100644 index 0d401936..00000000 --- a/src/plugins/external/mysql.ts +++ /dev/null @@ -1,24 +0,0 @@ -import fp from 'fastify-plugin' -import fastifyMysql, { MySQLPromisePool } from '@fastify/mysql' -import { FastifyInstance } from 'fastify' - -declare module 'fastify' { - export interface FastifyInstance { - mysql: MySQLPromisePool; - } -} - -export const autoConfig = (fastify: FastifyInstance) => { - return { - promise: true, - host: fastify.config.MYSQL_HOST, - user: fastify.config.MYSQL_USER, - password: fastify.config.MYSQL_PASSWORD, - database: fastify.config.MYSQL_DATABASE, - port: Number(fastify.config.MYSQL_PORT) - } -} - -export default fp(fastifyMysql, { - name: 'mysql' -}) diff --git a/src/plugins/external/under-pressure.ts b/src/plugins/external/under-pressure.ts index 103a613e..5a101325 100644 --- a/src/plugins/external/under-pressure.ts +++ b/src/plugins/external/under-pressure.ts @@ -11,17 +11,13 @@ export const autoConfig = (fastify: FastifyInstance) => { message: 'The server is under pressure, retry later!', retryAfter: 50, healthCheck: async () => { - let connection try { - connection = await fastify.mysql.getConnection() - await connection.query('SELECT 1;') + await fastify.knex.raw('SELECT 1') return true /* c8 ignore start */ } catch (err) { fastify.log.error(err, 'healthCheck has failed') throw new Error('Database connection is not available') - } finally { - connection?.release() } /* c8 ignore stop */ }, @@ -39,5 +35,5 @@ export const autoConfig = (fastify: FastifyInstance) => { * @see {@link https://www.youtube.com/watch?v=VI29mUA8n9w} */ export default fp(fastifyUnderPressure, { - dependencies: ['mysql'] + dependencies: ['knex'] }) diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index fdae96b2..418c41d6 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -1,7 +1,4 @@ -import { - FastifyPluginAsyncTypebox, - Type -} from '@fastify/type-provider-typebox' +import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' import { CredentialsSchema, Credentials } from '../../../schemas/auth.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { @@ -24,25 +21,19 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { async function (request, reply) { const { username, password } = request.body - const user = await fastify.repository.find('users', { - select: 'username, password', - where: { username } - }) + const user = await fastify.knex('users') + .select('username', 'password') + .where({ username }) + .first() if (user) { const isPasswordValid = await fastify.compare(password, user.password) if (isPasswordValid) { - const roles = await this.repository.findMany<{ name: string }>( - 'roles', - { - select: 'roles.name', - join: [ - { table: 'user_roles', on: 'roles.id = user_roles.role_id' }, - { table: 'users', on: 'user_roles.user_id = users.id' } - ], - where: { 'users.username': username } - } - ) + const roles = await fastify.knex<{ name: string }>('roles') + .select('roles.name') + .join('user_roles', 'roles.id', '=', 'user_roles.role_id') + .join('users', 'user_roles.user_id', '=', 'users.id') + .where('users.username', username) const token = fastify.jwt.sign({ username: user.username, @@ -54,7 +45,6 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } reply.status(401) - return { message: 'Invalid username or password.' } } ) diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts index 08d70345..82cbf408 100644 --- a/src/routes/api/autohooks.ts +++ b/src/routes/api/autohooks.ts @@ -1,4 +1,11 @@ import { FastifyInstance } from 'fastify' +import { Auth } from '../../schemas/auth.js' + +declare module '@fastify/jwt' { + interface FastifyJWT { + user: Auth + } +} export default async function (fastify: FastifyInstance) { fastify.addHook('onRequest', async (request) => { diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 8d0a01e8..5b0a5e61 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -1,14 +1,5 @@ -import { - FastifyPluginAsyncTypebox, - Type -} from '@fastify/type-provider-typebox' -import { - TaskSchema, - Task, - CreateTaskSchema, - UpdateTaskSchema, - TaskStatus -} from '../../../schemas/tasks.js' +import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' +import { TaskSchema, Task, CreateTaskSchema, UpdateTaskSchema, TaskStatus } from '../../../schemas/tasks.js' import { FastifyReply } from 'fastify' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { @@ -23,8 +14,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function () { - const tasks = await fastify.repository.findMany('tasks') - + const tasks = await fastify.knex('tasks').select('*') return tasks } ) @@ -45,7 +35,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, async function (request, reply) { const { id } = request.params - const task = await fastify.repository.find('tasks', { where: { id } }) + const task = await fastify.knex('tasks').where({ id }).first() if (!task) { return notFound(reply) @@ -69,12 +59,11 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function (request, reply) { - const id = await fastify.repository.create('tasks', { data: { ...request.body, status: TaskStatus.New } }) - reply.code(201) + const newTask = { ...request.body, status: TaskStatus.New } + const [id] = await fastify.knex('tasks').insert(newTask) - return { - id - } + reply.code(201) + return { id } } ) @@ -95,18 +84,16 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, async function (request, reply) { const { id } = request.params - const affectedRows = await fastify.repository.update('tasks', { - data: request.body, - where: { id } - }) + const affectedRows = await fastify.knex('tasks') + .where({ id }) + .update(request.body) if (affectedRows === 0) { return notFound(reply) } - const task = await fastify.repository.find('tasks', { where: { id } }) - - return task as Task + const updatedTask = await fastify.knex('tasks').where({ id }).first() + return updatedTask } ) @@ -127,7 +114,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, async function (request, reply) { const { id } = request.params - const affectedRows = await fastify.repository.delete('tasks', { id }) + const affectedRows = await fastify.knex('tasks').where({ id }).delete() if (affectedRows === 0) { return notFound(reply) @@ -159,15 +146,14 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const { id } = request.params const { userId } = request.body - const task = await fastify.repository.find('tasks', { where: { id } }) + const task = await fastify.knex('tasks').where({ id }).first() if (!task) { return notFound(reply) } - await fastify.repository.update('tasks', { - data: { assigned_user_id: userId }, - where: { id } - }) + await fastify.knex('tasks') + .where({ id }) + .update({ assigned_user_id: userId ?? null }) task.assigned_user_id = userId diff --git a/src/scripts/create-database.ts b/src/scripts/create-database.ts new file mode 100644 index 00000000..f381cd4d --- /dev/null +++ b/src/scripts/create-database.ts @@ -0,0 +1,26 @@ +import { createConnection, Connection } from 'mysql2/promise' + +async function createDatabase () { + const connection: Connection = await createConnection({ + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + try { + await createDB(connection) + console.log(`Database ${process.env.MYSQL_DATABASE} has been created successfully.`) + } catch (error) { + console.error('Error creating database:', error) + } finally { + await connection.end() + } +} + +async function createDB (connection: Connection) { + await connection.query(`CREATE DATABASE IF NOT EXISTS \`${process.env.MYSQL_DATABASE}\``) + console.log(`Database ${process.env.MYSQL_DATABASE} created or already exists.`) +} + +createDatabase() diff --git a/src/scripts/drop-database.ts b/src/scripts/drop-database.ts new file mode 100644 index 00000000..4d5cd5a5 --- /dev/null +++ b/src/scripts/drop-database.ts @@ -0,0 +1,26 @@ +import { createConnection, Connection } from 'mysql2/promise' + +async function dropDatabase () { + const connection: Connection = await createConnection({ + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + try { + await dropDB(connection) + console.log(`Database ${process.env.MYSQL_DATABASE} has been dropped successfully.`) + } catch (error) { + console.error('Error dropping database:', error) + } finally { + await connection.end() + } +} + +async function dropDB (connection: Connection) { + await connection.query(`DROP DATABASE IF EXISTS \`${process.env.MYSQL_DATABASE}\``) + console.log(`Database ${process.env.MYSQL_DATABASE} dropped.`) +} + +dropDatabase() diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts deleted file mode 100644 index 9d534af4..00000000 --- a/test/plugins/repository.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { test } from 'tap' -import assert from 'node:assert' -import { execSync } from 'child_process' -import Fastify from 'fastify' -import repository from '../../src/plugins/custom/repository.js' -import * as envPlugin from '../../src/plugins/external/env.js' -import * as mysqlPlugin from '../../src/plugins/external/mysql.js' -import { Auth } from '../../src/schemas/auth.js' - -test('repository works standalone', async (t) => { - const app = Fastify() - - t.after(() => { - app.close() - // Run the seed script again to clean up after tests - execSync('npm run db:seed') - }) - - app.register(envPlugin.default, envPlugin.autoConfig) - app.register(mysqlPlugin.default, mysqlPlugin.autoConfig) - app.register(repository) - - await app.ready() - - // Test find method - const user = await app.repository.find('users', { select: 'username', where: { username: 'basic' } }) - assert.deepStrictEqual(user, { username: 'basic' }) - - const firstUser = await app.repository.find('users', { select: 'username' }) - assert.deepStrictEqual(firstUser, { username: 'basic' }) - - const nullUser = await app.repository.find('users', { select: 'username', where: { username: 'unknown' } }) - assert.equal(nullUser, null) - - // Test findMany method - const users = await app.repository.findMany('users', { select: 'username', where: { username: 'basic' } }) - assert.deepStrictEqual(users, [ - { username: 'basic' } - ]) - - // Test findMany method - const allUsers = await app.repository.findMany('users', { select: 'username' }) - assert.deepStrictEqual(allUsers, [ - { username: 'basic' }, - { username: 'moderator' }, - { username: 'admin' } - ]) - - // Test create method - const newUserId = await app.repository.create('users', { data: { username: 'new_user', password: 'new_password' } }) - const newUser = await app.repository.find('users', { select: 'username', where: { id: newUserId } }) - assert.deepStrictEqual(newUser, { username: 'new_user' }) - - // Test update method - const updateCount = await app.repository.update('users', { data: { password: 'updated_password' }, where: { username: 'new_user' } }) - assert.equal(updateCount, 1) - const updatedUser = await app.repository.find('users', { select: 'password', where: { username: 'new_user' } }) - assert.deepStrictEqual(updatedUser, { password: 'updated_password' }) - - // Test delete method - const deleteCount = await app.repository.delete('users', { username: 'new_user' }) - assert.equal(deleteCount, 1) - const deletedUser = await app.repository.find('users', { select: 'username', where: { username: 'new_user' } }) - assert.equal(deletedUser, null) -}) diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 77f5ce90..351aef48 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -5,7 +5,9 @@ import { Task, TaskStatus } from '../../../../src/schemas/tasks.js' import { FastifyInstance } from 'fastify' async function createTask (app: FastifyInstance, taskData: Partial) { - return await app.repository.create('tasks', { data: taskData }) + const [id] = await app.knex('tasks').insert(taskData) + + return id } describe('Tasks api (logged user only)', () => { @@ -19,7 +21,7 @@ describe('Tasks api (logged user only)', () => { status: TaskStatus.New } - const newTaskId = await app.repository.create('tasks', { data: taskData }) + const newTaskId = await createTask(app, taskData) const res = await app.injectWithLogin('basic', { method: 'GET', @@ -31,9 +33,9 @@ describe('Tasks api (logged user only)', () => { const createdTask = tasks.find((task) => task.id === newTaskId) assert.ok(createdTask, 'Created task should be in the response') - assert.deepStrictEqual(taskData.name, createdTask.name) - assert.strictEqual(taskData.author_id, createdTask.author_id) - assert.strictEqual(taskData.status, createdTask.status) + assert.deepStrictEqual(taskData.name, createdTask?.name) + assert.strictEqual(taskData.author_id, createdTask?.author_id) + assert.strictEqual(taskData.status, createdTask?.status) }) }) @@ -91,8 +93,8 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 201) const { id } = JSON.parse(res.payload) - const createdTask = await app.repository.find('tasks', { select: 'name', where: { id } }) as Task - assert.equal(createdTask.name, taskData.name) + const createdTask = await app.knex('tasks').where({ id }).first() + assert.equal(createdTask?.name, taskData.name) }) }) @@ -118,8 +120,8 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task - assert.equal(updatedTask.name, updatedData.name) + const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + assert.equal(updatedTask?.name, updatedData.name) }) it('should return 404 if task is not found for update', async (t) => { @@ -159,8 +161,8 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 204) - const deletedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) - assert.strictEqual(deletedTask, null) + const deletedTask = await app.knex('tasks').where({ id: newTaskId }).first() + assert.strictEqual(deletedTask, undefined) }) it('should return 404 if task is not found for deletion', async (t) => { @@ -199,8 +201,8 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task - assert.strictEqual(updatedTask.assigned_user_id, 2) + const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + assert.strictEqual(updatedTask?.assigned_user_id, 2) } }) @@ -224,8 +226,8 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task - assert.strictEqual(updatedTask.assigned_user_id, null) + const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + assert.strictEqual(updatedTask?.assigned_user_id, null) } }) @@ -235,8 +237,7 @@ describe('Tasks api (logged user only)', () => { const res = await app.injectWithLogin('basic', { method: 'POST', url: '/api/tasks/1/assign', - payload: { - } + payload: {} }) assert.strictEqual(res.statusCode, 403) From c69e8264b00573588f8e90886a934ea7a1492539 Mon Sep 17 00:00:00 2001 From: jean Date: Wed, 9 Oct 2024 08:30:15 +0200 Subject: [PATCH 10/34] refactor: migrations --- docker-compose.yml | 16 ++++-- imports/ts-node.js | 3 + package.json | 9 +-- {src/scripts => scripts}/create-database.ts | 0 {src/scripts => scripts}/drop-database.ts | 0 scripts/migrate.ts | 61 +++++++++++++++++++++ {src/scripts => scripts}/seed-database.ts | 2 +- src/scripts/migrate.ts | 47 ---------------- tsconfig.json | 2 +- 9 files changed, 82 insertions(+), 58 deletions(-) create mode 100644 imports/ts-node.js rename {src/scripts => scripts}/create-database.ts (100%) rename {src/scripts => scripts}/drop-database.ts (100%) create mode 100644 scripts/migrate.ts rename {src/scripts => scripts}/seed-database.ts (97%) delete mode 100644 src/scripts/migrate.ts diff --git a/docker-compose.yml b/docker-compose.yml index 10830fc3..712b8b3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,19 @@ services: db: image: mysql:8.4 environment: - MYSQL_DATABASE: ${MYSQL_DATABASE} - MYSQL_USER: ${MYSQL_USER} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} ports: - 3306:3306 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-u${MYSQL_USER}", "-p${MYSQL_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 3 volumes: - db_data:/var/lib/mysql - + volumes: - db_data: + db_data: \ No newline at end of file diff --git a/imports/ts-node.js b/imports/ts-node.js new file mode 100644 index 00000000..95737a43 --- /dev/null +++ b/imports/ts-node.js @@ -0,0 +1,3 @@ +import { register } from 'node:module' +import { pathToFileURL } from 'node:url' +register('ts-node/esm', pathToFileURL('./')) diff --git a/package.json b/package.json index d7854689..f80a75d2 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "standalone": "node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", - "db:create": "node --loader ts-node/esm --env-file=.env src/scripts/create-database.ts", - "db:drop": "node --loader ts-node/esm --env-file=.env src/scripts/drop-database.ts", - "db:migrate": "node --loader ts-node/esm --env-file=.env src/scripts/migrate.ts", - "db:seed": "node --loader ts-node/esm --env-file=.env src/scripts/seed-database.ts" + "db:create": "node --import ./imports/ts-node.js --env-file=.env ./scripts/create-database.ts", + "db:drop": "node --import ./imports/ts-node.js --env-file=.env ./scripts/drop-database.ts", + "db:migrate": "node --import ./imports/ts-node.js --env-file=.env ./scripts/migrate.ts", + "db:seed": "node --import ./imports/ts-node.js --env-file=.env ./scripts/seed-database.ts" }, "keywords": [], "author": "Michelet Jean ", @@ -43,6 +43,7 @@ "fastify-cli": "^7.0.0", "fastify-plugin": "^5.0.1", "knex": "^3.1.0", + "mysql2": "^3.11.3", "postgrator": "^7.3.0", "ts-node": "^10.9.2" }, diff --git a/src/scripts/create-database.ts b/scripts/create-database.ts similarity index 100% rename from src/scripts/create-database.ts rename to scripts/create-database.ts diff --git a/src/scripts/drop-database.ts b/scripts/drop-database.ts similarity index 100% rename from src/scripts/drop-database.ts rename to scripts/drop-database.ts diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 00000000..48f6fc6b --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,61 @@ +import mysql, { Connection } from 'mysql2/promise' +import path from 'node:path' +import fs from 'node:fs' +import Postgrator from 'postgrator' + +interface PostgratorResult { + rows: any; + fields: any; +} + +async function doMigration (): Promise { + const connection: Connection = await mysql.createConnection({ + multipleStatements: true, + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + database: process.env.MYSQL_DATABASE, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + try { + const migrationDir = path.join(import.meta.dirname, '../migrations') + + if (!fs.existsSync(migrationDir)) { + throw new Error( + `Migration directory "${migrationDir}" does not exist. Skipping migrations.` + ) + } + + const postgrator = new Postgrator({ + migrationPattern: path.join(migrationDir, '*'), + driver: 'mysql', + database: process.env.MYSQL_DATABASE, + execQuery: async (query: string): Promise => { + const [rows, fields] = await connection.query(query) + return { rows, fields } + }, + schemaTable: 'schemaversion' + }) + + await postgrator.migrate() + + console.log('Migration completed!') + + await new Promise((resolve, reject) => { + connection.end((err: unknown) => { + if (err) { + return reject(err) + } + + resolve() + }) + }) + } catch (error) { + console.error(error) + } finally { + await connection.end() + } +} + +doMigration() diff --git a/src/scripts/seed-database.ts b/scripts/seed-database.ts similarity index 97% rename from src/scripts/seed-database.ts rename to scripts/seed-database.ts index 98c83e8b..a1a02964 100644 --- a/src/scripts/seed-database.ts +++ b/scripts/seed-database.ts @@ -1,5 +1,5 @@ import { createConnection, Connection } from 'mysql2/promise' -import { scryptHash } from '../plugins/custom/scrypt.js' +import { scryptHash } from '../src/plugins/custom/scrypt.js' async function seed () { const connection: Connection = await createConnection({ diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts deleted file mode 100644 index 1ce8c7af..00000000 --- a/src/scripts/migrate.ts +++ /dev/null @@ -1,47 +0,0 @@ -import mysql, { Connection } from 'mysql2/promise' -import path from 'path' -import Postgrator from 'postgrator' - -interface PostgratorResult { - rows: any - fields: any -} - -async function doMigration (): Promise { - const connection: Connection = await mysql.createConnection({ - multipleStatements: true, - host: process.env.MYSQL_HOST, - port: Number(process.env.MYSQL_PORT), - database: process.env.MYSQL_DATABASE, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD - }) - - const postgrator = new Postgrator({ - migrationPattern: path.join(import.meta.dirname, '../../migrations', '*'), - driver: 'mysql', - database: process.env.MYSQL_DATABASE, - execQuery: async (query: string): Promise => { - const [rows, fields] = await connection.query(query) - return { rows, fields } - }, - schemaTable: 'schemaversion' - }) - - await postgrator.migrate() - - console.log('Migration completed!') - - await new Promise((resolve, reject) => { - connection.end((err: unknown) => { - if (err) { - return reject(err) - } - - resolve() - }) - }) -} - -doMigration() - .catch(err => console.error(err)) diff --git a/tsconfig.json b/tsconfig.json index 6fe21f5a..1da6b460 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["@types", "src/**/*.ts"] + "include": ["@types", "src/**/*.ts", "scripts/**/*.ts"] } From edd2b48d490f63465e0e0dd61fe89d99f761a0ef Mon Sep 17 00:00:00 2001 From: jean Date: Wed, 9 Oct 2024 09:01:29 +0200 Subject: [PATCH 11/34] fix: remove useless c8 ignore comments --- docker-compose.yml | 2 +- scripts/seed-database.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 712b8b3d..69bdfc7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,4 +17,4 @@ services: - db_data:/var/lib/mysql volumes: - db_data: \ No newline at end of file + db_data: diff --git a/scripts/seed-database.ts b/scripts/seed-database.ts index a1a02964..aabe4281 100644 --- a/scripts/seed-database.ts +++ b/scripts/seed-database.ts @@ -14,12 +14,9 @@ async function seed () { try { await truncateTables(connection) await seedUsers(connection) - - /* c8 ignore start */ } catch (error) { console.error('Error seeding database:', error) } finally { - /* c8 ignore end */ await connection.end() } } From 74836925dbe2847c220a9b665a5f2a6f54ab6e0f Mon Sep 17 00:00:00 2001 From: jean Date: Wed, 9 Oct 2024 09:07:31 +0200 Subject: [PATCH 12/34] docs: update path --- test/helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helper.ts b/test/helper.ts index aa5ed000..9772dd8c 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -22,7 +22,7 @@ export function config () { } const tokens: Record = {} -// @See src/scripts/seed-database.ts +// @See /scripts/seed-database.ts async function login (this: FastifyInstance, username: string) { if (tokens[username]) { return tokens[username] From 43eb97f37c1d85923c5097abcda2e1a8fb0bf7b4 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 13 Oct 2024 18:03:34 +0200 Subject: [PATCH 13/34] refactor: change JWT auth for cookie session auth --- .env.example | 3 +- .github/workflows/ci.yml | 8 ++--- package.json | 3 +- src/plugins/custom/authorization.ts | 10 ++----- src/plugins/external/cookie.ts | 11 +++++++ src/plugins/external/env.ts | 17 +++++++++-- src/plugins/external/jwt.ts | 10 ------- src/plugins/external/session.ts | 32 ++++++++++++++++++++ src/routes/api/auth/index.ts | 12 ++++---- src/routes/api/autohooks.ts | 19 ++++++------ src/routes/api/index.ts | 4 +-- test/helper.ts | 46 ++++++++++++++++++----------- test/routes/api/api.test.ts | 36 +++++++--------------- test/routes/api/auth/auth.test.ts | 2 +- 14 files changed, 125 insertions(+), 88 deletions(-) create mode 100644 src/plugins/external/cookie.ts delete mode 100644 src/plugins/external/jwt.ts create mode 100644 src/plugins/external/session.ts diff --git a/.env.example b/.env.example index d6f44897..8028311d 100644 --- a/.env.example +++ b/.env.example @@ -14,5 +14,6 @@ FASTIFY_CLOSE_GRACE_DELAY=1000 LOG_LEVEL=info # Security -JWT_SECRET= +COOKIE_SECRET= +COOKIE_NAME= RATE_LIMIT_MAX= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f095f1ce..40bae314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,11 +51,11 @@ jobs: - name: Lint Code run: npm run lint - - name: Generate JWT Secret + - name: Generate COOKIE Secret id: gen-jwt run: | - JWT_SECRET=$(openssl rand -hex 32) - echo "JWT_SECRET=$JWT_SECRET" >> $GITHUB_ENV + COOKIE_SECRET=$(openssl rand -hex 32) + echo "COOKIE_SECRET=$COOKIE_SECRET" >> $GITHUB_ENV - name: Generate dummy .env for scripts using -env-file=.env flag run: touch .env @@ -67,6 +67,6 @@ jobs: MYSQL_DATABASE: test_db MYSQL_USER: test_user MYSQL_PASSWORD: test_password - # JWT_SECRET is dynamically generated and loaded from the environment + # COOKIE_SECRET is dynamically generated and loaded from the environment RATE_LIMIT_MAX: 4 run: npm run db:migrate && npm run test diff --git a/package.json b/package.json index f80a75d2..8268088b 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,13 @@ "license": "MIT", "dependencies": { "@fastify/autoload": "^6.0.0", + "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.0", "@fastify/env": "^5.0.1", "@fastify/helmet": "^12.0.0", - "@fastify/jwt": "^9.0.0", "@fastify/rate-limit": "^10.0.1", "@fastify/sensible": "^6.0.1", + "@fastify/session": "^11.0.1", "@fastify/swagger": "^9.0.0", "@fastify/swagger-ui": "^5.0.1", "@fastify/type-provider-typebox": "^5.0.0", diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts index a3cd39ae..6aefd4c0 100644 --- a/src/plugins/custom/authorization.ts +++ b/src/plugins/custom/authorization.ts @@ -1,6 +1,5 @@ import fp from 'fastify-plugin' import { FastifyReply, FastifyRequest } from 'fastify' -import { Auth } from '../../schemas/auth.js' declare module 'fastify' { export interface FastifyInstance { @@ -9,12 +8,9 @@ declare module 'fastify' { } } -function verifyAccess ( - request: FastifyRequest, - reply: FastifyReply, - role: string -) { - if (!request.user || !(request.user as Auth).roles.includes(role)) { +function verifyAccess (request: FastifyRequest, reply: FastifyReply, role: string) { + const userRoles = request.session.user?.roles || [] + if (!userRoles.includes(role)) { reply.status(403).send('You are not authorized to access this resource.') } } diff --git a/src/plugins/external/cookie.ts b/src/plugins/external/cookie.ts new file mode 100644 index 00000000..f743845f --- /dev/null +++ b/src/plugins/external/cookie.ts @@ -0,0 +1,11 @@ +import fastifyCookie from '@fastify/cookie' +import fp from 'fastify-plugin' + +/** + * This plugins enables the use of cookies. + * + * @see {@link https://github.com/fastify/fastify-cookie} + */ +export default fp(fastifyCookie, { + name: 'cookies' +}) diff --git a/src/plugins/external/env.ts b/src/plugins/external/env.ts index 68a7f533..b27feb4c 100644 --- a/src/plugins/external/env.ts +++ b/src/plugins/external/env.ts @@ -9,7 +9,9 @@ declare module 'fastify' { MYSQL_USER: string; MYSQL_PASSWORD: string; MYSQL_DATABASE: string; - JWT_SECRET: string; + COOKIE_SECRET: string; + COOKIE_NAME: string; + COOKIE_SECURED: boolean; RATE_LIMIT_MAX: number; }; } @@ -23,7 +25,9 @@ const schema = { 'MYSQL_USER', 'MYSQL_PASSWORD', 'MYSQL_DATABASE', - 'JWT_SECRET' + 'COOKIE_SECRET', + 'COOKIE_NAME', + 'COOKIE_SECURED' ], properties: { // Database @@ -46,9 +50,16 @@ const schema = { }, // Security - JWT_SECRET: { + COOKIE_SECRET: { type: 'string' }, + COOKIE_NAME: { + type: 'string' + }, + COOKIE_SECURED: { + type: 'boolean', + default: true + }, RATE_LIMIT_MAX: { type: 'number', default: 100 diff --git a/src/plugins/external/jwt.ts b/src/plugins/external/jwt.ts deleted file mode 100644 index f4213ec6..00000000 --- a/src/plugins/external/jwt.ts +++ /dev/null @@ -1,10 +0,0 @@ -import fastifyJwt from '@fastify/jwt' -import { FastifyInstance } from 'fastify' - -export const autoConfig = (fastify: FastifyInstance) => { - return { - secret: fastify.config.JWT_SECRET - } -} - -export default fastifyJwt diff --git a/src/plugins/external/session.ts b/src/plugins/external/session.ts new file mode 100644 index 00000000..1eec68d9 --- /dev/null +++ b/src/plugins/external/session.ts @@ -0,0 +1,32 @@ +import fastifySession, { FastifySessionOptions } from '@fastify/session' +import { FastifyInstance } from 'fastify' +import fp from 'fastify-plugin' +import { Auth } from '../../schemas/auth.js' + +declare module 'fastify' { + interface Session { + user: Auth + } +} + +export const autoConfig = ( + fastify: FastifyInstance +): FastifySessionOptions => ({ + secret: fastify.config.COOKIE_SECRET, + cookieName: fastify.config.COOKIE_NAME, + cookie: { + secure: fastify.config.COOKIE_SECURED, + httpOnly: true, + maxAge: 1800000 // 30 minutes + } +}) + +/** + * This plugins enables the use of session. + * + * @see {@link https://github.com/fastify/session} + */ +export default fp(fastifySession, { + name: 'session', + dependencies: ['cookies'] +}) diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index 418c41d6..2aa3c5ae 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -9,7 +9,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { body: CredentialsSchema, response: { 200: Type.Object({ - token: Type.String() + success: Type.Boolean(), + message: Type.Optional(Type.String()) }), 401: Type.Object({ message: Type.String() @@ -35,12 +36,11 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { .join('users', 'user_roles.user_id', '=', 'users.id') .where('users.username', username) - const token = fastify.jwt.sign({ - username: user.username, - roles: roles.map((role) => role.name) - }) + request.session.user = { username, roles: roles.map(role => role.name) } + + await request.session.save() - return { token } + return { success: true } } } diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts index 82cbf408..3e661520 100644 --- a/src/routes/api/autohooks.ts +++ b/src/routes/api/autohooks.ts @@ -1,16 +1,15 @@ import { FastifyInstance } from 'fastify' -import { Auth } from '../../schemas/auth.js' - -declare module '@fastify/jwt' { - interface FastifyJWT { - user: Auth - } -} export default async function (fastify: FastifyInstance) { - fastify.addHook('onRequest', async (request) => { - if (!request.url.startsWith('/api/auth/login')) { - await request.jwtVerify() + fastify.addHook('onRequest', async (request, reply) => { + if (request.url.startsWith('/api/auth/login')) { + return + } + + if (!request.session.user) { + reply.status(401).send({ + message: 'You must be authenticated to access this route.' + }) } }) } diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index d897d911..6192c147 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -1,10 +1,10 @@ import { FastifyInstance } from 'fastify' export default async function (fastify: FastifyInstance) { - fastify.get('/', ({ user, protocol, hostname }) => { + fastify.get('/', ({ session, protocol, hostname }) => { return { message: - `Hello ${user.username}! See documentation at ${protocol}://${hostname}/documentation` + `Hello ${session.user.username}! See documentation at ${protocol}://${hostname}/documentation` } }) } diff --git a/test/helper.ts b/test/helper.ts index 9772dd8c..71a8df2b 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -7,7 +7,7 @@ import { options as serverOptions } from '../src/app.js' declare module 'fastify' { interface FastifyInstance { login: typeof login; - injectWithLogin: typeof injectWithLogin + injectWithLogin: typeof injectWithLogin; } } @@ -21,13 +21,7 @@ export function config () { } } -const tokens: Record = {} -// @See /scripts/seed-database.ts async function login (this: FastifyInstance, username: string) { - if (tokens[username]) { - return tokens[username] - } - const res = await this.inject({ method: 'POST', url: '/api/auth/login', @@ -37,19 +31,33 @@ async function login (this: FastifyInstance, username: string) { } }) - tokens[username] = JSON.parse(res.payload).token + const cookie = res.cookies.find( + (c) => c.name === this.config.COOKIE_NAME + ) + + if (!cookie) { + throw new Error('Failed to retrieve session cookie.') + } - return tokens[username] + return cookie.value } -async function injectWithLogin (this: FastifyInstance, username: string, opts: InjectOptions) { - opts.headers = { - ...opts.headers, - Authorization: `Bearer ${await this.login(username)}` +async function injectWithLogin ( + this: FastifyInstance, + username: string, + opts: InjectOptions +) { + const cookieValue = await this.login(username) + + opts.cookies = { + ...opts.cookies, + [this.config.COOKIE_NAME]: cookieValue } - return this.inject(opts) -}; + return this.inject({ + ...opts + }) +} // automatically build and tear down our instance export async function build (t: TestContext) { @@ -59,10 +67,14 @@ export async function build (t: TestContext) { // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await buildApplication(argv, config(), serverOptions) as FastifyInstance + const app = (await buildApplication( + argv, + config(), + serverOptions + )) as FastifyInstance + // This is after start, so we can't decorate the instance using `.decorate` app.login = login - app.injectWithLogin = injectWithLogin // close the app after we are done diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts index a4341d30..06a0e64c 100644 --- a/test/routes/api/api.test.ts +++ b/test/routes/api/api.test.ts @@ -2,35 +2,19 @@ import { test } from 'node:test' import assert from 'node:assert' import { build } from '../../helper.js' -test('GET /api without authorization header', async (t) => { - const app = await build(t) - - const res = await app.inject({ - url: '/api' - }) - - assert.deepStrictEqual(JSON.parse(res.payload), { - message: 'No Authorization was found in request.headers' - }) -}) - -test('GET /api without JWT Token', async (t) => { - const app = await build(t) +// test('GET /api with no login', async (t) => { +// const app = await build(t) - const res = await app.inject({ - method: 'GET', - url: '/api', - headers: { - Authorization: 'Bearer invalidtoken' - } - }) +// const res = await app.inject({ +// url: '/api' +// }) - assert.deepStrictEqual(JSON.parse(res.payload), { - message: 'Authorization token is invalid: The token is malformed.' - }) -}) +// assert.deepStrictEqual(JSON.parse(res.payload), { +// message: 'You must be authenticated to access this route.' +// }) +// }) -test('GET /api with JWT Token', async (t) => { +test('GET /api with cookie', async (t) => { const app = await build(t) const res = await app.injectWithLogin('basic', { diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index 2231c54d..fde06b26 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -15,7 +15,7 @@ test('POST /api/auth/login with valid credentials', async (t) => { }) assert.strictEqual(res.statusCode, 200) - assert.ok(JSON.parse(res.payload).token) + assert.ok(res.cookies.some(cookie => cookie.name === app.config.COOKIE_NAME)) }) test('POST /api/auth/login with invalid credentials', async (t) => { From 0f17f3ffe8728f71170a77cc8f2fc2dbafd319e9 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 13 Oct 2024 18:06:19 +0200 Subject: [PATCH 14/34] chore: ci - env must have required property 'COOKIE_NAME' --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40bae314..05eb7304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,6 @@ jobs: run: npm run lint - name: Generate COOKIE Secret - id: gen-jwt run: | COOKIE_SECRET=$(openssl rand -hex 32) echo "COOKIE_SECRET=$COOKIE_SECRET" >> $GITHUB_ENV @@ -68,5 +67,6 @@ jobs: MYSQL_USER: test_user MYSQL_PASSWORD: test_password # COOKIE_SECRET is dynamically generated and loaded from the environment + COOKIE_NAME: 'sessid' RATE_LIMIT_MAX: 4 run: npm run db:migrate && npm run test From e2f934bfe36a287b5aa772fa4696552c6d2a998d Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 13 Oct 2024 18:10:42 +0200 Subject: [PATCH 15/34] fix: uncomment unauthenticated test --- src/plugins/custom/authorization.ts | 3 +-- test/routes/api/api.test.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts index 6aefd4c0..7d5c67e5 100644 --- a/src/plugins/custom/authorization.ts +++ b/src/plugins/custom/authorization.ts @@ -9,8 +9,7 @@ declare module 'fastify' { } function verifyAccess (request: FastifyRequest, reply: FastifyReply, role: string) { - const userRoles = request.session.user?.roles || [] - if (!userRoles.includes(role)) { + if (!request.session.user.roles.includes(role)) { reply.status(403).send('You are not authorized to access this resource.') } } diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts index 06a0e64c..03b895ac 100644 --- a/test/routes/api/api.test.ts +++ b/test/routes/api/api.test.ts @@ -2,17 +2,17 @@ import { test } from 'node:test' import assert from 'node:assert' import { build } from '../../helper.js' -// test('GET /api with no login', async (t) => { -// const app = await build(t) +test('GET /api with no login', async (t) => { + const app = await build(t) -// const res = await app.inject({ -// url: '/api' -// }) + const res = await app.inject({ + url: '/api' + }) -// assert.deepStrictEqual(JSON.parse(res.payload), { -// message: 'You must be authenticated to access this route.' -// }) -// }) + assert.deepStrictEqual(JSON.parse(res.payload), { + message: 'You must be authenticated to access this route.' + }) +}) test('GET /api with cookie', async (t) => { const app = await build(t) From 667f132744a6edffcb5bcc297eb2b705220b0b99 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 13 Oct 2024 18:19:47 +0200 Subject: [PATCH 16/34] refactor: leverage fastify sensible decorators --- src/app.ts | 2 +- src/routes/api/autohooks.ts | 4 +--- src/routes/api/tasks/index.ts | 14 ++++---------- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/app.ts b/src/app.ts index b36f698e..b6f39673 100644 --- a/src/app.ts +++ b/src/app.ts @@ -61,7 +61,7 @@ export default async function serviceApp ( reply.code(err.statusCode ?? 500) let message = 'Internal Server Error' - if (err.statusCode === 401) { + if (err.statusCode && err.statusCode < 500) { message = err.message } diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts index 3e661520..dfd49fff 100644 --- a/src/routes/api/autohooks.ts +++ b/src/routes/api/autohooks.ts @@ -7,9 +7,7 @@ export default async function (fastify: FastifyInstance) { } if (!request.session.user) { - reply.status(401).send({ - message: 'You must be authenticated to access this route.' - }) + reply.unauthorized('You must be authenticated to access this route.') } }) } diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 5b0a5e61..ce60d363 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -1,6 +1,5 @@ import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' import { TaskSchema, Task, CreateTaskSchema, UpdateTaskSchema, TaskStatus } from '../../../schemas/tasks.js' -import { FastifyReply } from 'fastify' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -38,7 +37,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const task = await fastify.knex('tasks').where({ id }).first() if (!task) { - return notFound(reply) + return reply.notFound('Task not found') } return task @@ -89,7 +88,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { .update(request.body) if (affectedRows === 0) { - return notFound(reply) + return reply.notFound('Task not found') } const updatedTask = await fastify.knex('tasks').where({ id }).first() @@ -117,7 +116,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const affectedRows = await fastify.knex('tasks').where({ id }).delete() if (affectedRows === 0) { - return notFound(reply) + return reply.notFound('Task not found') } reply.code(204).send(null) @@ -148,7 +147,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const task = await fastify.knex('tasks').where({ id }).first() if (!task) { - return notFound(reply) + return reply.notFound('Task not found') } await fastify.knex('tasks') @@ -162,9 +161,4 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { ) } -function notFound (reply: FastifyReply) { - reply.code(404) - return { message: 'Task not found' } -} - export default plugin From 745875cf29ba89a2072ca910dd621814b99e6521 Mon Sep 17 00:00:00 2001 From: jean Date: Wed, 16 Oct 2024 20:18:23 +0200 Subject: [PATCH 17/34] chore: use tsx --- imports/ts-node.js | 3 --- package.json | 12 ++++++------ 2 files changed, 6 insertions(+), 9 deletions(-) delete mode 100644 imports/ts-node.js diff --git a/imports/ts-node.js b/imports/ts-node.js deleted file mode 100644 index 95737a43..00000000 --- a/imports/ts-node.js +++ /dev/null @@ -1,3 +0,0 @@ -import { register } from 'node:module' -import { pathToFileURL } from 'node:url' -register('ts-node/esm', pathToFileURL('./')) diff --git a/package.json b/package.json index 8268088b..0762cd46 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "standalone": "node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", - "db:create": "node --import ./imports/ts-node.js --env-file=.env ./scripts/create-database.ts", - "db:drop": "node --import ./imports/ts-node.js --env-file=.env ./scripts/drop-database.ts", - "db:migrate": "node --import ./imports/ts-node.js --env-file=.env ./scripts/migrate.ts", - "db:seed": "node --import ./imports/ts-node.js --env-file=.env ./scripts/seed-database.ts" + "db:create": "tsx --env-file=.env ./scripts/create-database.ts", + "db:drop": "tsx --env-file=.env ./scripts/drop-database.ts", + "db:migrate": "tsx --env-file=.env ./scripts/migrate.ts", + "db:seed": "tsx --env-file=.env ./scripts/seed-database.ts" }, "keywords": [], "author": "Michelet Jean ", @@ -45,8 +45,7 @@ "fastify-plugin": "^5.0.1", "knex": "^3.1.0", "mysql2": "^3.11.3", - "postgrator": "^7.3.0", - "ts-node": "^10.9.2" + "postgrator": "^7.3.0" }, "devDependencies": { "@types/node": "^22.5.5", @@ -54,6 +53,7 @@ "fastify-tsconfig": "^2.0.0", "neostandard": "^0.11.5", "tap": "^21.0.1", + "tsx": "^4.19.1", "typescript": "^5.6.2" } } From 99b362eb490f0c9c4447a937e7d07a59b19a2696 Mon Sep 17 00:00:00 2001 From: jean Date: Wed, 16 Oct 2024 21:49:03 +0200 Subject: [PATCH 18/34] feat: add pagination to tasks --- src/routes/api/tasks/index.ts | 46 ++++++++-- src/schemas/tasks.ts | 33 ++++++- test/helper.ts | 8 +- test/routes/api/tasks/tasks.test.ts | 129 +++++++++++++++++++++++----- 4 files changed, 181 insertions(+), 35 deletions(-) diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index ce60d363..f2ef8a91 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -1,20 +1,50 @@ import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' -import { TaskSchema, Task, CreateTaskSchema, UpdateTaskSchema, TaskStatus } from '../../../schemas/tasks.js' +import { TaskSchema, Task, CreateTaskSchema, UpdateTaskSchema, TaskStatusEnum, QueryTaskPaginationSchema, TaskPaginationResultSchema } from '../../../schemas/tasks.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( '/', { schema: { + querystring: QueryTaskPaginationSchema, response: { - 200: Type.Array(TaskSchema) + 200: TaskPaginationResultSchema }, tags: ['Tasks'] } }, - async function () { - const tasks = await fastify.knex('tasks').select('*') - return tasks + async function (request) { + const q = request.query + + const offset = (q.page - 1) * q.limit + + const baseQuery = fastify.knex('tasks') + + if (q.author_id !== undefined) { + baseQuery.where({ author_id: q.author_id }) + } + + if (q.assigned_user_id !== undefined) { + baseQuery.where({ assigned_user_id: q.assigned_user_id }) + } + + if (q.status !== undefined) { + baseQuery.where({ status: q.status }) + } + + const tasksQuery = baseQuery.clone() + .select('*') + .limit(q.limit).offset(offset).orderBy('created_at', q.order) + + const [tasks, [{ count }]] = await Promise.all([ + tasksQuery, + baseQuery.clone().count({ count: '*' }) + ]) + + return { + tasks, + total: Number(count) + } } ) @@ -58,10 +88,11 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function (request, reply) { - const newTask = { ...request.body, status: TaskStatus.New } + const newTask = { ...request.body, status: TaskStatusEnum.New } const [id] = await fastify.knex('tasks').insert(newTask) reply.code(201) + return { id } } ) @@ -91,8 +122,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { return reply.notFound('Task not found') } - const updatedTask = await fastify.knex('tasks').where({ id }).first() - return updatedTask + return fastify.knex('tasks').where({ id }).first() } ) diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts index 3315f8f3..0367cc4d 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,6 +1,6 @@ import { Static, Type } from '@sinclair/typebox' -export const TaskStatus = { +export const TaskStatusEnum = { New: 'new', InProgress: 'in-progress', OnHold: 'on-hold', @@ -9,14 +9,23 @@ export const TaskStatus = { Archived: 'archived' } as const -export type TaskStatusType = typeof TaskStatus[keyof typeof TaskStatus] +export type TaskStatusType = typeof TaskStatusEnum[keyof typeof TaskStatusEnum] + +const TaskStatusSchema = Type.Union([ + Type.Literal('new'), + Type.Literal('in-progress'), + Type.Literal('on-hold'), + Type.Literal('completed'), + Type.Literal('canceled'), + Type.Literal('archived') +]) export const TaskSchema = Type.Object({ id: Type.Number(), name: Type.String(), author_id: Type.Number(), assigned_user_id: Type.Optional(Type.Number()), - status: Type.String(), + status: TaskStatusSchema, created_at: Type.String({ format: 'date-time' }), updated_at: Type.String({ format: 'date-time' }) }) @@ -33,3 +42,21 @@ export const UpdateTaskSchema = Type.Object({ name: Type.Optional(Type.String()), assigned_user_id: Type.Optional(Type.Number()) }) + +export const QueryTaskPaginationSchema = Type.Object({ + page: Type.Number({ minimum: 1, default: 1 }), + limit: Type.Number({ minimum: 1, maximum: 100, default: 10 }), + + author_id: Type.Optional(Type.Number()), + assigned_user_id: Type.Optional(Type.Number()), + status: Type.Optional(TaskStatusSchema), + order: Type.Optional(Type.Union([ + Type.Literal('asc'), + Type.Literal('desc') + ], { default: 'desc' })) +}) + +export const TaskPaginationResultSchema = Type.Object({ + total: Type.Number({ minimum: 0, default: 0 }), + tasks: Type.Array(TaskSchema) +}) diff --git a/test/helper.ts b/test/helper.ts index 71a8df2b..3160137d 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -60,7 +60,7 @@ async function injectWithLogin ( } // automatically build and tear down our instance -export async function build (t: TestContext) { +export async function build (t?: TestContext) { // you can set all the options supported by the fastify CLI command const argv = [AppPath] @@ -77,8 +77,10 @@ export async function build (t: TestContext) { app.login = login app.injectWithLogin = injectWithLogin - // close the app after we are done - t.after(() => app.close()) + // If we pass the test contest, it will close the app after we are done + if (t) { + t.after(() => app.close()) + } return app } diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 351aef48..9959d6ff 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,8 +1,14 @@ -import { describe, it } from 'node:test' +import { before, describe, it } from 'node:test' import assert from 'node:assert' import { build } from '../../../helper.js' -import { Task, TaskStatus } from '../../../../src/schemas/tasks.js' +import { Task, TaskStatusEnum, TaskPaginationResultSchema } from '../../../../src/schemas/tasks.js' import { FastifyInstance } from 'fastify' +import { Static } from '@sinclair/typebox' + +async function createUser (app: FastifyInstance, userData: Partial<{ username: string; password: string }>) { + const [id] = await app.knex('users').insert(userData) + return id +} async function createTask (app: FastifyInstance, taskData: Partial) { const [id] = await app.knex('tasks').insert(taskData) @@ -12,16 +18,28 @@ async function createTask (app: FastifyInstance, taskData: Partial) { describe('Tasks api (logged user only)', () => { describe('GET /api/tasks', () => { - it('should return a list of tasks', async (t) => { - const app = await build(t) + let app: FastifyInstance + let userId1: number + let userId2: number - const taskData = { - name: 'New Task', - author_id: 1, - status: TaskStatus.New - } + let firstTaskId: number - const newTaskId = await createTask(app, taskData) + before(async () => { + app = await build() + + userId1 = await createUser(app, { username: 'user1', password: 'password1' }) + userId2 = await createUser(app, { username: 'user2', password: 'password2' }) + + firstTaskId = await createTask(app, { name: 'Task 1', author_id: userId1, status: TaskStatusEnum.New }) + await createTask(app, { name: 'Task 2', author_id: userId1, assigned_user_id: userId2, status: TaskStatusEnum.InProgress }) + await createTask(app, { name: 'Task 3', author_id: userId2, status: TaskStatusEnum.Completed }) + await createTask(app, { name: 'Task 4', author_id: userId1, assigned_user_id: userId1, status: TaskStatusEnum.OnHold }) + + app.close() + }) + + it('should return a list of tasks with no pagination filter', async (t) => { + app = await build(t) const res = await app.injectWithLogin('basic', { method: 'GET', @@ -29,13 +47,82 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const tasks = JSON.parse(res.payload) as Task[] - const createdTask = tasks.find((task) => task.id === newTaskId) - assert.ok(createdTask, 'Created task should be in the response') + const { tasks, total } = JSON.parse(res.payload) as Static + const firstTask = tasks.find((task) => task.id === firstTaskId) + + assert.ok(firstTask, 'Created task should be in the response') + assert.deepStrictEqual(firstTask.name, 'Task 1') + assert.strictEqual(firstTask.author_id, userId1) + assert.strictEqual(firstTask.status, TaskStatusEnum.New) + + assert.strictEqual(total, 4) + }) + + it('should paginate by page and limit', async (t) => { + app = await build(t) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { page: '2', limit: '1' } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static + + assert.strictEqual(total, 4) + assert.strictEqual(tasks.length, 1) + assert.strictEqual(tasks[0].name, 'Task 2') + assert.strictEqual(tasks[0].author_id, userId1) + assert.strictEqual(tasks[0].status, TaskStatusEnum.InProgress) + }) + + it('should filter tasks by assigned_user_id', async (t) => { + app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { assigned_user_id: userId2.toString() } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static + + assert.strictEqual(total, 1) + tasks.forEach(task => assert.strictEqual(task.assigned_user_id, userId2)) + }) + + it('should filter tasks by status', async (t) => { + app = await build(t) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { status: TaskStatusEnum.Completed } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static + + assert.strictEqual(total, 1) + tasks.forEach(task => assert.strictEqual(task.status, TaskStatusEnum.Completed)) + }) + + it('should paginate and filter tasks by author_id and status', async (t) => { + app = await build(t) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { author_id: userId1.toString(), status: TaskStatusEnum.OnHold, page: '1', limit: '1' } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static - assert.deepStrictEqual(taskData.name, createdTask?.name) - assert.strictEqual(taskData.author_id, createdTask?.author_id) - assert.strictEqual(taskData.status, createdTask?.status) + assert.strictEqual(total, 1) + assert.strictEqual(tasks.length, 1) + assert.strictEqual(tasks[0].name, 'Task 4') + assert.strictEqual(tasks[0].author_id, userId1) + assert.strictEqual(tasks[0].status, TaskStatusEnum.OnHold) }) }) @@ -46,7 +133,7 @@ describe('Tasks api (logged user only)', () => { const taskData = { name: 'Single Task', author_id: 1, - status: TaskStatus.New + status: TaskStatusEnum.New } const newTaskId = await createTask(app, taskData) @@ -105,7 +192,7 @@ describe('Tasks api (logged user only)', () => { const taskData = { name: 'Task to Update', author_id: 1, - status: TaskStatus.New + status: TaskStatusEnum.New } const newTaskId = await createTask(app, taskData) @@ -147,7 +234,7 @@ describe('Tasks api (logged user only)', () => { const taskData = { name: 'Task to Delete', author_id: 1, - status: TaskStatus.New + status: TaskStatusEnum.New } it('should delete an existing task', async (t) => { @@ -187,7 +274,7 @@ describe('Tasks api (logged user only)', () => { const taskData = { name: 'Task to Assign', author_id: 1, - status: TaskStatus.New + status: TaskStatusEnum.New } const newTaskId = await createTask(app, taskData) @@ -214,7 +301,7 @@ describe('Tasks api (logged user only)', () => { name: 'Task to Unassign', author_id: 1, assigned_user_id: 2, - status: TaskStatus.New + status: TaskStatusEnum.New } const newTaskId = await createTask(app, taskData) From da0fb0da7a710e079d0a86a2ae3eeeb5d90f919e Mon Sep 17 00:00:00 2001 From: jean Date: Fri, 18 Oct 2024 10:17:03 +0200 Subject: [PATCH 19/34] refactor: use COUNT(*) OVER() AS rowNum for tasks pagination --- src/routes/api/tasks/index.ts | 51 +++++++++++++++++++---------- test/routes/api/tasks/tasks.test.ts | 17 ++++++++++ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index f2ef8a91..3d5f7e31 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -1,5 +1,16 @@ -import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' -import { TaskSchema, Task, CreateTaskSchema, UpdateTaskSchema, TaskStatusEnum, QueryTaskPaginationSchema, TaskPaginationResultSchema } from '../../../schemas/tasks.js' +import { + FastifyPluginAsyncTypebox, + Type +} from '@fastify/type-provider-typebox' +import { + TaskSchema, + Task, + CreateTaskSchema, + UpdateTaskSchema, + TaskStatusEnum, + QueryTaskPaginationSchema, + TaskPaginationResultSchema +} from '../../../schemas/tasks.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -18,32 +29,31 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const offset = (q.page - 1) * q.limit - const baseQuery = fastify.knex('tasks') + const query = fastify + .knex('tasks') + .select('*') + .select(fastify.knex.raw('count(*) OVER() as total')) if (q.author_id !== undefined) { - baseQuery.where({ author_id: q.author_id }) + query.where({ author_id: q.author_id }) } if (q.assigned_user_id !== undefined) { - baseQuery.where({ assigned_user_id: q.assigned_user_id }) + query.where({ assigned_user_id: q.assigned_user_id }) } if (q.status !== undefined) { - baseQuery.where({ status: q.status }) + query.where({ status: q.status }) } - const tasksQuery = baseQuery.clone() - .select('*') - .limit(q.limit).offset(offset).orderBy('created_at', q.order) - - const [tasks, [{ count }]] = await Promise.all([ - tasksQuery, - baseQuery.clone().count({ count: '*' }) - ]) + const tasks = await query + .limit(q.limit) + .offset(offset) + .orderBy('created_at', q.order) return { tasks, - total: Number(count) + total: tasks.length > 0 ? Number(tasks[0].total) : 0 } } ) @@ -114,7 +124,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, async function (request, reply) { const { id } = request.params - const affectedRows = await fastify.knex('tasks') + const affectedRows = await fastify + .knex('tasks') .where({ id }) .update(request.body) @@ -143,7 +154,10 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, async function (request, reply) { const { id } = request.params - const affectedRows = await fastify.knex('tasks').where({ id }).delete() + const affectedRows = await fastify + .knex('tasks') + .where({ id }) + .delete() if (affectedRows === 0) { return reply.notFound('Task not found') @@ -180,7 +194,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { return reply.notFound('Task not found') } - await fastify.knex('tasks') + await fastify + .knex('tasks') .where({ id }) .update({ assigned_user_id: userId ?? null }) diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 9959d6ff..b5144e03 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -124,6 +124,23 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(tasks[0].author_id, userId1) assert.strictEqual(tasks[0].status, TaskStatusEnum.OnHold) }) + + it('should return empty array and total = 0 if no tasks', async (t) => { + app = await build(t) + + await app.knex('tasks').delete() + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks' + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static + + assert.strictEqual(total, 0) + assert.strictEqual(tasks.length, 0) + }) }) describe('GET /api/tasks/:id', () => { From 82928599a53952bd5ebb4ff36d2caa14fd311adf Mon Sep 17 00:00:00 2001 From: jean Date: Fri, 18 Oct 2024 10:52:24 +0200 Subject: [PATCH 20/34] refactor: decorate request for authorization --- src/plugins/custom/authorization.ts | 20 +++++++++++--------- src/routes/api/tasks/index.ts | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts index 7d5c67e5..6afff0c7 100644 --- a/src/plugins/custom/authorization.ts +++ b/src/plugins/custom/authorization.ts @@ -2,24 +2,25 @@ import fp from 'fastify-plugin' import { FastifyReply, FastifyRequest } from 'fastify' declare module 'fastify' { - export interface FastifyInstance { + export interface FastifyRequest { + verifyAccess: typeof verifyAccess; isModerator: typeof isModerator; isAdmin: typeof isAdmin; } } -function verifyAccess (request: FastifyRequest, reply: FastifyReply, role: string) { - if (!request.session.user.roles.includes(role)) { +function verifyAccess (this: FastifyRequest, reply: FastifyReply, role: string) { + if (!this.session.user.roles.includes(role)) { reply.status(403).send('You are not authorized to access this resource.') } } -async function isModerator (request: FastifyRequest, reply: FastifyReply) { - verifyAccess(request, reply, 'moderator') +async function isModerator (this: FastifyRequest, reply: FastifyReply) { + this.verifyAccess(reply, 'moderator') } -async function isAdmin (request: FastifyRequest, reply: FastifyReply) { - verifyAccess(request, reply, 'admin') +async function isAdmin (this: FastifyRequest, reply: FastifyReply) { + this.verifyAccess(reply, 'admin') } /** @@ -30,8 +31,9 @@ async function isAdmin (request: FastifyRequest, reply: FastifyReply) { */ export default fp( async function (fastify) { - fastify.decorate('isModerator', isModerator) - fastify.decorate('isAdmin', isAdmin) + fastify.decorateRequest('verifyAccess', verifyAccess) + fastify.decorateRequest('isModerator', isModerator) + fastify.decorateRequest('isAdmin', isAdmin) }, // You should name your plugins if you want to avoid name collisions // and/or to perform dependency checks. diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 3d5f7e31..90e7c5a7 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -150,7 +150,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, tags: ['Tasks'] }, - preHandler: fastify.isAdmin + preHandler: (request, reply) => request.isAdmin(reply) }, async function (request, reply) { const { id } = request.params @@ -183,7 +183,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, tags: ['Tasks'] }, - preHandler: fastify.isModerator + preHandler: (request, reply) => request.isModerator(reply) }, async function (request, reply) { const { id } = request.params From d4c335ef6d04e7b353a84cdace73d3511b43d660 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 11:04:42 +0200 Subject: [PATCH 21/34] fix: use transaction for login controller --- package.json | 6 ++-- src/plugins/external/knex.ts | 10 ++---- src/routes/api/auth/index.ts | 51 +++++++++++++++++++------------ test/routes/api/auth/auth.test.ts | 26 +++++++++++++++- tsconfig.json | 4 +-- 5 files changed, 65 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 0762cd46..ed105961 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ }, "scripts": { "start": "npm run build && fastify start -l info dist/app.js", - "build": "tsc", + "build": "rm -rf ./dist && tsc", "watch": "tsc -w", "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", - "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", + "dev:start": "npm run build && fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", "test": "npm run db:seed && tap --jobs=1 test/**/*", - "standalone": "node --env-file=.env dist/server.js", + "standalone": "npm run build && node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", "db:create": "tsx --env-file=.env ./scripts/create-database.ts", diff --git a/src/plugins/external/knex.ts b/src/plugins/external/knex.ts index 9dd1fe58..3ab3533d 100644 --- a/src/plugins/external/knex.ts +++ b/src/plugins/external/knex.ts @@ -22,14 +22,10 @@ export const autoConfig = (fastify: FastifyInstance) => { } } -const knexPlugin = async (fastify: FastifyInstance) => { - const db = knex(autoConfig(fastify)) - - fastify.decorate('knex', db) +export default fp(async (fastify: FastifyInstance, opts) => { + fastify.decorate('knex', knex(opts)) fastify.addHook('onClose', async (instance) => { await instance.knex.destroy() }) -} - -export default fp(knexPlugin, { name: 'knex' }) +}, { name: 'knex' }) diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index 2aa3c5ae..74b4c1ee 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -1,4 +1,7 @@ -import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' +import { + FastifyPluginAsyncTypebox, + Type +} from '@fastify/type-provider-typebox' import { CredentialsSchema, Credentials } from '../../../schemas/auth.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { @@ -22,30 +25,40 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { async function (request, reply) { const { username, password } = request.body - const user = await fastify.knex('users') - .select('username', 'password') - .where({ username }) - .first() + return fastify.knex.transaction(async (trx) => { + const user = await trx('users') + .select('username', 'password') + .where({ username }) + .first() - if (user) { - const isPasswordValid = await fastify.compare(password, user.password) - if (isPasswordValid) { - const roles = await fastify.knex<{ name: string }>('roles') - .select('roles.name') - .join('user_roles', 'roles.id', '=', 'user_roles.role_id') - .join('users', 'user_roles.user_id', '=', 'users.id') - .where('users.username', username) + if (user) { + const isPasswordValid = await fastify.compare( + password, + user.password + ) + if (isPasswordValid) { + const roles = await trx<{ name: string }>('roles') + .select('roles.name') + .join('user_roles', 'roles.id', '=', 'user_roles.role_id') + .join('users', 'user_roles.user_id', '=', 'users.id') + .where('users.username', username) - request.session.user = { username, roles: roles.map(role => role.name) } + request.session.user = { + username, + roles: roles.map((role) => role.name) + } - await request.session.save() + await request.session.save() - return { success: true } + return { success: true } + } } - } - reply.status(401) - return { message: 'Invalid username or password.' } + reply.status(401) + return { message: 'Invalid username or password.' } + }).catch(() => { + reply.internalServerError('Transaction failed.') + }) } ) } diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index fde06b26..005af1f0 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -2,6 +2,28 @@ import { test } from 'node:test' import assert from 'node:assert' import { build } from '../../../helper.js' +test('Transaction should rollback on error', async (t) => { + const app = await build(t) + + app.compare = (value: string, hash: string) => { + throw new Error() + } + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + username: 'basic', + password: 'password123$' + } + }) + + assert.strictEqual(res.statusCode, 500) + assert.deepStrictEqual(JSON.parse(res.body), { + message: 'Internal Server Error' + }) +}) + test('POST /api/auth/login with valid credentials', async (t) => { const app = await build(t) @@ -15,7 +37,9 @@ test('POST /api/auth/login with valid credentials', async (t) => { }) assert.strictEqual(res.statusCode, 200) - assert.ok(res.cookies.some(cookie => cookie.name === app.config.COOKIE_NAME)) + assert.ok( + res.cookies.some((cookie) => cookie.name === app.config.COOKIE_NAME) + ) }) test('POST /api/auth/login with invalid credentials', async (t) => { diff --git a/tsconfig.json b/tsconfig.json index 1da6b460..1cd580c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "fastify-tsconfig", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", }, - "include": ["@types", "src/**/*.ts", "scripts/**/*.ts"] + "include": ["@types", "src/**/*.ts"] } From 624096011af4938155d94287314d759251fc687a Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 11:08:21 +0200 Subject: [PATCH 22/34] refactor: register cookie plugin in session plugin --- src/plugins/external/cookie.ts | 11 ----------- src/plugins/external/session.ts | 32 +++++++++++++++----------------- 2 files changed, 15 insertions(+), 28 deletions(-) delete mode 100644 src/plugins/external/cookie.ts diff --git a/src/plugins/external/cookie.ts b/src/plugins/external/cookie.ts deleted file mode 100644 index f743845f..00000000 --- a/src/plugins/external/cookie.ts +++ /dev/null @@ -1,11 +0,0 @@ -import fastifyCookie from '@fastify/cookie' -import fp from 'fastify-plugin' - -/** - * This plugins enables the use of cookies. - * - * @see {@link https://github.com/fastify/fastify-cookie} - */ -export default fp(fastifyCookie, { - name: 'cookies' -}) diff --git a/src/plugins/external/session.ts b/src/plugins/external/session.ts index 1eec68d9..d435ed0b 100644 --- a/src/plugins/external/session.ts +++ b/src/plugins/external/session.ts @@ -1,7 +1,7 @@ -import fastifySession, { FastifySessionOptions } from '@fastify/session' -import { FastifyInstance } from 'fastify' +import fastifySession from '@fastify/session' import fp from 'fastify-plugin' import { Auth } from '../../schemas/auth.js' +import fastifyCookie from '@fastify/cookie' declare module 'fastify' { interface Session { @@ -9,24 +9,22 @@ declare module 'fastify' { } } -export const autoConfig = ( - fastify: FastifyInstance -): FastifySessionOptions => ({ - secret: fastify.config.COOKIE_SECRET, - cookieName: fastify.config.COOKIE_NAME, - cookie: { - secure: fastify.config.COOKIE_SECURED, - httpOnly: true, - maxAge: 1800000 // 30 minutes - } -}) - /** * This plugins enables the use of session. * * @see {@link https://github.com/fastify/session} */ -export default fp(fastifySession, { - name: 'session', - dependencies: ['cookies'] +export default fp(async (fastify) => { + fastify.register(fastifyCookie) + fastify.register(fastifySession, { + secret: fastify.config.COOKIE_SECRET, + cookieName: fastify.config.COOKIE_NAME, + cookie: { + secure: fastify.config.COOKIE_SECURED, + httpOnly: true, + maxAge: 1800000 + } + }) +}, { + name: 'session' }) From ebf639f630a338ba16435e3ae92ab9e92976dc35 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 11:38:22 +0200 Subject: [PATCH 23/34] test: mock app.compare implementation instead of reassignation --- src/app.ts | 2 +- test/routes/api/auth/auth.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index b6f39673..2100feb3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -45,7 +45,7 @@ export default async function serviceApp ( }) fastify.setErrorHandler((err, request, reply) => { - request.log.error( + fastify.log.error( { err, request: { diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index 005af1f0..a0de3999 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -5,9 +5,10 @@ import { build } from '../../../helper.js' test('Transaction should rollback on error', async (t) => { const app = await build(t) - app.compare = (value: string, hash: string) => { + const { mock } = t.mock.method(app, 'compare') + mock.mockImplementationOnce((value: string, hash: string) => { throw new Error() - } + }) const res = await app.inject({ method: 'POST', From 8723ee3e0e2910630b4a95537b1b4401e532fe94 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 11:47:37 +0200 Subject: [PATCH 24/34] test: spy logger to ensure 500 error is due to Transaction failure --- test/routes/api/auth/auth.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index a0de3999..c5c71d1b 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -5,11 +5,13 @@ import { build } from '../../../helper.js' test('Transaction should rollback on error', async (t) => { const app = await build(t) - const { mock } = t.mock.method(app, 'compare') - mock.mockImplementationOnce((value: string, hash: string) => { + const { mock: mockCompare } = t.mock.method(app, 'compare') + mockCompare.mockImplementationOnce((value: string, hash: string) => { throw new Error() }) + const { mock: mockHello } = t.mock.method(app.log, 'error') + const res = await app.inject({ method: 'POST', url: '/api/auth/login', @@ -19,10 +21,14 @@ test('Transaction should rollback on error', async (t) => { } }) + assert.strictEqual(mockCompare.callCount(), 1) + + const arg = mockHello.calls[0].arguments[0] as unknown as { + err: Error + } + assert.strictEqual(res.statusCode, 500) - assert.deepStrictEqual(JSON.parse(res.body), { - message: 'Internal Server Error' - }) + assert.deepStrictEqual(arg.err.message, 'Transaction failed.') }) test('POST /api/auth/login with valid credentials', async (t) => { From 59d083ee08687dfc5b679767bf82249ac9ba477b Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 20:37:09 +0200 Subject: [PATCH 25/34] feat: allow to upload task image --- migrations/002.do.tasks.sql | 1 + package.json | 3 + src/plugins/external/env.ts | 9 +- src/plugins/external/multipart.ts | 14 ++ src/plugins/external/static.ts | 10 + src/routes/api/auth/index.ts | 1 + src/routes/api/tasks/index.ts | 99 +++++++++ src/schemas/tasks.ts | 4 +- test/app/error-handler.test.ts | 2 +- test/app/not-found-handler.test.ts | 4 +- test/routes/api/tasks/fixtures/one_line.csv | 2 + test/routes/api/tasks/fixtures/short-logo.png | Bin 0 -> 20109 bytes test/routes/api/tasks/tasks.test.ts | 197 +++++++++++++++++- uploads/tasks/.gitkeep | 0 14 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 src/plugins/external/multipart.ts create mode 100644 src/plugins/external/static.ts create mode 100644 test/routes/api/tasks/fixtures/one_line.csv create mode 100644 test/routes/api/tasks/fixtures/short-logo.png create mode 100644 uploads/tasks/.gitkeep diff --git a/migrations/002.do.tasks.sql b/migrations/002.do.tasks.sql index 9c08a4e2..8dd7521d 100644 --- a/migrations/002.do.tasks.sql +++ b/migrations/002.do.tasks.sql @@ -3,6 +3,7 @@ CREATE TABLE tasks ( name VARCHAR(255) NOT NULL, author_id INT NOT NULL, assigned_user_id INT, + filename VARCHAR(255), status VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/package.json b/package.json index ca0f019e..3c3b1d94 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,11 @@ "@fastify/cors": "^10.0.0", "@fastify/env": "^5.0.1", "@fastify/helmet": "^12.0.0", + "@fastify/multipart": "^9.0.1", "@fastify/rate-limit": "^10.0.1", "@fastify/sensible": "^6.0.1", "@fastify/session": "^11.0.1", + "@fastify/static": "^8.0.2", "@fastify/swagger": "^9.0.0", "@fastify/swagger-ui": "^5.0.1", "@fastify/type-provider-typebox": "^5.0.0", @@ -43,6 +45,7 @@ "fastify": "^5.0.0", "fastify-cli": "^7.0.0", "fastify-plugin": "^5.0.1", + "form-data": "^4.0.1", "knex": "^3.1.0", "mysql2": "^3.11.3", "postgrator": "^7.3.0" diff --git a/src/plugins/external/env.ts b/src/plugins/external/env.ts index b27feb4c..3a610d94 100644 --- a/src/plugins/external/env.ts +++ b/src/plugins/external/env.ts @@ -13,6 +13,7 @@ declare module 'fastify' { COOKIE_NAME: string; COOKIE_SECURED: boolean; RATE_LIMIT_MAX: number; + UPLOAD_DIRNAME: string; }; } } @@ -62,7 +63,13 @@ const schema = { }, RATE_LIMIT_MAX: { type: 'number', - default: 100 + default: 100 // Put it to 4 in your .env file for tests + }, + + // Files + UPLOAD_DIRNAME: { + type: 'string', + default: 'uploads' } } } diff --git a/src/plugins/external/multipart.ts b/src/plugins/external/multipart.ts new file mode 100644 index 00000000..d3865c0e --- /dev/null +++ b/src/plugins/external/multipart.ts @@ -0,0 +1,14 @@ +import fastifyMultipart from '@fastify/multipart' + +export const autoConfig = { + limits: { + fieldNameSize: 100, // Max field name size in bytes + fieldSize: 100, // Max field value size in bytes + fields: 10, // Max number of non-file fields + fileSize: 1 * 1024 * 1024, // Max file size in bytes (5 MB) + files: 1, // Max number of file fields + parts: 1000 // Max number of parts + } +} + +export default fastifyMultipart diff --git a/src/plugins/external/static.ts b/src/plugins/external/static.ts new file mode 100644 index 00000000..9cd26e84 --- /dev/null +++ b/src/plugins/external/static.ts @@ -0,0 +1,10 @@ +import fastifyStatic, { FastifyStaticOptions } from '@fastify/static' +import { FastifyInstance } from 'fastify' +import path from 'path' + +export const autoConfig = (fastify: FastifyInstance): FastifyStaticOptions => ({ + root: path.join(import.meta.dirname, '../../..'), + prefix: `/${fastify.config.UPLOAD_DIRNAME}` +}) + +export default fastifyStatic diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index 74b4c1ee..5225cff7 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -55,6 +55,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } reply.status(401) + return { message: 'Invalid username or password.' } }).catch(() => { reply.internalServerError('Transaction failed.') diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 90e7c5a7..6d095423 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -11,6 +11,11 @@ import { QueryTaskPaginationSchema, TaskPaginationResultSchema } from '../../../schemas/tasks.js' +import path from 'node:path' +import { pipeline } from 'node:stream/promises' +import fs from 'node:fs' + +const TASKS_DIRNAME = 'tasks' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -204,6 +209,100 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { return task } ) + + fastify.post( + '/:id/upload', + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + consumes: ['multipart/form-data'], + response: { + 200: Type.Object({ + path: Type.String(), + message: Type.String() + }), + 404: Type.Object({ message: Type.String() }), + 400: Type.Object({ message: Type.String() }) + }, + tags: ['Tasks'] + } + }, + async function (request, reply) { + const { id } = request.params + + return fastify.knex.transaction(async (trx) => { + const task = await trx('tasks').where({ id }).first() + if (!task) { + return reply.notFound('Task not found') + } + + const file = await request.file() + + if (!file) { + return reply.notFound('File not found') + } + + const allowedMimeTypes = ['image/jpeg', 'image/png'] + if (!allowedMimeTypes.includes(file.mimetype)) { + return reply.badRequest('Invalid file type') + } + + if (file.file.truncated) { + return reply.badRequest('File size limit exceeded') + } + + const filename = `${id}_${file.filename}` + const filePath = path.join( + import.meta.dirname, + '../../../..', + fastify.config.UPLOAD_DIRNAME, + TASKS_DIRNAME, + filename + ) + + await pipeline(file.file, fs.createWriteStream(filePath)) + + await trx('tasks') + .where({ id }) + .update({ filename }) + + return { path: filePath, message: 'File uploaded successfully' } + }).catch(() => { + reply.internalServerError('Transaction failed.') + }) + } + ) + + fastify.get( + '/:filename/image', + { + schema: { + params: Type.Object({ + filename: Type.String() + }), + response: { + 200: { type: 'string', contentMediaType: 'image/*' }, + 404: Type.Object({ message: Type.String() }) + }, + tags: ['Tasks'] + } + }, + async function (request, reply) { + const { filename } = request.params + + const task = await fastify.knex('tasks').select('filename').where({ filename }).first() + if (!task) { + return reply.notFound(`No task has filename "${filename}"`) + } + + return reply.sendFile( + task.filename as string, + path.join(fastify.config.UPLOAD_DIRNAME, TASKS_DIRNAME) + ) + } + ) } export default plugin diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts index 0367cc4d..ccb522dd 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -30,7 +30,9 @@ export const TaskSchema = Type.Object({ updated_at: Type.String({ format: 'date-time' }) }) -export interface Task extends Static {} +export interface Task extends Static { + filename?: string +} export const CreateTaskSchema = Type.Object({ name: Type.String(), diff --git a/test/app/error-handler.test.ts b/test/app/error-handler.test.ts index df3c9c95..76113d87 100644 --- a/test/app/error-handler.test.ts +++ b/test/app/error-handler.test.ts @@ -1,7 +1,7 @@ import { it } from 'node:test' import assert from 'node:assert' import fastify from 'fastify' -import serviceApp from '../../src/app.ts' +import serviceApp from '../../src/app.js' import fp from 'fastify-plugin' it('should call errorHandler', async (t) => { diff --git a/test/app/not-found-handler.test.ts b/test/app/not-found-handler.test.ts index 497c5f5c..2805583f 100644 --- a/test/app/not-found-handler.test.ts +++ b/test/app/not-found-handler.test.ts @@ -23,7 +23,7 @@ it('should be rate limited', async (t) => { url: '/this-route-does-not-exist' }) - assert.strictEqual(res.statusCode, 404) + assert.strictEqual(res.statusCode, 404, `Iteration ${i}`) } const res = await app.inject({ @@ -31,5 +31,5 @@ it('should be rate limited', async (t) => { url: '/this-route-does-not-exist' }) - assert.strictEqual(res.statusCode, 429) + assert.strictEqual(res.statusCode, 429, 'Expected 429') }) diff --git a/test/routes/api/tasks/fixtures/one_line.csv b/test/routes/api/tasks/fixtures/one_line.csv new file mode 100644 index 00000000..0b6fa709 --- /dev/null +++ b/test/routes/api/tasks/fixtures/one_line.csv @@ -0,0 +1,2 @@ +Line +This is a very small CSV with one line. diff --git a/test/routes/api/tasks/fixtures/short-logo.png b/test/routes/api/tasks/fixtures/short-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8041e33dd617054d1f796032f58c364fec3732cf GIT binary patch literal 20109 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 a=l!lP;&eQ*_$(*@qN%QPHusFh-Tw>VhJ3#O literal 0 HcmV?d00001 diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index b5144e03..f75d5c4b 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,9 +1,13 @@ -import { before, describe, it } from 'node:test' +import { after, before, describe, it } from 'node:test' import assert from 'node:assert' import { build } from '../../../helper.js' import { Task, TaskStatusEnum, TaskPaginationResultSchema } from '../../../../src/schemas/tasks.js' import { FastifyInstance } from 'fastify' import { Static } from '@sinclair/typebox' +import fs from 'node:fs' +import path from 'node:path' +import FormData from 'form-data' +import os from 'os' async function createUser (app: FastifyInstance, userData: Partial<{ username: string; password: string }>) { const [id] = await app.knex('users').insert(userData) @@ -363,4 +367,195 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(payload.message, 'Task not found') }) }) + + describe('Task image upload and retrieval', () => { + let app: FastifyInstance + let taskId: number + const filename = 'short-logo.png' + const fixturesDir = path.join(import.meta.dirname, './fixtures') + const testImagePath = path.join(fixturesDir, filename) + const testCsvPath = path.join(fixturesDir, 'one_line.csv') + let uploadedImagePath: string + + before(async () => { + app = await build() + + // Ensure the directory exists + if (!fs.existsSync(fixturesDir)) { + fs.mkdirSync(fixturesDir, { recursive: true }) + } + + // Create a sample task to associate with image uploads + taskId = await createTask(app, { name: 'Task with image', author_id: 1, status: TaskStatusEnum.New }) + + app.close() + }) + + after(() => { + fs.unlinkSync(uploadedImagePath) + }) + + it('should upload a valid image for a task', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 200) + + const { path: uploadedImagePath_, message } = JSON.parse(res.payload) + assert.strictEqual(message, 'File uploaded successfully') + + uploadedImagePath = uploadedImagePath_ + assert.ok(fs.existsSync(uploadedImagePath_)) + }) + + it('should return 404 if task not found', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: '/api/tasks/100000/upload', + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 404) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'Task not found') + }) + + it('should return 404 if file not found', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: undefined, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 404) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'File not found') + }) + + it('should reject an invalid file type', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testCsvPath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 400) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'Invalid file type') + }) + + it('should reject if file size exceeds limit (truncated)', async (t) => { + app = await build(t) + + const tmpDir = os.tmpdir() + const largeTestImagePath = path.join(tmpDir, 'large-test-image.jpg') + + const largeBuffer = Buffer.alloc(1024 * 1024 * 1.5, 'a') // Max file size in bytes is 1 MB + fs.writeFileSync(largeTestImagePath, largeBuffer) + + const form = new FormData() + form.append('file', fs.createReadStream(largeTestImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 400) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'File size limit exceeded') + }) + + it('File upload transaction should rollback on error', async (t) => { + const app = await build(t) + + const { mock: mockPipeline } = t.mock.method(fs, 'createWriteStream') + mockPipeline.mockImplementationOnce(() => { + throw new Error() + }) + + const { mock: mockLogError } = t.mock.method(app.log, 'error') + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 500) + assert.strictEqual(mockLogError.callCount(), 1) + + const arg = mockLogError.calls[0].arguments[0] as unknown as { + err: Error + } + + assert.deepStrictEqual(arg.err.message, 'Transaction failed.') + }) + + it('should retrieve the uploaded image based on task ID', async (t) => { + app = await build(t) + + const taskFilename = encodeURIComponent(`${taskId}_${filename}`) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: `/api/tasks/${taskFilename}/image` + }) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.headers['content-type'], 'image/png') + + const originalFile = fs.readFileSync(testImagePath) + + assert.deepStrictEqual(originalFile, res.rawPayload) + }) + + it('should return 404 error for non-existant filename', async (t) => { + app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks/non-existant/image' + }) + + assert.strictEqual(res.statusCode, 404) + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'No task has filename "non-existant"') + }) + }) }) diff --git a/uploads/tasks/.gitkeep b/uploads/tasks/.gitkeep new file mode 100644 index 00000000..e69de29b From 2ba800f03c38a5947726cb4c0bbebb228eb89695 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 21:06:27 +0200 Subject: [PATCH 26/34] refactor: improve scripts typing --- scripts/create-database.ts | 2 +- scripts/drop-database.ts | 2 +- scripts/migrate.ts | 22 ++++++---------------- scripts/seed-database.ts | 6 +++--- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/scripts/create-database.ts b/scripts/create-database.ts index f381cd4d..3a00ba5a 100644 --- a/scripts/create-database.ts +++ b/scripts/create-database.ts @@ -1,7 +1,7 @@ import { createConnection, Connection } from 'mysql2/promise' async function createDatabase () { - const connection: Connection = await createConnection({ + const connection = await createConnection({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT), user: process.env.MYSQL_USER, diff --git a/scripts/drop-database.ts b/scripts/drop-database.ts index 4d5cd5a5..5969256f 100644 --- a/scripts/drop-database.ts +++ b/scripts/drop-database.ts @@ -1,7 +1,7 @@ import { createConnection, Connection } from 'mysql2/promise' async function dropDatabase () { - const connection: Connection = await createConnection({ + const connection = await createConnection({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT), user: process.env.MYSQL_USER, diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 48f6fc6b..068ffea4 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -1,15 +1,15 @@ -import mysql, { Connection } from 'mysql2/promise' +import mysql, { FieldPacket } from 'mysql2/promise' import path from 'node:path' import fs from 'node:fs' import Postgrator from 'postgrator' interface PostgratorResult { rows: any; - fields: any; + fields: FieldPacket[]; } async function doMigration (): Promise { - const connection: Connection = await mysql.createConnection({ + const connection = await mysql.createConnection({ multipleStatements: true, host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT), @@ -41,20 +41,10 @@ async function doMigration (): Promise { await postgrator.migrate() console.log('Migration completed!') - - await new Promise((resolve, reject) => { - connection.end((err: unknown) => { - if (err) { - return reject(err) - } - - resolve() - }) - }) - } catch (error) { - console.error(error) + } catch (err) { + console.error(err) } finally { - await connection.end() + await connection.end().catch(err => console.error(err)) } } diff --git a/scripts/seed-database.ts b/scripts/seed-database.ts index aabe4281..1745b27f 100644 --- a/scripts/seed-database.ts +++ b/scripts/seed-database.ts @@ -26,7 +26,7 @@ async function truncateTables (connection: Connection) { if (tables.length > 0) { const tableNames = tables.map( - (row: { [key: string]: string }) => row[`Tables_in_${process.env.MYSQL_DATABASE}`] + (row: Record) => row[`Tables_in_${process.env.MYSQL_DATABASE}`] ) const truncateQueries = tableNames .map((tableName: string) => `TRUNCATE TABLE \`${tableName}\``) @@ -51,14 +51,14 @@ async function seedUsers (connection: Connection) { const rolesAccumulator: number[] = [] for (const username of usernames) { - const [userResult]: any[] = await connection.execute(` + const [userResult] = await connection.execute(` INSERT INTO users (username, password) VALUES (?, ?) `, [username, hash]) const userId = (userResult as { insertId: number }).insertId - const [roleResult]: any[] = await connection.execute(` + const [roleResult] = await connection.execute(` INSERT INTO roles (name) VALUES (?) `, [username]) From ed78e1cced6f76b9b6eb90317f1530dd4d2be258 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 21:11:52 +0200 Subject: [PATCH 27/34] docs: static and multipart plugin --- src/plugins/external/multipart.ts | 5 +++++ src/plugins/external/static.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/plugins/external/multipart.ts b/src/plugins/external/multipart.ts index d3865c0e..10dd03ac 100644 --- a/src/plugins/external/multipart.ts +++ b/src/plugins/external/multipart.ts @@ -11,4 +11,9 @@ export const autoConfig = { } } +/** + * This plugins allows to parse the multipart content-type + * + * @see {@link https://github.com/fastify/fastify-multipart} + */ export default fastifyMultipart diff --git a/src/plugins/external/static.ts b/src/plugins/external/static.ts index 9cd26e84..b6ba9926 100644 --- a/src/plugins/external/static.ts +++ b/src/plugins/external/static.ts @@ -7,4 +7,9 @@ export const autoConfig = (fastify: FastifyInstance): FastifyStaticOptions => ({ prefix: `/${fastify.config.UPLOAD_DIRNAME}` }) +/** + * This plugins allows to serve static files as fast as possible. + * + * @see {@link https://github.com/fastify/fastify-static} + */ export default fastifyStatic From b2e502cf045d79bb1db64780ea7321b10fe9a287 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 21:20:38 +0200 Subject: [PATCH 28/34] chore: dangerous DB operations should be explicitly authorized --- .env.example | 6 +++++- .github/workflows/ci.yml | 1 + README.md | 2 -- scripts/create-database.ts | 4 ++++ scripts/drop-database.ts | 4 ++++ scripts/seed-database.ts | 4 ++++ 6 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 8028311d..28bf0d5f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ # @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} NODE_ENV=production +CAN_CREATE_DATABASE=0 +CAN_DROP_DATABASE=0 +CAN_SEED_DATABASE=0 + # Database MYSQL_HOST=localhost MYSQL_PORT=3306 @@ -16,4 +20,4 @@ LOG_LEVEL=info # Security COOKIE_SECRET= COOKIE_NAME= -RATE_LIMIT_MAX= +RATE_LIMIT_MAX=4 # 4 for tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05eb7304..13b02a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,4 +69,5 @@ jobs: # COOKIE_SECRET is dynamically generated and loaded from the environment COOKIE_NAME: 'sessid' RATE_LIMIT_MAX: 4 + CAN_SEED_DATABASE: 1 run: npm run db:migrate && npm run test diff --git a/README.md b/README.md index d5301a6e..987403cc 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ ![CI](https://github.com/fastify/demo/workflows/CI/badge.svg) -> :warning: **Please note:** This repository is still under active development. - The aim of this repository is to provide a concrete example of a Fastify application using what are considered best practices by the Fastify community. **Prerequisites:** You need to have Node.js version 22 or higher installed. diff --git a/scripts/create-database.ts b/scripts/create-database.ts index 3a00ba5a..405452b1 100644 --- a/scripts/create-database.ts +++ b/scripts/create-database.ts @@ -1,5 +1,9 @@ import { createConnection, Connection } from 'mysql2/promise' +if (Number(process.env.CAN_CREATE_DATABASE) !== 1) { + throw new Error("You can't create the database. Set `CAN_CREATE_DATABASE=1` environment variable to allow this operation.") +} + async function createDatabase () { const connection = await createConnection({ host: process.env.MYSQL_HOST, diff --git a/scripts/drop-database.ts b/scripts/drop-database.ts index 5969256f..ccaaee70 100644 --- a/scripts/drop-database.ts +++ b/scripts/drop-database.ts @@ -1,5 +1,9 @@ import { createConnection, Connection } from 'mysql2/promise' +if (Number(process.env.CAN_DROP_DATABASE) !== 1) { + throw new Error("You can't drop the database. Set `CAN_DROP_DATABASE=1` environment variable to allow this operation.") +} + async function dropDatabase () { const connection = await createConnection({ host: process.env.MYSQL_HOST, diff --git a/scripts/seed-database.ts b/scripts/seed-database.ts index 1745b27f..7f4054fc 100644 --- a/scripts/seed-database.ts +++ b/scripts/seed-database.ts @@ -1,6 +1,10 @@ import { createConnection, Connection } from 'mysql2/promise' import { scryptHash } from '../src/plugins/custom/scrypt.js' +if (Number(process.env.CAN_SEED_DATABASE) !== 1) { + throw new Error("You can't seed the database. Set `CAN_SEED_DATABASE=1` environment variable to allow this operation.") +} + async function seed () { const connection: Connection = await createConnection({ multipleStatements: true, From bfd7380620d344241a1ac54f4baafd50504761e4 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 19 Oct 2024 22:00:25 +0200 Subject: [PATCH 29/34] refactor: use node test runner utitities --- src/routes/api/tasks/index.ts | 1 - test/plugins/scrypt.test.ts | 13 +++++++------ uploads/tasks/13_one_line.png | 2 ++ uploads/tasks/13_one_line_csv.png | 2 ++ 4 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 uploads/tasks/13_one_line.png create mode 100644 uploads/tasks/13_one_line_csv.png diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 6d095423..0641814d 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -239,7 +239,6 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } const file = await request.file() - if (!file) { return reply.notFound('File not found') } diff --git a/test/plugins/scrypt.test.ts b/test/plugins/scrypt.test.ts index ea513d80..09d0371e 100644 --- a/test/plugins/scrypt.test.ts +++ b/test/plugins/scrypt.test.ts @@ -1,11 +1,12 @@ -import { test } from 'tap' +import { test } from 'node:test' import Fastify from 'fastify' import scryptPlugin from '../../src/plugins/custom/scrypt.js' +import assert from 'node:assert' test('scrypt works standalone', async t => { const app = Fastify() - t.teardown(() => app.close()) + t.after(() => app.close()) app.register(scryptPlugin) @@ -13,15 +14,15 @@ test('scrypt works standalone', async t => { const password = 'test_password' const hash = await app.hash(password) - t.type(hash, 'string') + assert.ok(typeof hash === 'string') const isValid = await app.compare(password, hash) - t.ok(isValid, 'compare should return true for correct password') + assert.ok(isValid, 'compare should return true for correct password') const isInvalid = await app.compare('wrong_password', hash) - t.notOk(isInvalid, 'compare should return false for incorrect password') + assert.ok(!isInvalid, 'compare should return false for incorrect password') - await t.rejects( + await assert.rejects( () => app.compare(password, 'malformed_hash'), 'compare should throw an error for malformed hash' ) diff --git a/uploads/tasks/13_one_line.png b/uploads/tasks/13_one_line.png new file mode 100644 index 00000000..0b6fa709 --- /dev/null +++ b/uploads/tasks/13_one_line.png @@ -0,0 +1,2 @@ +Line +This is a very small CSV with one line. diff --git a/uploads/tasks/13_one_line_csv.png b/uploads/tasks/13_one_line_csv.png new file mode 100644 index 00000000..0b6fa709 --- /dev/null +++ b/uploads/tasks/13_one_line_csv.png @@ -0,0 +1,2 @@ +Line +This is a very small CSV with one line. From e946939ea5c7baa6d3bde3d74b9fbfff94a024dc Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 20 Oct 2024 07:42:37 +0200 Subject: [PATCH 30/34] refactor: check file size before mime-type --- src/routes/api/tasks/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 0641814d..fed952dc 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -243,15 +243,15 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { return reply.notFound('File not found') } + if (file.file.truncated) { + return reply.badRequest('File size limit exceeded') + } + const allowedMimeTypes = ['image/jpeg', 'image/png'] if (!allowedMimeTypes.includes(file.mimetype)) { return reply.badRequest('Invalid file type') } - if (file.file.truncated) { - return reply.badRequest('File size limit exceeded') - } - const filename = `${id}_${file.filename}` const filePath = path.join( import.meta.dirname, From 265940ee18fa5fb3de41d9b17c6e70c2144fd0f3 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 20 Oct 2024 07:42:57 +0200 Subject: [PATCH 31/34] fix: identifier typo --- test/routes/api/auth/auth.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index c5c71d1b..d077bd03 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -10,7 +10,7 @@ test('Transaction should rollback on error', async (t) => { throw new Error() }) - const { mock: mockHello } = t.mock.method(app.log, 'error') + const { mock: mockLogError } = t.mock.method(app.log, 'error') const res = await app.inject({ method: 'POST', @@ -23,7 +23,7 @@ test('Transaction should rollback on error', async (t) => { assert.strictEqual(mockCompare.callCount(), 1) - const arg = mockHello.calls[0].arguments[0] as unknown as { + const arg = mockLogError.calls[0].arguments[0] as unknown as { err: Error } From 7c4dbc3ef6b712f8b625dbc683f659647e398cd8 Mon Sep 17 00:00:00 2001 From: jean Date: Mon, 21 Oct 2024 20:20:39 +0200 Subject: [PATCH 32/34] feat: do not use rm -rf --- .gitignore | 3 +++ package.json | 2 +- uploads/tasks/.gitkeep | 0 3 files changed, 4 insertions(+), 1 deletion(-) delete mode 100644 uploads/tasks/.gitkeep diff --git a/.gitignore b/.gitignore index e4d563a7..a0a7fee9 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ bun.lockb package-lock.json pnpm-lock.yaml yarn.lock + +# uploaded files +uploads/* \ No newline at end of file diff --git a/package.json b/package.json index 3c3b1d94..bfa9a3bd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "start": "npm run build && fastify start -l info dist/app.js", - "build": "rm -rf ./dist && tsc", + "build": "tsc", "watch": "tsc -w", "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", "dev:start": "npm run build && fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", diff --git a/uploads/tasks/.gitkeep b/uploads/tasks/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 5af3cd0fb5086e1afc7452c3db9acc96cc0f369b Mon Sep 17 00:00:00 2001 From: jean Date: Mon, 21 Oct 2024 21:19:57 +0200 Subject: [PATCH 33/34] fix: storage path disclosure --- .gitignore | 2 +- src/plugins/external/env.ts | 5 + src/plugins/external/static.ts | 21 +++- src/routes/api/tasks/index.ts | 6 +- test/routes/api/tasks/tasks.test.ts | 149 +++++++++++++++++++++------- uploads/tasks/13_one_line.png | 2 - uploads/tasks/13_one_line_csv.png | 2 - 7 files changed, 138 insertions(+), 49 deletions(-) delete mode 100644 uploads/tasks/13_one_line.png delete mode 100644 uploads/tasks/13_one_line_csv.png diff --git a/.gitignore b/.gitignore index a0a7fee9..5d587cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,4 @@ pnpm-lock.yaml yarn.lock # uploaded files -uploads/* \ No newline at end of file +uploads/tasks/* \ No newline at end of file diff --git a/src/plugins/external/env.ts b/src/plugins/external/env.ts index 3a610d94..b5157724 100644 --- a/src/plugins/external/env.ts +++ b/src/plugins/external/env.ts @@ -14,6 +14,7 @@ declare module 'fastify' { COOKIE_SECURED: boolean; RATE_LIMIT_MAX: number; UPLOAD_DIRNAME: string; + UPLOAD_TASKS_DIRNAME: string; }; } } @@ -70,6 +71,10 @@ const schema = { UPLOAD_DIRNAME: { type: 'string', default: 'uploads' + }, + UPLOAD_TASKS_DIRNAME: { + type: 'string', + default: 'tasks' } } } diff --git a/src/plugins/external/static.ts b/src/plugins/external/static.ts index b6ba9926..06ba21f5 100644 --- a/src/plugins/external/static.ts +++ b/src/plugins/external/static.ts @@ -1,11 +1,24 @@ import fastifyStatic, { FastifyStaticOptions } from '@fastify/static' import { FastifyInstance } from 'fastify' +import fs from 'fs' import path from 'path' -export const autoConfig = (fastify: FastifyInstance): FastifyStaticOptions => ({ - root: path.join(import.meta.dirname, '../../..'), - prefix: `/${fastify.config.UPLOAD_DIRNAME}` -}) +export const autoConfig = (fastify: FastifyInstance): FastifyStaticOptions => { + const dirPath = path.join(import.meta.dirname, '../../..', fastify.config.UPLOAD_DIRNAME) + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath) + } + + const dirTasksPath = path.join(dirPath, fastify.config.UPLOAD_TASKS_DIRNAME) + if (!fs.existsSync(dirTasksPath)) { + fs.mkdirSync(dirTasksPath) + } + + return { + root: path.join(import.meta.dirname, '../../..'), + prefix: `/${fastify.config.UPLOAD_DIRNAME}` + } +} /** * This plugins allows to serve static files as fast as possible. diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index fed952dc..087a2941 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -15,8 +15,6 @@ import path from 'node:path' import { pipeline } from 'node:stream/promises' import fs from 'node:fs' -const TASKS_DIRNAME = 'tasks' - const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( '/', @@ -257,7 +255,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { import.meta.dirname, '../../../..', fastify.config.UPLOAD_DIRNAME, - TASKS_DIRNAME, + fastify.config.UPLOAD_TASKS_DIRNAME, filename ) @@ -298,7 +296,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { return reply.sendFile( task.filename as string, - path.join(fastify.config.UPLOAD_DIRNAME, TASKS_DIRNAME) + path.join(fastify.config.UPLOAD_DIRNAME, fastify.config.UPLOAD_TASKS_DIRNAME) ) } ) diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index f75d5c4b..6065cafb 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,7 +1,11 @@ import { after, before, describe, it } from 'node:test' import assert from 'node:assert' import { build } from '../../../helper.js' -import { Task, TaskStatusEnum, TaskPaginationResultSchema } from '../../../../src/schemas/tasks.js' +import { + Task, + TaskStatusEnum, + TaskPaginationResultSchema +} from '../../../../src/schemas/tasks.js' import { FastifyInstance } from 'fastify' import { Static } from '@sinclair/typebox' import fs from 'node:fs' @@ -9,7 +13,10 @@ import path from 'node:path' import FormData from 'form-data' import os from 'os' -async function createUser (app: FastifyInstance, userData: Partial<{ username: string; password: string }>) { +async function createUser ( + app: FastifyInstance, + userData: Partial<{ username: string; password: string }> +) { const [id] = await app.knex('users').insert(userData) return id } @@ -31,13 +38,37 @@ describe('Tasks api (logged user only)', () => { before(async () => { app = await build() - userId1 = await createUser(app, { username: 'user1', password: 'password1' }) - userId2 = await createUser(app, { username: 'user2', password: 'password2' }) + userId1 = await createUser(app, { + username: 'user1', + password: 'password1' + }) + userId2 = await createUser(app, { + username: 'user2', + password: 'password2' + }) - firstTaskId = await createTask(app, { name: 'Task 1', author_id: userId1, status: TaskStatusEnum.New }) - await createTask(app, { name: 'Task 2', author_id: userId1, assigned_user_id: userId2, status: TaskStatusEnum.InProgress }) - await createTask(app, { name: 'Task 3', author_id: userId2, status: TaskStatusEnum.Completed }) - await createTask(app, { name: 'Task 4', author_id: userId1, assigned_user_id: userId1, status: TaskStatusEnum.OnHold }) + firstTaskId = await createTask(app, { + name: 'Task 1', + author_id: userId1, + status: TaskStatusEnum.New + }) + await createTask(app, { + name: 'Task 2', + author_id: userId1, + assigned_user_id: userId2, + status: TaskStatusEnum.InProgress + }) + await createTask(app, { + name: 'Task 3', + author_id: userId2, + status: TaskStatusEnum.Completed + }) + await createTask(app, { + name: 'Task 4', + author_id: userId1, + assigned_user_id: userId1, + status: TaskStatusEnum.OnHold + }) app.close() }) @@ -51,7 +82,9 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const { tasks, total } = JSON.parse(res.payload) as Static + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > const firstTask = tasks.find((task) => task.id === firstTaskId) assert.ok(firstTask, 'Created task should be in the response') @@ -71,7 +104,9 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const { tasks, total } = JSON.parse(res.payload) as Static + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > assert.strictEqual(total, 4) assert.strictEqual(tasks.length, 1) @@ -90,10 +125,14 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const { tasks, total } = JSON.parse(res.payload) as Static + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > assert.strictEqual(total, 1) - tasks.forEach(task => assert.strictEqual(task.assigned_user_id, userId2)) + tasks.forEach((task) => + assert.strictEqual(task.assigned_user_id, userId2) + ) }) it('should filter tasks by status', async (t) => { @@ -105,10 +144,14 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const { tasks, total } = JSON.parse(res.payload) as Static + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > assert.strictEqual(total, 1) - tasks.forEach(task => assert.strictEqual(task.status, TaskStatusEnum.Completed)) + tasks.forEach((task) => + assert.strictEqual(task.status, TaskStatusEnum.Completed) + ) }) it('should paginate and filter tasks by author_id and status', async (t) => { @@ -116,11 +159,18 @@ describe('Tasks api (logged user only)', () => { const res = await app.injectWithLogin('basic', { method: 'GET', url: '/api/tasks', - query: { author_id: userId1.toString(), status: TaskStatusEnum.OnHold, page: '1', limit: '1' } + query: { + author_id: userId1.toString(), + status: TaskStatusEnum.OnHold, + page: '1', + limit: '1' + } }) assert.strictEqual(res.statusCode, 200) - const { tasks, total } = JSON.parse(res.payload) as Static + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > assert.strictEqual(total, 1) assert.strictEqual(tasks.length, 1) @@ -140,7 +190,9 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const { tasks, total } = JSON.parse(res.payload) as Static + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > assert.strictEqual(total, 0) assert.strictEqual(tasks.length, 0) @@ -228,7 +280,10 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + const updatedTask = await app + .knex('tasks') + .where({ id: newTaskId }) + .first() assert.equal(updatedTask?.name, updatedData.name) }) @@ -269,7 +324,10 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 204) - const deletedTask = await app.knex('tasks').where({ id: newTaskId }).first() + const deletedTask = await app + .knex('tasks') + .where({ id: newTaskId }) + .first() assert.strictEqual(deletedTask, undefined) }) @@ -309,7 +367,10 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + const updatedTask = await app + .knex('tasks') + .where({ id: newTaskId }) + .first() assert.strictEqual(updatedTask?.assigned_user_id, 2) } }) @@ -334,7 +395,10 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 200) - const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + const updatedTask = await app + .knex('tasks') + .where({ id: newTaskId }) + .first() assert.strictEqual(updatedTask?.assigned_user_id, null) } }) @@ -375,24 +439,40 @@ describe('Tasks api (logged user only)', () => { const fixturesDir = path.join(import.meta.dirname, './fixtures') const testImagePath = path.join(fixturesDir, filename) const testCsvPath = path.join(fixturesDir, 'one_line.csv') - let uploadedImagePath: string + let uploadDir: string + let uploadDirTask: string before(async () => { app = await build() + uploadDir = path.join(import.meta.dirname, '../../../../', app.config.UPLOAD_DIRNAME) + uploadDirTask = path.join(uploadDir, app.config.UPLOAD_TASKS_DIRNAME) + assert.ok(fs.existsSync(uploadDir)) - // Ensure the directory exists - if (!fs.existsSync(fixturesDir)) { - fs.mkdirSync(fixturesDir, { recursive: true }) - } - - // Create a sample task to associate with image uploads - taskId = await createTask(app, { name: 'Task with image', author_id: 1, status: TaskStatusEnum.New }) + taskId = await createTask(app, { + name: 'Task with image', + author_id: 1, + status: TaskStatusEnum.New + }) app.close() }) after(() => { - fs.unlinkSync(uploadedImagePath) + const files = fs.readdirSync(uploadDirTask) + files.forEach((file) => { + const filePath = path.join(uploadDirTask, file) + fs.rmSync(filePath, { recursive: true }) + }) + }) + + it('should create upload directories at boot if not exist', async (t) => { + fs.rmSync(uploadDir, { recursive: true }) + assert.ok(!fs.existsSync(uploadDir)) + + app = await build(t) + + assert.ok(fs.existsSync(uploadDir)) + assert.ok(fs.existsSync(uploadDirTask)) }) it('should upload a valid image for a task', async (t) => { @@ -410,11 +490,8 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(res.statusCode, 200) - const { path: uploadedImagePath_, message } = JSON.parse(res.payload) + const { message } = JSON.parse(res.payload) assert.strictEqual(message, 'File uploaded successfully') - - uploadedImagePath = uploadedImagePath_ - assert.ok(fs.existsSync(uploadedImagePath_)) }) it('should return 404 if task not found', async (t) => { @@ -522,13 +599,13 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(mockLogError.callCount(), 1) const arg = mockLogError.calls[0].arguments[0] as unknown as { - err: Error + err: Error; } assert.deepStrictEqual(arg.err.message, 'Transaction failed.') }) - it('should retrieve the uploaded image based on task ID', async (t) => { + it('should retrieve the uploaded image based on task id and filename', async (t) => { app = await build(t) const taskFilename = encodeURIComponent(`${taskId}_${filename}`) diff --git a/uploads/tasks/13_one_line.png b/uploads/tasks/13_one_line.png deleted file mode 100644 index 0b6fa709..00000000 --- a/uploads/tasks/13_one_line.png +++ /dev/null @@ -1,2 +0,0 @@ -Line -This is a very small CSV with one line. diff --git a/uploads/tasks/13_one_line_csv.png b/uploads/tasks/13_one_line_csv.png deleted file mode 100644 index 0b6fa709..00000000 --- a/uploads/tasks/13_one_line_csv.png +++ /dev/null @@ -1,2 +0,0 @@ -Line -This is a very small CSV with one line. From 444000e069c3fad97b335c6b3cbc0a226cf5a840 Mon Sep 17 00:00:00 2001 From: jean Date: Mon, 21 Oct 2024 21:33:27 +0200 Subject: [PATCH 34/34] fix: nit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5d587cd4..2b923efe 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,4 @@ pnpm-lock.yaml yarn.lock # uploaded files -uploads/tasks/* \ No newline at end of file +uploads/tasks/*