From ab5dcc113e2f73c1fa6d94d75425e1339d00bd94 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 15:57:56 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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)