diff --git a/.env.example b/.env.example index d6f4489..28bf0d5 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ # @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} NODE_ENV=production +CAN_CREATE_DATABASE=0 +CAN_DROP_DATABASE=0 +CAN_SEED_DATABASE=0 + # Database MYSQL_HOST=localhost MYSQL_PORT=3306 @@ -14,5 +18,6 @@ FASTIFY_CLOSE_GRACE_DELAY=1000 LOG_LEVEL=info # Security -JWT_SECRET= -RATE_LIMIT_MAX= +COOKIE_SECRET= +COOKIE_NAME= +RATE_LIMIT_MAX=4 # 4 for tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e040d3..13b02a3 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/**" @@ -50,11 +51,10 @@ jobs: - name: Lint Code run: npm run lint - - name: Generate JWT Secret - id: gen-jwt + - name: Generate COOKIE Secret run: | - JWT_SECRET=$(openssl rand -hex 32) - echo "JWT_SECRET=$JWT_SECRET" >> $GITHUB_ENV + COOKIE_SECRET=$(openssl rand -hex 32) + echo "COOKIE_SECRET=$COOKIE_SECRET" >> $GITHUB_ENV - name: Generate dummy .env for scripts using -env-file=.env flag run: touch .env @@ -66,6 +66,8 @@ jobs: MYSQL_DATABASE: test_db MYSQL_USER: test_user MYSQL_PASSWORD: test_password - # JWT_SECRET is dynamically generated and loaded from the environment + # COOKIE_SECRET is dynamically generated and loaded from the environment + COOKIE_NAME: 'sessid' RATE_LIMIT_MAX: 4 + CAN_SEED_DATABASE: 1 run: npm run db:migrate && npm run test diff --git a/.gitignore b/.gitignore index e4d563a..2b923ef 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ bun.lockb package-lock.json pnpm-lock.yaml yarn.lock + +# uploaded files +uploads/tasks/* diff --git a/@types/fastify/fastify.d.ts b/@types/fastify/fastify.d.ts deleted file mode 100644 index b9d797b..0000000 --- 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/README.md b/README.md index d5301a6..987403c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ ![CI](https://github.com/fastify/demo/workflows/CI/badge.svg) -> :warning: **Please note:** This repository is still under active development. - The aim of this repository is to provide a concrete example of a Fastify application using what are considered best practices by the Fastify community. **Prerequisites:** You need to have Node.js version 22 or higher installed. diff --git a/docker-compose.yml b/docker-compose.yml index 10830fc..69bdfc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,19 @@ services: db: image: mysql:8.4 environment: - MYSQL_DATABASE: ${MYSQL_DATABASE} - MYSQL_USER: ${MYSQL_USER} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} ports: - 3306:3306 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-u${MYSQL_USER}", "-p${MYSQL_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 3 volumes: - db_data:/var/lib/mysql - + volumes: db_data: diff --git a/migrations/002.do.tasks.sql b/migrations/002.do.tasks.sql index 9c08a4e..8dd7521 100644 --- a/migrations/002.do.tasks.sql +++ b/migrations/002.do.tasks.sql @@ -3,6 +3,7 @@ CREATE TABLE tasks ( name VARCHAR(255) NOT NULL, author_id INT NOT NULL, assigned_user_id INT, + filename VARCHAR(255), status VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/migrations/004.do.roles.sql b/migrations/004.do.roles.sql new file mode 100644 index 0000000..0dbae96 --- /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 0000000..06e938c --- /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 0000000..1ad3d93 --- /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 0000000..71fd145 --- /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 95880a7..bfa9a3b 100644 --- a/package.json +++ b/package.json @@ -12,26 +12,30 @@ "build": "tsc", "watch": "tsc -w", "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", - "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", + "dev:start": "npm run build && fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", "test": "npm run db:seed && tap --jobs=1 test/**/*", - "standalone": "node --env-file=.env dist/server.js", + "standalone": "npm run build && node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", - "db:migrate": "node --env-file=.env scripts/migrate.js", - "db:seed": "node --env-file=.env scripts/seed-database.js" + "db:create": "tsx --env-file=.env ./scripts/create-database.ts", + "db:drop": "tsx --env-file=.env ./scripts/drop-database.ts", + "db:migrate": "tsx --env-file=.env ./scripts/migrate.ts", + "db:seed": "tsx --env-file=.env ./scripts/seed-database.ts" }, "keywords": [], "author": "Michelet Jean ", "license": "MIT", "dependencies": { "@fastify/autoload": "^6.0.0", + "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.0", "@fastify/env": "^5.0.1", "@fastify/helmet": "^12.0.0", - "@fastify/jwt": "^9.0.0", - "@fastify/mysql": "^5.0.1", + "@fastify/multipart": "^9.0.1", "@fastify/rate-limit": "^10.0.1", "@fastify/sensible": "^6.0.1", + "@fastify/session": "^11.0.1", + "@fastify/static": "^8.0.2", "@fastify/swagger": "^9.0.0", "@fastify/swagger-ui": "^5.0.1", "@fastify/type-provider-typebox": "^5.0.0", @@ -41,15 +45,18 @@ "fastify": "^5.0.0", "fastify-cli": "^7.0.0", "fastify-plugin": "^5.0.1", + "form-data": "^4.0.1", + "knex": "^3.1.0", + "mysql2": "^3.11.3", "postgrator": "^7.3.0" }, "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", + "tsx": "^4.19.1", "typescript": "~5.6.2" } } diff --git a/scripts/create-database.ts b/scripts/create-database.ts new file mode 100644 index 0000000..405452b --- /dev/null +++ b/scripts/create-database.ts @@ -0,0 +1,30 @@ +import { createConnection, Connection } from 'mysql2/promise' + +if (Number(process.env.CAN_CREATE_DATABASE) !== 1) { + throw new Error("You can't create the database. Set `CAN_CREATE_DATABASE=1` environment variable to allow this operation.") +} + +async function createDatabase () { + const connection = await createConnection({ + host: process.env.MYSQL_HOST, + 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/scripts/drop-database.ts b/scripts/drop-database.ts new file mode 100644 index 0000000..ccaaee7 --- /dev/null +++ b/scripts/drop-database.ts @@ -0,0 +1,30 @@ +import { createConnection, Connection } from 'mysql2/promise' + +if (Number(process.env.CAN_DROP_DATABASE) !== 1) { + throw new Error("You can't drop the database. Set `CAN_DROP_DATABASE=1` environment variable to allow this operation.") +} + +async function dropDatabase () { + const connection = await createConnection({ + host: process.env.MYSQL_HOST, + 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/scripts/migrate.js deleted file mode 100644 index 1eab2ca..0000000 --- a/scripts/migrate.js +++ /dev/null @@ -1,39 +0,0 @@ -import mysql from 'mysql2/promise' -import path from 'path' -import Postgrator from 'postgrator' - -async function doMigration () { - const connection = await mysql.createConnection({ - multipleStatements: true, - host: process.env.MYSQL_HOST, - port: process.env.MYSQL_PORT, - database: process.env.MYSQL_DATABASE, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD - }) - - const postgrator = new Postgrator({ - migrationPattern: path.join(import.meta.dirname, '../migrations', '*'), - driver: 'mysql', - database: process.env.MYSQL_DATABASE, - execQuery: async (query) => { - const [rows, fields] = await connection.query(query) - - return { rows, fields } - }, - schemaTable: 'schemaversion' - }) - - await postgrator.migrate() - - await new Promise((resolve, reject) => { - connection.end((err) => { - if (err) { - return reject(err) - } - resolve() - }) - }) -} - -doMigration().catch(err => console.error(err)) diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..068ffea --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,51 @@ +import mysql, { FieldPacket } from 'mysql2/promise' +import path from 'node:path' +import fs from 'node:fs' +import Postgrator from 'postgrator' + +interface PostgratorResult { + rows: any; + fields: FieldPacket[]; +} + +async function doMigration (): Promise { + const connection = await mysql.createConnection({ + multipleStatements: true, + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + database: process.env.MYSQL_DATABASE, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + try { + const migrationDir = path.join(import.meta.dirname, '../migrations') + + if (!fs.existsSync(migrationDir)) { + throw new Error( + `Migration directory "${migrationDir}" does not exist. Skipping migrations.` + ) + } + + const postgrator = new Postgrator({ + migrationPattern: path.join(migrationDir, '*'), + driver: 'mysql', + database: process.env.MYSQL_DATABASE, + execQuery: async (query: string): Promise => { + const [rows, fields] = await connection.query(query) + return { rows, fields } + }, + schemaTable: 'schemaversion' + }) + + await postgrator.migrate() + + console.log('Migration completed!') + } catch (err) { + console.error(err) + } finally { + await connection.end().catch(err => console.error(err)) + } +} + +doMigration() diff --git a/scripts/seed-database.js b/scripts/seed-database.js deleted file mode 100644 index 1c5acc9..0000000 --- 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/scripts/seed-database.ts b/scripts/seed-database.ts new file mode 100644 index 0000000..7f4054f --- /dev/null +++ b/scripts/seed-database.ts @@ -0,0 +1,85 @@ +import { createConnection, Connection } from 'mysql2/promise' +import { scryptHash } from '../src/plugins/custom/scrypt.js' + +if (Number(process.env.CAN_SEED_DATABASE) !== 1) { + throw new Error("You can't seed the database. Set `CAN_SEED_DATABASE=1` environment variable to allow this operation.") +} + +async function seed () { + const connection: Connection = await createConnection({ + multipleStatements: true, + 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) + } catch (error) { + console.error('Error seeding database:', error) + } finally { + 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: Record) => 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] = await connection.execute(` + INSERT INTO users (username, password) + VALUES (?, ?) + `, [username, hash]) + + const userId = (userResult as { insertId: number }).insertId + + const [roleResult] = 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/src/app.ts b/src/app.ts index b36f698..2100feb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -45,7 +45,7 @@ export default async function serviceApp ( }) fastify.setErrorHandler((err, request, reply) => { - request.log.error( + fastify.log.error( { err, request: { @@ -61,7 +61,7 @@ export default async function serviceApp ( reply.code(err.statusCode ?? 500) let message = 'Internal Server Error' - if (err.statusCode === 401) { + if (err.statusCode && err.statusCode < 500) { message = err.message } diff --git a/src/plugins/custom/authorization.ts b/src/plugins/custom/authorization.ts new file mode 100644 index 0000000..6afff0c --- /dev/null +++ b/src/plugins/custom/authorization.ts @@ -0,0 +1,41 @@ +import fp from 'fastify-plugin' +import { FastifyReply, FastifyRequest } from 'fastify' + +declare module 'fastify' { + export interface FastifyRequest { + verifyAccess: typeof verifyAccess; + isModerator: typeof isModerator; + isAdmin: typeof isAdmin; + } +} + +function verifyAccess (this: FastifyRequest, reply: FastifyReply, role: string) { + if (!this.session.user.roles.includes(role)) { + reply.status(403).send('You are not authorized to access this resource.') + } +} + +async function isModerator (this: FastifyRequest, reply: FastifyReply) { + this.verifyAccess(reply, 'moderator') +} + +async function isAdmin (this: FastifyRequest, reply: FastifyReply) { + this.verifyAccess(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.decorateRequest('verifyAccess', verifyAccess) + fastify.decorateRequest('isModerator', isModerator) + fastify.decorateRequest('isAdmin', isAdmin) + }, + // You should name your plugins if you want to avoid name collisions + // and/or to perform dependency checks. + { name: 'authorization' } +) diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts deleted file mode 100644 index 1ef3d7f..0000000 --- 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 1c78b8e..359e3ff 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/env.ts b/src/plugins/external/env.ts index 68a7f53..b515772 100644 --- a/src/plugins/external/env.ts +++ b/src/plugins/external/env.ts @@ -9,8 +9,12 @@ declare module 'fastify' { MYSQL_USER: string; MYSQL_PASSWORD: string; MYSQL_DATABASE: string; - JWT_SECRET: string; + COOKIE_SECRET: string; + COOKIE_NAME: string; + COOKIE_SECURED: boolean; RATE_LIMIT_MAX: number; + UPLOAD_DIRNAME: string; + UPLOAD_TASKS_DIRNAME: string; }; } } @@ -23,7 +27,9 @@ const schema = { 'MYSQL_USER', 'MYSQL_PASSWORD', 'MYSQL_DATABASE', - 'JWT_SECRET' + 'COOKIE_SECRET', + 'COOKIE_NAME', + 'COOKIE_SECURED' ], properties: { // Database @@ -46,12 +52,29 @@ const schema = { }, // Security - JWT_SECRET: { + COOKIE_SECRET: { type: 'string' }, + COOKIE_NAME: { + type: 'string' + }, + COOKIE_SECURED: { + type: 'boolean', + default: true + }, RATE_LIMIT_MAX: { type: 'number', - default: 100 + default: 100 // Put it to 4 in your .env file for tests + }, + + // Files + UPLOAD_DIRNAME: { + type: 'string', + default: 'uploads' + }, + UPLOAD_TASKS_DIRNAME: { + type: 'string', + default: 'tasks' } } } diff --git a/src/plugins/external/jwt.ts b/src/plugins/external/jwt.ts deleted file mode 100644 index f4213ec..0000000 --- a/src/plugins/external/jwt.ts +++ /dev/null @@ -1,10 +0,0 @@ -import fastifyJwt from '@fastify/jwt' -import { FastifyInstance } from 'fastify' - -export const autoConfig = (fastify: FastifyInstance) => { - return { - secret: fastify.config.JWT_SECRET - } -} - -export default fastifyJwt diff --git a/src/plugins/external/knex.ts b/src/plugins/external/knex.ts new file mode 100644 index 0000000..3ab3533 --- /dev/null +++ b/src/plugins/external/knex.ts @@ -0,0 +1,31 @@ +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 } + } +} + +export default fp(async (fastify: FastifyInstance, opts) => { + fastify.decorate('knex', knex(opts)) + + fastify.addHook('onClose', async (instance) => { + await instance.knex.destroy() + }) +}, { name: 'knex' }) diff --git a/src/plugins/external/multipart.ts b/src/plugins/external/multipart.ts new file mode 100644 index 0000000..10dd03a --- /dev/null +++ b/src/plugins/external/multipart.ts @@ -0,0 +1,19 @@ +import fastifyMultipart from '@fastify/multipart' + +export const autoConfig = { + limits: { + fieldNameSize: 100, // Max field name size in bytes + fieldSize: 100, // Max field value size in bytes + fields: 10, // Max number of non-file fields + fileSize: 1 * 1024 * 1024, // Max file size in bytes (5 MB) + files: 1, // Max number of file fields + parts: 1000 // Max number of parts + } +} + +/** + * This plugins allows to parse the multipart content-type + * + * @see {@link https://github.com/fastify/fastify-multipart} + */ +export default fastifyMultipart diff --git a/src/plugins/external/mysql.ts b/src/plugins/external/mysql.ts deleted file mode 100644 index 0d40193..0000000 --- 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/session.ts b/src/plugins/external/session.ts new file mode 100644 index 0000000..d435ed0 --- /dev/null +++ b/src/plugins/external/session.ts @@ -0,0 +1,30 @@ +import fastifySession from '@fastify/session' +import fp from 'fastify-plugin' +import { Auth } from '../../schemas/auth.js' +import fastifyCookie from '@fastify/cookie' + +declare module 'fastify' { + interface Session { + user: Auth + } +} + +/** + * This plugins enables the use of session. + * + * @see {@link https://github.com/fastify/session} + */ +export default fp(async (fastify) => { + fastify.register(fastifyCookie) + fastify.register(fastifySession, { + secret: fastify.config.COOKIE_SECRET, + cookieName: fastify.config.COOKIE_NAME, + cookie: { + secure: fastify.config.COOKIE_SECURED, + httpOnly: true, + maxAge: 1800000 + } + }) +}, { + name: 'session' +}) diff --git a/src/plugins/external/static.ts b/src/plugins/external/static.ts new file mode 100644 index 0000000..06ba21f --- /dev/null +++ b/src/plugins/external/static.ts @@ -0,0 +1,28 @@ +import fastifyStatic, { FastifyStaticOptions } from '@fastify/static' +import { FastifyInstance } from 'fastify' +import fs from 'fs' +import path from 'path' + +export const autoConfig = (fastify: FastifyInstance): FastifyStaticOptions => { + const dirPath = path.join(import.meta.dirname, '../../..', fastify.config.UPLOAD_DIRNAME) + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath) + } + + const dirTasksPath = path.join(dirPath, fastify.config.UPLOAD_TASKS_DIRNAME) + if (!fs.existsSync(dirTasksPath)) { + fs.mkdirSync(dirTasksPath) + } + + return { + root: path.join(import.meta.dirname, '../../..'), + prefix: `/${fastify.config.UPLOAD_DIRNAME}` + } +} + +/** + * This plugins allows to serve static files as fast as possible. + * + * @see {@link https://github.com/fastify/fastify-static} + */ +export default fastifyStatic diff --git a/src/plugins/external/under-pressure.ts b/src/plugins/external/under-pressure.ts index 103a613..5a10132 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 292f719..5225cff 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( @@ -12,7 +12,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { body: CredentialsSchema, response: { 200: Type.Object({ - token: Type.String() + success: Type.Boolean(), + message: Type.Optional(Type.String()) }), 401: Type.Object({ message: Type.String() @@ -24,23 +25,41 @@ 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 } - }) + return fastify.knex.transaction(async (trx) => { + const user = await trx('users') + .select('username', 'password') + .where({ username }) + .first() + + if (user) { + const isPasswordValid = await fastify.compare( + password, + user.password + ) + if (isPasswordValid) { + const roles = await trx<{ name: string }>('roles') + .select('roles.name') + .join('user_roles', 'roles.id', '=', 'user_roles.role_id') + .join('users', 'user_roles.user_id', '=', 'users.id') + .where('users.username', username) + + request.session.user = { + username, + roles: roles.map((role) => role.name) + } - if (user) { - const isPasswordValid = await fastify.compare(password, user.password) - if (isPasswordValid) { - const token = fastify.jwt.sign({ username: user.username }) + await request.session.save() - return { token } + return { success: true } + } } - } - reply.status(401) + reply.status(401) - return { message: 'Invalid username or password.' } + return { message: 'Invalid username or password.' } + }).catch(() => { + reply.internalServerError('Transaction failed.') + }) } ) } diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts index 08d7034..dfd49ff 100644 --- a/src/routes/api/autohooks.ts +++ b/src/routes/api/autohooks.ts @@ -1,9 +1,13 @@ 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() + fastify.addHook('onRequest', async (request, reply) => { + if (request.url.startsWith('/api/auth/login')) { + return + } + + if (!request.session.user) { + reply.unauthorized('You must be authenticated to access this route.') } }) } diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index d897d91..6192c14 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -1,10 +1,10 @@ import { FastifyInstance } from 'fastify' export default async function (fastify: FastifyInstance) { - fastify.get('/', ({ user, protocol, hostname }) => { + fastify.get('/', ({ session, protocol, hostname }) => { return { message: - `Hello ${user.username}! See documentation at ${protocol}://${hostname}/documentation` + `Hello ${session.user.username}! See documentation at ${protocol}://${hostname}/documentation` } }) } diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 4ad70d9..087a294 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -7,25 +7,57 @@ import { Task, CreateTaskSchema, UpdateTaskSchema, - TaskStatus + TaskStatusEnum, + QueryTaskPaginationSchema, + TaskPaginationResultSchema } from '../../../schemas/tasks.js' -import { FastifyReply } from 'fastify' +import path from 'node:path' +import { pipeline } from 'node:stream/promises' +import fs from 'node:fs' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( '/', { schema: { + querystring: QueryTaskPaginationSchema, response: { - 200: Type.Array(TaskSchema) + 200: TaskPaginationResultSchema }, tags: ['Tasks'] } }, - async function () { - const tasks = await fastify.repository.findMany('tasks') + async function (request) { + const q = request.query - return tasks + const offset = (q.page - 1) * q.limit + + const query = fastify + .knex('tasks') + .select('*') + .select(fastify.knex.raw('count(*) OVER() as total')) + + if (q.author_id !== undefined) { + query.where({ author_id: q.author_id }) + } + + if (q.assigned_user_id !== undefined) { + query.where({ assigned_user_id: q.assigned_user_id }) + } + + if (q.status !== undefined) { + query.where({ status: q.status }) + } + + const tasks = await query + .limit(q.limit) + .offset(offset) + .orderBy('created_at', q.order) + + return { + tasks, + total: tasks.length > 0 ? Number(tasks[0].total) : 0 + } } ) @@ -45,10 +77,10 @@ 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) + return reply.notFound('Task not found') } return task @@ -69,12 +101,12 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function (request, reply) { - const id = await fastify.repository.create('tasks', { data: { ...request.body, status: TaskStatus.New } }) + const newTask = { ...request.body, status: TaskStatusEnum.New } + const [id] = await fastify.knex('tasks').insert(newTask) + reply.code(201) - return { - id - } + return { id } } ) @@ -95,18 +127,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) + return reply.notFound('Task not found') } - const task = await fastify.repository.find('tasks', { where: { id } }) - - return task as Task + return fastify.knex('tasks').where({ id }).first() } ) @@ -122,14 +152,18 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 404: Type.Object({ message: Type.String() }) }, tags: ['Tasks'] - } + }, + preHandler: (request, reply) => request.isAdmin(reply) }, 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) + return reply.notFound('Task not found') } reply.code(204).send(null) @@ -151,32 +185,121 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 404: Type.Object({ message: Type.String() }) }, tags: ['Tasks'] - } + }, + preHandler: (request, reply) => request.isModerator(reply) }, 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) + return reply.notFound('Task not found') } - 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 return task } ) -} -function notFound (reply: FastifyReply) { - reply.code(404) - return { message: 'Task not found' } + fastify.post( + '/:id/upload', + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + consumes: ['multipart/form-data'], + response: { + 200: Type.Object({ + path: Type.String(), + message: Type.String() + }), + 404: Type.Object({ message: Type.String() }), + 400: Type.Object({ message: Type.String() }) + }, + tags: ['Tasks'] + } + }, + async function (request, reply) { + const { id } = request.params + + return fastify.knex.transaction(async (trx) => { + const task = await trx('tasks').where({ id }).first() + if (!task) { + return reply.notFound('Task not found') + } + + const file = await request.file() + if (!file) { + return reply.notFound('File not found') + } + + if (file.file.truncated) { + return reply.badRequest('File size limit exceeded') + } + + const allowedMimeTypes = ['image/jpeg', 'image/png'] + if (!allowedMimeTypes.includes(file.mimetype)) { + return reply.badRequest('Invalid file type') + } + + const filename = `${id}_${file.filename}` + const filePath = path.join( + import.meta.dirname, + '../../../..', + fastify.config.UPLOAD_DIRNAME, + fastify.config.UPLOAD_TASKS_DIRNAME, + filename + ) + + await pipeline(file.file, fs.createWriteStream(filePath)) + + await trx('tasks') + .where({ id }) + .update({ filename }) + + return { path: filePath, message: 'File uploaded successfully' } + }).catch(() => { + reply.internalServerError('Transaction failed.') + }) + } + ) + + fastify.get( + '/:filename/image', + { + schema: { + params: Type.Object({ + filename: Type.String() + }), + response: { + 200: { type: 'string', contentMediaType: 'image/*' }, + 404: Type.Object({ message: Type.String() }) + }, + tags: ['Tasks'] + } + }, + async function (request, reply) { + const { filename } = request.params + + const task = await fastify.knex('tasks').select('filename').where({ filename }).first() + if (!task) { + return reply.notFound(`No task has filename "${filename}"`) + } + + return reply.sendFile( + task.filename as string, + path.join(fastify.config.UPLOAD_DIRNAME, fastify.config.UPLOAD_TASKS_DIRNAME) + ) + } + ) } export default plugin diff --git a/src/schemas/auth.ts b/src/schemas/auth.ts index 4acdb0d..b222e90 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/schemas/tasks.ts b/src/schemas/tasks.ts index 3315f8f..ccb522d 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,6 +1,6 @@ import { Static, Type } from '@sinclair/typebox' -export const TaskStatus = { +export const TaskStatusEnum = { New: 'new', InProgress: 'in-progress', OnHold: 'on-hold', @@ -9,19 +9,30 @@ export const TaskStatus = { Archived: 'archived' } as const -export type TaskStatusType = typeof TaskStatus[keyof typeof TaskStatus] +export type TaskStatusType = typeof TaskStatusEnum[keyof typeof TaskStatusEnum] + +const TaskStatusSchema = Type.Union([ + Type.Literal('new'), + Type.Literal('in-progress'), + Type.Literal('on-hold'), + Type.Literal('completed'), + Type.Literal('canceled'), + Type.Literal('archived') +]) export const TaskSchema = Type.Object({ id: Type.Number(), name: Type.String(), author_id: Type.Number(), assigned_user_id: Type.Optional(Type.Number()), - status: Type.String(), + status: TaskStatusSchema, created_at: Type.String({ format: 'date-time' }), updated_at: Type.String({ format: 'date-time' }) }) -export interface Task extends Static {} +export interface Task extends Static { + filename?: string +} export const CreateTaskSchema = Type.Object({ name: Type.String(), @@ -33,3 +44,21 @@ export const UpdateTaskSchema = Type.Object({ name: Type.Optional(Type.String()), assigned_user_id: Type.Optional(Type.Number()) }) + +export const QueryTaskPaginationSchema = Type.Object({ + page: Type.Number({ minimum: 1, default: 1 }), + limit: Type.Number({ minimum: 1, maximum: 100, default: 10 }), + + author_id: Type.Optional(Type.Number()), + assigned_user_id: Type.Optional(Type.Number()), + status: Type.Optional(TaskStatusSchema), + order: Type.Optional(Type.Union([ + Type.Literal('asc'), + Type.Literal('desc') + ], { default: 'desc' })) +}) + +export const TaskPaginationResultSchema = Type.Object({ + total: Type.Number({ minimum: 0, default: 0 }), + tasks: Type.Array(TaskSchema) +}) diff --git a/test/app/error-handler.test.ts b/test/app/error-handler.test.ts index df3c9c9..76113d8 100644 --- a/test/app/error-handler.test.ts +++ b/test/app/error-handler.test.ts @@ -1,7 +1,7 @@ import { it } from 'node:test' import assert from 'node:assert' import fastify from 'fastify' -import serviceApp from '../../src/app.ts' +import serviceApp from '../../src/app.js' import fp from 'fastify-plugin' it('should call errorHandler', async (t) => { diff --git a/test/app/not-found-handler.test.ts b/test/app/not-found-handler.test.ts index 497c5f5..2805583 100644 --- a/test/app/not-found-handler.test.ts +++ b/test/app/not-found-handler.test.ts @@ -23,7 +23,7 @@ it('should be rate limited', async (t) => { url: '/this-route-does-not-exist' }) - assert.strictEqual(res.statusCode, 404) + assert.strictEqual(res.statusCode, 404, `Iteration ${i}`) } const res = await app.inject({ @@ -31,5 +31,5 @@ it('should be rate limited', async (t) => { url: '/this-route-does-not-exist' }) - assert.strictEqual(res.statusCode, 429) + assert.strictEqual(res.statusCode, 429, 'Expected 429') }) diff --git a/test/helper.ts b/test/helper.ts index bc935c5..3160137 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -7,7 +7,7 @@ import { options as serverOptions } from '../src/app.js' declare module 'fastify' { interface FastifyInstance { login: typeof login; - injectWithLogin: typeof injectWithLogin + injectWithLogin: typeof injectWithLogin; } } @@ -21,52 +21,66 @@ export function config () { } } -const tokens: Record = {} -// We will create different users with different roles async function login (this: FastifyInstance, username: string) { - if (tokens[username]) { - return tokens[username] - } - const res = await this.inject({ method: 'POST', url: '/api/auth/login', payload: { username, - password: 'password' + password: 'password123$' } }) - tokens[username] = JSON.parse(res.payload).token + const cookie = res.cookies.find( + (c) => c.name === this.config.COOKIE_NAME + ) - return tokens[username] + if (!cookie) { + throw new Error('Failed to retrieve session cookie.') + } + + return cookie.value } -async function injectWithLogin (this: FastifyInstance, username: string, opts: InjectOptions) { - opts.headers = { - ...opts.headers, - Authorization: `Bearer ${await this.login(username)}` +async function injectWithLogin ( + this: FastifyInstance, + username: string, + opts: InjectOptions +) { + const cookieValue = await this.login(username) + + opts.cookies = { + ...opts.cookies, + [this.config.COOKIE_NAME]: cookieValue } - return this.inject(opts) -}; + return this.inject({ + ...opts + }) +} // automatically build and tear down our instance -export async function build (t: TestContext) { +export async function build (t?: TestContext) { // you can set all the options supported by the fastify CLI command const argv = [AppPath] // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await buildApplication(argv, config(), serverOptions) as FastifyInstance + const app = (await buildApplication( + argv, + config(), + serverOptions + )) as FastifyInstance + // This is after start, so we can't decorate the instance using `.decorate` app.login = login - app.injectWithLogin = injectWithLogin - // close the app after we are done - t.after(() => app.close()) + // If we pass the test contest, it will close the app after we are done + if (t) { + t.after(() => app.close()) + } return app } diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts deleted file mode 100644 index 9d534af..0000000 --- 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/plugins/scrypt.test.ts b/test/plugins/scrypt.test.ts index ea513d8..09d0371 100644 --- a/test/plugins/scrypt.test.ts +++ b/test/plugins/scrypt.test.ts @@ -1,11 +1,12 @@ -import { test } from 'tap' +import { test } from 'node:test' import Fastify from 'fastify' import scryptPlugin from '../../src/plugins/custom/scrypt.js' +import assert from 'node:assert' test('scrypt works standalone', async t => { const app = Fastify() - t.teardown(() => app.close()) + t.after(() => app.close()) app.register(scryptPlugin) @@ -13,15 +14,15 @@ test('scrypt works standalone', async t => { const password = 'test_password' const hash = await app.hash(password) - t.type(hash, 'string') + assert.ok(typeof hash === 'string') const isValid = await app.compare(password, hash) - t.ok(isValid, 'compare should return true for correct password') + assert.ok(isValid, 'compare should return true for correct password') const isInvalid = await app.compare('wrong_password', hash) - t.notOk(isInvalid, 'compare should return false for incorrect password') + assert.ok(!isInvalid, 'compare should return false for incorrect password') - await t.rejects( + await assert.rejects( () => app.compare(password, 'malformed_hash'), 'compare should throw an error for malformed hash' ) diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts index a4341d3..03b895a 100644 --- a/test/routes/api/api.test.ts +++ b/test/routes/api/api.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert' import { build } from '../../helper.js' -test('GET /api without authorization header', async (t) => { +test('GET /api with no login', async (t) => { const app = await build(t) const res = await app.inject({ @@ -10,27 +10,11 @@ test('GET /api without authorization header', async (t) => { }) assert.deepStrictEqual(JSON.parse(res.payload), { - message: 'No Authorization was found in request.headers' + message: 'You must be authenticated to access this route.' }) }) -test('GET /api without JWT Token', async (t) => { - const app = await build(t) - - const res = await app.inject({ - method: 'GET', - url: '/api', - headers: { - Authorization: 'Bearer invalidtoken' - } - }) - - assert.deepStrictEqual(JSON.parse(res.payload), { - message: 'Authorization token is invalid: The token is malformed.' - }) -}) - -test('GET /api with JWT Token', async (t) => { +test('GET /api with cookie', async (t) => { const app = await build(t) const res = await app.injectWithLogin('basic', { diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index 5ae6652..d077bd0 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -2,6 +2,35 @@ import { test } from 'node:test' import assert from 'node:assert' import { build } from '../../../helper.js' +test('Transaction should rollback on error', async (t) => { + const app = await build(t) + + const { mock: mockCompare } = t.mock.method(app, 'compare') + mockCompare.mockImplementationOnce((value: string, hash: string) => { + throw new Error() + }) + + const { mock: mockLogError } = t.mock.method(app.log, 'error') + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + username: 'basic', + password: 'password123$' + } + }) + + assert.strictEqual(mockCompare.callCount(), 1) + + const arg = mockLogError.calls[0].arguments[0] as unknown as { + err: Error + } + + assert.strictEqual(res.statusCode, 500) + assert.deepStrictEqual(arg.err.message, 'Transaction failed.') +}) + test('POST /api/auth/login with valid credentials', async (t) => { const app = await build(t) @@ -10,12 +39,14 @@ test('POST /api/auth/login with valid credentials', async (t) => { url: '/api/auth/login', payload: { username: 'basic', - password: 'password' + password: 'password123$' } }) assert.strictEqual(res.statusCode, 200) - assert.ok(JSON.parse(res.payload).token) + assert.ok( + res.cookies.some((cookie) => cookie.name === app.config.COOKIE_NAME) + ) }) test('POST /api/auth/login with invalid credentials', async (t) => { diff --git a/test/routes/api/tasks/fixtures/one_line.csv b/test/routes/api/tasks/fixtures/one_line.csv new file mode 100644 index 0000000..0b6fa70 --- /dev/null +++ b/test/routes/api/tasks/fixtures/one_line.csv @@ -0,0 +1,2 @@ +Line +This is a very small CSV with one line. diff --git a/test/routes/api/tasks/fixtures/short-logo.png b/test/routes/api/tasks/fixtures/short-logo.png new file mode 100644 index 0000000..8041e33 Binary files /dev/null and b/test/routes/api/tasks/fixtures/short-logo.png differ diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index d4d9987..6065caf 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,25 +1,188 @@ -import { describe, it } from 'node:test' +import { after, before, describe, it } from 'node:test' import assert from 'node:assert' import { build } from '../../../helper.js' -import { Task, TaskStatus } from '../../../../src/schemas/tasks.js' +import { + Task, + TaskStatusEnum, + TaskPaginationResultSchema +} from '../../../../src/schemas/tasks.js' import { FastifyInstance } from 'fastify' +import { Static } from '@sinclair/typebox' +import fs from 'node:fs' +import path from 'node:path' +import FormData from 'form-data' +import os from 'os' + +async function createUser ( + app: FastifyInstance, + userData: Partial<{ username: string; password: string }> +) { + const [id] = await app.knex('users').insert(userData) + return id +} 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)', () => { describe('GET /api/tasks', () => { - it('should return a list of tasks', async (t) => { - const app = await build(t) + let app: FastifyInstance + let userId1: number + let userId2: number - const taskData = { - name: 'New Task', - author_id: 1, - status: TaskStatus.New - } + let firstTaskId: number + + before(async () => { + app = await build() + + userId1 = await createUser(app, { + username: 'user1', + password: 'password1' + }) + userId2 = await createUser(app, { + username: 'user2', + password: 'password2' + }) + + firstTaskId = await createTask(app, { + name: 'Task 1', + author_id: userId1, + status: TaskStatusEnum.New + }) + await createTask(app, { + name: 'Task 2', + author_id: userId1, + assigned_user_id: userId2, + status: TaskStatusEnum.InProgress + }) + await createTask(app, { + name: 'Task 3', + author_id: userId2, + status: TaskStatusEnum.Completed + }) + await createTask(app, { + name: 'Task 4', + author_id: userId1, + assigned_user_id: userId1, + status: TaskStatusEnum.OnHold + }) + + app.close() + }) + + it('should return a list of tasks with no pagination filter', async (t) => { + app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks' + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > + const firstTask = tasks.find((task) => task.id === firstTaskId) + + assert.ok(firstTask, 'Created task should be in the response') + assert.deepStrictEqual(firstTask.name, 'Task 1') + assert.strictEqual(firstTask.author_id, userId1) + assert.strictEqual(firstTask.status, TaskStatusEnum.New) + + assert.strictEqual(total, 4) + }) + + it('should paginate by page and limit', async (t) => { + app = await build(t) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { page: '2', limit: '1' } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > + + assert.strictEqual(total, 4) + assert.strictEqual(tasks.length, 1) + assert.strictEqual(tasks[0].name, 'Task 2') + assert.strictEqual(tasks[0].author_id, userId1) + assert.strictEqual(tasks[0].status, TaskStatusEnum.InProgress) + }) + + it('should filter tasks by assigned_user_id', async (t) => { + app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { assigned_user_id: userId2.toString() } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > + + assert.strictEqual(total, 1) + tasks.forEach((task) => + assert.strictEqual(task.assigned_user_id, userId2) + ) + }) + + it('should filter tasks by status', async (t) => { + app = await build(t) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { status: TaskStatusEnum.Completed } + }) + + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > + + assert.strictEqual(total, 1) + tasks.forEach((task) => + assert.strictEqual(task.status, TaskStatusEnum.Completed) + ) + }) + + it('should paginate and filter tasks by author_id and status', async (t) => { + app = await build(t) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks', + query: { + author_id: userId1.toString(), + status: TaskStatusEnum.OnHold, + page: '1', + limit: '1' + } + }) - const newTaskId = await app.repository.create('tasks', { data: taskData }) + assert.strictEqual(res.statusCode, 200) + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > + + assert.strictEqual(total, 1) + assert.strictEqual(tasks.length, 1) + assert.strictEqual(tasks[0].name, 'Task 4') + assert.strictEqual(tasks[0].author_id, userId1) + assert.strictEqual(tasks[0].status, TaskStatusEnum.OnHold) + }) + + it('should return empty array and total = 0 if no tasks', async (t) => { + app = await build(t) + + await app.knex('tasks').delete() const res = await app.injectWithLogin('basic', { method: 'GET', @@ -27,13 +190,12 @@ describe('Tasks api (logged user only)', () => { }) assert.strictEqual(res.statusCode, 200) - const tasks = JSON.parse(res.payload) as Task[] - const createdTask = tasks.find((task) => task.id === newTaskId) - assert.ok(createdTask, 'Created task should be in the response') + const { tasks, total } = JSON.parse(res.payload) as Static< + typeof TaskPaginationResultSchema + > - assert.deepStrictEqual(taskData.name, createdTask.name) - assert.strictEqual(taskData.author_id, createdTask.author_id) - assert.strictEqual(taskData.status, createdTask.status) + assert.strictEqual(total, 0) + assert.strictEqual(tasks.length, 0) }) }) @@ -44,7 +206,7 @@ describe('Tasks api (logged user only)', () => { const taskData = { name: 'Single Task', author_id: 1, - status: TaskStatus.New + status: TaskStatusEnum.New } const newTaskId = await createTask(app, taskData) @@ -91,8 +253,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) }) }) @@ -103,7 +265,7 @@ describe('Tasks api (logged user only)', () => { const taskData = { name: 'Task to Update', author_id: 1, - status: TaskStatus.New + status: TaskStatusEnum.New } const newTaskId = await createTask(app, taskData) @@ -118,8 +280,11 @@ 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 +307,34 @@ describe('Tasks api (logged user only)', () => { }) describe('DELETE /api/tasks/:id', () => { + const taskData = { + name: 'Task to Delete', + author_id: 1, + status: TaskStatusEnum.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 +349,76 @@ 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 + for (const username of ['moderator', 'admin']) { + const taskData = { + name: 'Task to Assign', + author_id: 1, + status: TaskStatusEnum.New + } + const newTaskId = await createTask(app, taskData) + + const res = await app.injectWithLogin(username, { + method: 'POST', + url: `/api/tasks/${newTaskId}/assign`, + payload: { + userId: 2 + } + }) + + assert.strictEqual(res.statusCode, 200) + + const updatedTask = await app + .knex('tasks') + .where({ id: newTaskId }) + .first() + assert.strictEqual(updatedTask?.assigned_user_id, 2) } - const newTaskId = await createTask(app, taskData) + }) - const res = await app.injectWithLogin('basic', { - method: 'POST', - url: `/api/tasks/${newTaskId}/assign`, - payload: { - userId: 2 + it('should unassign a task from a user and persist the changes', async (t) => { + const app = await build(t) + + for (const username of ['moderator', 'admin']) { + const taskData = { + name: 'Task to Unassign', + author_id: 1, + assigned_user_id: 2, + status: TaskStatusEnum.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: {} + }) + + 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, null) + } }) - it('should unassign a task from a user and persist the changes', async (t) => { + it('should return 403 if not a moderator', 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) - 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: { @@ -241,4 +431,208 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(payload.message, 'Task not found') }) }) + + describe('Task image upload and retrieval', () => { + let app: FastifyInstance + let taskId: number + const filename = 'short-logo.png' + const fixturesDir = path.join(import.meta.dirname, './fixtures') + const testImagePath = path.join(fixturesDir, filename) + const testCsvPath = path.join(fixturesDir, 'one_line.csv') + let uploadDir: string + let uploadDirTask: string + + before(async () => { + app = await build() + uploadDir = path.join(import.meta.dirname, '../../../../', app.config.UPLOAD_DIRNAME) + uploadDirTask = path.join(uploadDir, app.config.UPLOAD_TASKS_DIRNAME) + assert.ok(fs.existsSync(uploadDir)) + + taskId = await createTask(app, { + name: 'Task with image', + author_id: 1, + status: TaskStatusEnum.New + }) + + app.close() + }) + + after(() => { + const files = fs.readdirSync(uploadDirTask) + files.forEach((file) => { + const filePath = path.join(uploadDirTask, file) + fs.rmSync(filePath, { recursive: true }) + }) + }) + + it('should create upload directories at boot if not exist', async (t) => { + fs.rmSync(uploadDir, { recursive: true }) + assert.ok(!fs.existsSync(uploadDir)) + + app = await build(t) + + assert.ok(fs.existsSync(uploadDir)) + assert.ok(fs.existsSync(uploadDirTask)) + }) + + it('should upload a valid image for a task', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 200) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'File uploaded successfully') + }) + + it('should return 404 if task not found', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: '/api/tasks/100000/upload', + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 404) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'Task not found') + }) + + it('should return 404 if file not found', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: undefined, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 404) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'File not found') + }) + + it('should reject an invalid file type', async (t) => { + app = await build(t) + + const form = new FormData() + form.append('file', fs.createReadStream(testCsvPath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 400) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'Invalid file type') + }) + + it('should reject if file size exceeds limit (truncated)', async (t) => { + app = await build(t) + + const tmpDir = os.tmpdir() + const largeTestImagePath = path.join(tmpDir, 'large-test-image.jpg') + + const largeBuffer = Buffer.alloc(1024 * 1024 * 1.5, 'a') // Max file size in bytes is 1 MB + fs.writeFileSync(largeTestImagePath, largeBuffer) + + const form = new FormData() + form.append('file', fs.createReadStream(largeTestImagePath)) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 400) + + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'File size limit exceeded') + }) + + it('File upload transaction should rollback on error', async (t) => { + const app = await build(t) + + const { mock: mockPipeline } = t.mock.method(fs, 'createWriteStream') + mockPipeline.mockImplementationOnce(() => { + throw new Error() + }) + + const { mock: mockLogError } = t.mock.method(app.log, 'error') + + const form = new FormData() + form.append('file', fs.createReadStream(testImagePath)) + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: `/api/tasks/${taskId}/upload`, + payload: form, + headers: form.getHeaders() + }) + + assert.strictEqual(res.statusCode, 500) + assert.strictEqual(mockLogError.callCount(), 1) + + const arg = mockLogError.calls[0].arguments[0] as unknown as { + err: Error; + } + + assert.deepStrictEqual(arg.err.message, 'Transaction failed.') + }) + + it('should retrieve the uploaded image based on task id and filename', async (t) => { + app = await build(t) + + const taskFilename = encodeURIComponent(`${taskId}_${filename}`) + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: `/api/tasks/${taskFilename}/image` + }) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.headers['content-type'], 'image/png') + + const originalFile = fs.readFileSync(testImagePath) + + assert.deepStrictEqual(originalFile, res.rawPayload) + }) + + it('should return 404 error for non-existant filename', async (t) => { + app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks/non-existant/image' + }) + + assert.strictEqual(res.statusCode, 404) + const { message } = JSON.parse(res.payload) + assert.strictEqual(message, 'No task has filename "non-existant"') + }) + }) }) diff --git a/tsconfig.json b/tsconfig.json index 6fe21f5..1cd580c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "fastify-tsconfig", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", }, "include": ["@types", "src/**/*.ts"] }