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/@types/fastify/fastify.d.ts b/@types/fastify/fastify.d.ts deleted file mode 100644 index b9d797bd..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/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.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/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/package.json b/package.json index 8384ea4f..d7854689 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "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: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" }, "keywords": [], "author": "Michelet Jean ", @@ -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,13 +42,14 @@ "fastify": "^5.0.0", "fastify-cli": "^7.0.0", "fastify-plugin": "^5.0.1", - "postgrator": "^7.3.0" + "knex": "^3.1.0", + "postgrator": "^7.3.0", + "ts-node": "^10.9.2" }, "devDependencies": { "@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/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..a3cd39ae --- /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' } +) diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts deleted file mode 100644 index 1ef3d7f8..00000000 --- a/src/plugins/custom/repository.ts +++ /dev/null @@ -1,109 +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; -} - -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 repository = { - ...fastify.mysql, - find: async (table: string, opts: QueryOptions): Promise => { - const { select = '*', where = { 1: 1 } } = opts - const [clause, values] = processAssignmentRecord(where, 'AND') - - const query = `SELECT ${select} FROM ${table} 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 } } = opts - const [clause, values] = processAssignmentRecord(where, 'AND') - - const query = `SELECT ${select} FROM ${table} 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/custom/scrypt.ts b/src/plugins/custom/scrypt.ts index 1c78b8e2..359e3ffd 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/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 292f7191..418c41d6 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -1,8 +1,5 @@ -import { - FastifyPluginAsyncTypebox, - Type -} from '@fastify/type-provider-typebox' -import { CredentialsSchema, Auth } from '../../../schemas/auth.js' +import { FastifyPluginAsyncTypebox, Type } from '@fastify/type-provider-typebox' +import { CredentialsSchema, Credentials } from '../../../schemas/auth.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.post( @@ -24,22 +21,30 @@ 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 token = fastify.jwt.sign({ username: user.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, + 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 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 4ad70d91..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 } ) @@ -122,11 +109,12 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 404: Type.Object({ message: Type.String() }) }, tags: ['Tasks'] - } + }, + preHandler: fastify.isAdmin }, 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) @@ -151,21 +139,21 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 404: Type.Object({ message: Type.String() }) }, tags: ['Tasks'] - } + }, + preHandler: fastify.isModerator }, async function (request, reply) { 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/schemas/auth.ts b/src/schemas/auth.ts index 4acdb0d1..b222e90a 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/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/scripts/migrate.js b/src/scripts/migrate.ts similarity index 50% rename from scripts/migrate.js rename to src/scripts/migrate.ts index 1eab2ca4..1ce8c7af 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,14 +30,18 @@ async function doMigration () { await postgrator.migrate() - await new Promise((resolve, reject) => { - connection.end((err) => { + 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)) diff --git a/src/scripts/seed-database.ts b/src/scripts/seed-database.ts new file mode 100644 index 00000000..98c83e8b --- /dev/null +++ b/src/scripts/seed-database.ts @@ -0,0 +1,84 @@ +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$') + + // 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) + 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 + + rolesAccumulator.push(newRoleId) + + for (const roleId of rolesAccumulator) { + 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 bc935c5c..aa5ed000 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -22,7 +22,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] @@ -33,7 +33,7 @@ async function login (this: FastifyInstance, username: string) { url: '/api/auth/login', payload: { username, - password: 'password' + password: 'password123$' } }) 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/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index 5ae66520..2231c54d 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 d4d9987e..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) => { @@ -142,31 +144,31 @@ 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}` }) 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) => { const app = await build(t) - const res = await app.injectWithLogin('basic', { + const res = await app.injectWithLogin('admin', { method: 'DELETE', url: '/api/tasks/9999' }) @@ -181,54 +183,70 @@ 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('basic', { - 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.knex('tasks').where({ id: newTaskId }).first() + 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 + 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(username, { + method: 'POST', + url: `/api/tasks/${newTaskId}/assign`, + payload: {} + }) + + assert.strictEqual(res.statusCode, 200) + + const updatedTask = await app.knex('tasks').where({ id: newTaskId }).first() + assert.strictEqual(updatedTask?.assigned_user_id, null) } - const newTaskId = await createTask(app, taskData) + }) + + it('should return 403 if not a moderator', async (t) => { + const app = await build(t) const res = await app.injectWithLogin('basic', { method: 'POST', - url: `/api/tasks/${newTaskId}/assign`, + url: '/api/tasks/1/assign', payload: {} }) - assert.strictEqual(res.statusCode, 200) - - const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task - assert.strictEqual(updatedTask.assigned_user_id, null) + 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: {