diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd51b17 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# environment +NODE_ENV='development' + +# Encryption keys are AES256 (32 bytes) +ENCRYPTION_KEY=d3680f1c027e865e1da5c2be8b0be20c43f70a8107071e61df15cab6df4357cf + +# JWT secret +JWT_SECRET=sdfj94mfm430f72m3487rdsjiy7834n9rnf934n8r3n490fn4u83fh894hr9nf0 + +# SERVER_HOST=localhost +# SERVER_BASEPATH_API=v1/ +# TIMEZONE=Europe/Amsterdam + +FASTIFY_PORT=14000 + +# [ Database ] + +# [local] database +POSTGRES_NAME=fusero-db +POSTGRES_HOST=localhost +POSTGRES_PORT=19090 +POSTGRES_USER=admin +POSTGRES_PASSWORD=admin123 + +# [ APPS ] + + diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..2112815 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "es6": true, + "browser": true, + "node": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "off" + // , + // "lines-between-class-members": [ + // "error", + // "always", + // { "exceptAfterSingleLine": false } + // ], + // "padding-line-between-statements": [ + // "error", + // { "blankLine": "always", "prev": "const", "next": "*" } + // ] + } +} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..2ad5967 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "jsxSingleQuote": true, + "tabWidth": 2, + "useTabs": false, + "printWidth": 144, + "bracketSpacing": true +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9bd3fe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Use Node.js 18.3 as the base image +FROM node:20-slim AS build + +# Install Python and build tools for node-gyp +RUN apt-get update && \ + apt-get install -y python3 make g++ && \ + ln -s /usr/bin/python3 /usr/bin/python && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create app directory +ENV APP_DIR=/usr/src/app/ +RUN mkdir -p ${APP_DIR} + +# Install global dependencies like pm2, ts-node, and typescript as root +RUN npm install -g pm2 ts-node typescript + +# Create a non-root user and switch to it +ENV APP_USER=appuser +RUN adduser --disabled-password --gecos '' ${APP_USER} +WORKDIR ${APP_DIR} +RUN chown -R ${APP_USER}:${APP_USER} ${APP_DIR} + +# Switch to non-root user before copying files and installing dependencies +USER ${APP_USER} + +# Copy package.json and package-lock.json and install dependencies as appuser +COPY --chown=${APP_USER}:${APP_USER} package.json package-lock.json ./ +RUN npm install + +# Copy the rest of the application code with appropriate ownership +COPY --chown=${APP_USER}:${APP_USER} . . + +# Rebuild bcrypt and other native dependencies as appuser +RUN npm rebuild bcrypt --build-from-source + +# Build the application using the npm script, assuming "build:ts" is defined +RUN npm run build:ts + +# Environment variables +ENV CI=true +ENV PORT=14000 +ENV NODE_ENV=production + +# Expose the application's port +EXPOSE ${PORT} + +# Command to run the application using npm start +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ef0928 --- /dev/null +++ b/README.md @@ -0,0 +1,346 @@ +# Fusero Boilerplate App + +This is a Fastify + MikroORM + PostgreSQL boilerplate, designed to run in Docker for local development. +It is set up to run alongside other projects without port or database conflicts. + +--- + +## 1. Prerequisites + +- [Node.js](https://nodejs.org/) (v18+ recommended) +- [Docker](https://www.docker.com/get-started) +- [npm](https://www.npmjs.com/) + +--- + +## 2. Clone the Repo + +```bash +git clone +cd fusero-app-boilerplate +``` + +--- + +## 3. Setup the `.env` File + +Copy `.env.example` to `.env` (or create `.env` if not present): + +```env +# Database connection for Docker +POSTGRES_NAME=fusero-boilerplate-db +POSTGRES_HOSTNAME=localhost +POSTGRES_PORT=19095 +POSTGRES_USER=root +POSTGRES_PASSWORD=root123 + +# Test Database connection +POSTGRES_TEST_NAME=test-db +POSTGRES_TEST_PORT=19096 + +# Default admin user for seeding +DEFAULT_ADMIN_USERNAME=darren +DEFAULT_ADMIN_EMAIL=darren@fusero.nl +DEFAULT_ADMIN_PASSWORD=admin123 + +# JWT secret +JWT_SECRET=your_jwt_secret_here +``` + +--- + +## 4. Start the Database (Docker) + +```bash +docker-compose -f docker-compose.dev.yml up -d +``` + +This will start two Postgres instances: +- Main database on port 19095 +- Test database on port 19096 + +Both with dedicated Docker volumes. + +--- + +## 5. Install Dependencies + +```bash +npm install +``` + +--- + +## 6. Run Migrations + +```bash +npm run migration:create # Create a new migration +npm run migration:up # Run migrations +``` + +This will create all tables in the database. + +--- + +## 7. Seed the Database + +```bash +npm run seed +``` + +This will create the default admin user and roles as specified in your `.env`. + +--- + +## 8. Start the App + +```bash +npm run dev +``` + +The app will be available at [http://localhost:14000](http://localhost:14000). + +--- + +## 9. API Endpoints + +### Authentication + +#### Login +```bash +curl -X POST http://localhost:14000/api/v1/auth/login \ +-H "Content-Type: application/json" \ +-d '{ + "username": "darren", + "password": "admin123" +}' +``` + +Response: +```json +{ + "success": true, + "message": "Authentication successful", + "data": { + "token": "your.jwt.token", + "user": { + "id": 1, + "username": "darren", + "email": "darren@fusero.nl", + "roles": ["admin"] + } + } +} +``` + +### User Management + +#### Create User +```bash +curl -X POST http://localhost:14000/api/v1/app/users \ +-H "Content-Type: application/json" \ +-H "Authorization: Bearer your.jwt.token" \ +-d '{ + "username": "newuser", + "password": "userpass123", + "email": "user@example.com", + "roleName": "user" +}' +``` + +Response: +```json +{ + "success": true, + "message": "User created successfully", + "data": { + "id": 2, + "username": "newuser", + "email": "user@example.com", + "roles": ["user"] + } +} +``` + +#### Get All Users (Requires admin role) +```bash +curl -X GET http://localhost:14000/api/v1/app/users \ +-H "Authorization: Bearer your.jwt.token" +``` + +#### Get User by ID +```bash +curl -X GET http://localhost:14000/api/v1/app/users/1 \ +-H "Authorization: Bearer your.jwt.token" +``` + +### Canvas Dummy Grades API + +The canvas API provides endpoints for managing dummy grades. All endpoints are prefixed with `/api/v1/canvas-api/`. + +#### Get All Dummy Grades +```bash +GET /api/v1/canvas-api/ +``` + +Response: +```json +{ + "success": true, + "message": "Dummy grades retrieved successfully", + "data": { + "grades": [ + { + "id": 1, + "student_id": 101, + "course_id": 1, + "assignment_id": 1, + "score": 85, + "grade": "B", + "submitted_at": "2024-03-15T10:00:00Z", + "graded_at": "2024-03-16T14:30:00Z" + } + ] + } +} +``` + +#### Add a New Dummy Grade +```bash +POST /api/v1/canvas-api/add +Content-Type: application/json + +{ + "student_id": 123, + "course_id": 456, + "assignment_id": 789, + "score": 85 +} +``` + +Response: +```json +{ + "success": true, + "message": "Dummy grade added successfully", + "data": { + "id": 3, + "student_id": 123, + "course_id": 456, + "assignment_id": 789, + "score": 85, + "grade": "B", + "submitted_at": "2024-03-15T10:00:00Z", + "graded_at": "2024-03-16T14:30:00Z" + } +} +``` + +#### Update a Dummy Grade +```bash +PUT /api/v1/canvas-api/update +Content-Type: application/json + +{ + "id": 1, + "student_id": 123, + "course_id": 456, + "assignment_id": 789, + "score": 90 +} +``` + +Response: +```json +{ + "success": true, + "message": "Dummy grade updated successfully", + "data": { + "id": 1, + "student_id": 123, + "course_id": 456, + "assignment_id": 789, + "score": 90, + "grade": "A", + "submitted_at": "2024-03-15T10:00:00Z", + "graded_at": "2024-03-16T14:30:00Z" + } +} +``` + +#### Delete a Dummy Grade +```bash +DELETE /api/v1/canvas-api/delete +Content-Type: application/json + +{ + "id": 1 +} +``` + +Response: +```json +{ + "success": true, + "message": "Dummy grade deleted successfully" +} +``` + +--- + +## 10. Testing + +Run tests with: +```bash +npm test # Run all tests +npm run test:watch # Run tests in watch mode +npm run test:coverage # Run tests with coverage +``` + +The test database is automatically used for running tests. + +--- + +## 11. Authentication & Authorization + +- All endpoints except login require a valid JWT token +- The JWT token should be included in the Authorization header as: `Bearer your.jwt.token` +- Some endpoints require specific roles (admin/user) +- JWT tokens expire after 1 hour + +--- + +## 12. Troubleshooting + +### Common Issues + +1. **Database Connection Issues** + - Ensure Docker is running + - Check if the Postgres containers are up: `docker ps` + - Verify database credentials in `.env` + +2. **Authentication Issues** + - Ensure JWT_SECRET is set in `.env` + - Check if the user exists in the database + - Verify the password is correct + +3. **Role-based Access Issues** + - Ensure the user has the required role + - Check if the JWT token includes the correct roles + - Verify the token hasn't expired + +### Logs + +- Application logs can be viewed in the terminal where `npm run dev` was executed +- Database logs can be viewed using: `docker logs fusero-boilerplate-db` + +--- + +## 13. Notes + +- The app uses separate databases, ports, and Docker volumes from any other Fusero projects, so it can run in parallel. +- The default admin user is created by the seed script and can be changed via `.env`. +- For production, use `docker-compose.yml` and adjust ports/credentials as needed. +- The app includes TypeScript, ESLint, and Prettier for code quality. + +--- \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ac1482d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,36 @@ +services: + fusero-boilerplate-db: + image: postgres:15 + env_file: .env + restart: always + volumes: + - fusero_boilerplate_pgdata:/var/lib/postgresql/data + ports: + - '19095:5432' # New port + container_name: fusero-boilerplate-db + networks: + - fusero-boilerplate-network + + fusero-boilerplate-test-db: + image: postgres:15 + env_file: .env + restart: always + volumes: + - fusero_boilerplate_test_pgdata:/var/lib/postgresql/data + ports: + - '19096:5432' # New test port + container_name: fusero-boilerplate-test-db + networks: + - fusero-boilerplate-network + environment: + - POSTGRES_DB=test-db + +volumes: + fusero_boilerplate_pgdata: + external: true + fusero_boilerplate_test_pgdata: + external: false + +networks: + fusero-boilerplate-network: + name: fusero-boilerplate-network diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..823ac92 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ + +services: + fusero-frontend: + container_name: fusero-frontend + env_file: ../fusero-frontend/.env + build: + context: ../fusero-frontend + dockerfile: Dockerfile.dev + ports: + - '3000:80' + networks: + - fusero-network + + fusero-app-boilerplate: + environment: + - POSTGRES_HOST=fusero-app-db + build: + context: . + dockerfile: Dockerfile + env_file: .env + restart: always + ports: + - '5000:14000' + depends_on: + - fusero-app-db + container_name: fusero-app-boilerplate + networks: + - fusero-network + + fusero-app-db: + image: postgres:15 + env_file: .env + restart: always + volumes: + - fusero_app_pgdata:/var/lib/postgresql/data + ports: + - '19090:5432' + container_name: fusero-app-db + networks: + - fusero-network + + fusero-app-test-db: + image: postgres:15 + env_file: .env + restart: always + volumes: + - fusero_app_test_pgdata:/var/lib/postgresql/data + ports: + - '19091:5432' + container_name: fusero-app-test-db + networks: + - fusero-network + environment: + - POSTGRES_DB=test-db + + # ngrok: + # image: ngrok/ngrok:latest + # restart: unless-stopped + # command: + # - 'start' + # - '--all' + # - '--config' + # - '/etc/ngrok.yml' + # volumes: + # - ./ngrok.yml:/etc/ngrok.yml + # ports: + # - 19095:4040 + # networks: + # - fusero-network + + # fusero-redis: + # image: redis:7-alpine + # restart: always + # ports: + # - '6379:6379' + # volumes: + # - redis_data:/data + # container_name: fusero-redis + # networks: + # - fusero-network + +volumes: + redis_data: + fusero_app_pgdata: + external: true + fusero_app_test_pgdata: + external: false + +networks: + fusero-network: + name: fusero-network diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6c905f4 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + setupFiles: ['/src/tests/setup.ts'], + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json' + } + } +}; \ No newline at end of file diff --git a/mikro-orm.config.ts b/mikro-orm.config.ts new file mode 100644 index 0000000..74ede50 --- /dev/null +++ b/mikro-orm.config.ts @@ -0,0 +1,45 @@ +// import 'dotenv/config'; + + +import dotenv from 'dotenv'; + +// Force reload `.env` even if it was previously loaded +dotenv.config({ override: true }); +import { Options } from '@mikro-orm/core'; +import { PostgreSqlDriver } from '@mikro-orm/postgresql'; +import { Migrator } from '@mikro-orm/migrations'; + +const isProduction = process.env.NODE_ENV === 'production'; + +const config: Options = { + driver: PostgreSqlDriver, + entities: ['./dist/src/apps/_app/entities/**/*.js'], + entitiesTs: ['./src/apps/_app/entities/**/*.ts'], + extensions: [Migrator], + baseDir: process.cwd(), + discovery: { + warnWhenNoEntities: true, + disableDynamicFileAccess: false, + }, + dbName: process.env.POSTGRES_NAME, + host: process.env.POSTGRES_HOSTNAME, + port: Number(process.env.POSTGRES_PORT), + // port: parseInt(process.env.POSTGRES_PORT || "5432", 10), + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + debug: !isProduction, + migrations: { + tableName: process.env.POSTGRES_NAME, + path: isProduction ? './dist/src/database/migrations' : './src/database/migrations', + glob: '!(*.d).{js,ts}', + transactional: true, + disableForeignKeys: true, + allOrNothing: true, + dropTables: true, + safe: false, + snapshot: true, + emit: 'ts', + }, +}; + +export default config; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f913dcd --- /dev/null +++ b/package.json @@ -0,0 +1,97 @@ +{ + "name": "x", + "version": "1.0.0", + "description": "This project was bootstrapped with Fastify-CLI.", + "main": "app.ts", + "directories": { + "test": "test" + }, + "_moduleAliases": { + "@": "src" + }, + "scripts": { + "fc": "fastify cli", + "test": "jest", + "test:unit": "tap test/services/**/*.ts", + "test:integration": "tap test/integration/**/*.ts", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "start": "npm run build:ts && fastify start -l info -r tsconfig-paths/register dist/src/app.js", + "prebuild": "npm run lint", + "build:ts": "rimraf dist && tsc", + "watch:ts": "tsc -w", + "dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"", + "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P -r tsconfig-paths/register dist/src/app.js", + "app:generate": "node ./utils/generate-app.js", + "lint": "eslint src/**/*.{js,jsx,ts,tsx}", + "lint:fix": "eslint src/**/*.{js,jsx,ts,tsx} --fix", + "migration:create": "npx mikro-orm migration:create", + "seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts", + "test:db": "ts-node -r tsconfig-paths/register src/database/test-connection.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@fastify/autoload": "^5.0.0", + "@fastify/cookie": "^9.3.1", + "@fastify/cors": "^9.0.1", + "@fastify/jwt": "^8.0.1", + "@fastify/postgres": "^5.2.2", + "@fastify/sensible": "^5.0.0", + "@fastify/swagger": "^9.2.0", + "@fastify/swagger-ui": "^4.2.0", + "@fastify/type-provider-typebox": "^4.1.0", + "@mikro-orm/cli": "^6.2.1", + "@mikro-orm/core": "^6.2.1", + "@mikro-orm/migrations": "^6.2.1", + "@mikro-orm/postgresql": "^6.2.1", + "@sentry/node": "^7.112.1", + "@sentry/profiling-node": "^7.112.1", + "@sinclair/typebox": "^0.32.35", + "axios": "^1.6.8", + "bcrypt": "^5.1.1", + "dotenv": "^16.4.5", + "fastify": "^4.26.2", + "fastify-cli": "^6.1.1", + "fastify-jwt": "^4.2.0", + "fastify-plugin": "^4.0.0", + "glob": "^9.3.5", + "jsonwebtoken": "^9.0.2", + "mikro-orm": "^6.2.0", + "module-alias": "^2.2.3", + "pg": "^8.11.5", + "redoc": "^2.2.0", + "reflect-metadata": "^0.2.2" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.12.7", + "@typescript-eslint/eslint-plugin": "^7.7.0", + "c8": "^9.0.0", + "concurrently": "^8.2.2", + "fastify-tsconfig": "^2.0.0", + "husky": "^9.1.6", + "jest": "^29.7.0", + "lint-staged": "^15.2.10", + "pino-pretty": "^11.3.0", + "rimraf": "^5.0.10", + "tap": "^21.0.1", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "eslint --fix", + "git add" + ] + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..78f9e61 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,121 @@ +import { join } from 'path'; +import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; +import AutoLoad from '@fastify/autoload'; +import fastifyCors from '@fastify/cors'; +import { MikroORM, RequestContext } from '@mikro-orm/core'; +import mikroOrmConfig from '../mikro-orm.config'; +import fjwt from '@fastify/jwt'; +import fCookie from '@fastify/cookie'; +import router from './router'; +import fs from 'fs'; +import path from 'path'; +import fastify from 'fastify'; + +// Helper function to generate the directory tree in HTML +function generateDirectoryTree(dirPath: string): string { + const items = fs.readdirSync(dirPath); + let html = '
    '; + + items.forEach((item) => { + const itemPath = path.join(dirPath, item); + const isDirectory = fs.statSync(itemPath).isDirectory(); + html += `
  • ${isDirectory ? `${item}` : item}`; + + if (isDirectory) { + html += generateDirectoryTree(itemPath); // Recursive call for directories + } + + html += '
  • '; + }); + + html += '
'; + return html; +} + +// HTML wrapper function +function generateHtmlPage(directoryHtml: string): string { + return ` + + + + + + Directory Structure + + + +

Directory Structure

+ ${directoryHtml} + + + + `; +} + +// Fastify app setup +const app: FastifyPluginAsync = async (app, opts): Promise => { + console.log("DB CONFIG: ", { + dbName: process.env.POSTGRES_NAME, + host: process.env.POSTGRES_HOSTNAME, + port: process.env.POSTGRES_PORT, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + }); + + const orm = await MikroORM.init({ ...mikroOrmConfig }); + + // Register CORS, JWT, and Cookies + app.register(fastifyCors, { origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE'] }); + app.register(fjwt, { secret: process.env.JWT_SECRET || 'your-secret-here' }); + app.register(fCookie); + + // Database and request context + app.decorate('orm', orm); + app.decorateRequest('em', null); + app.addHook('onRequest', (request, reply, done) => { + request.em = orm.em.fork(); + RequestContext.create(request.em, done); + }); + app.addHook('onClose', async () => { + await orm.close(); + }); + + // Load plugins + void app.register(AutoLoad, { + dir: join(__dirname, 'plugins'), + options: opts, + }); + + // Directory structure route under `/system/files` + app.get('/system/files', async (request: FastifyRequest, reply: FastifyReply) => { + const directoryHtml = generateDirectoryTree(path.join(__dirname, '../')); // Adjust path as needed + const htmlContent = generateHtmlPage(directoryHtml); + + reply.type('text/html').send(htmlContent); + }); + + // API routes + void app.register(router, { prefix: '/api/v1' }); +}; + +export async function buildApp(): Promise { + const server = fastify(); + await server.register(app); + return server; +} + +export default app; diff --git a/src/apps/_app/entities/_BaseEntity.ts b/src/apps/_app/entities/_BaseEntity.ts new file mode 100644 index 0000000..ded3da0 --- /dev/null +++ b/src/apps/_app/entities/_BaseEntity.ts @@ -0,0 +1,28 @@ +import { + PrimaryKey, + Property, + BaseEntity as MikroOrmBaseEntity, + BeforeCreate, + BeforeUpdate, +} from '@mikro-orm/core'; + +export abstract class BaseEntity extends MikroOrmBaseEntity { + @PrimaryKey() + id!: number; + + @Property({ hidden: true }) + createdAt: Date = new Date(); + + @Property({ hidden: true }) + updatedAt: Date = new Date(); + + @BeforeCreate() + setCreationDate() { + this.createdAt = new Date(); + } + + @BeforeUpdate() + setUpdateDate() { + this.updatedAt = new Date(); + } +} diff --git a/src/apps/_app/entities/apikey/_APIKey.ts b/src/apps/_app/entities/apikey/_APIKey.ts new file mode 100644 index 0000000..797cf77 --- /dev/null +++ b/src/apps/_app/entities/apikey/_APIKey.ts @@ -0,0 +1,12 @@ +import { Entity, Property, ManyToOne } from '@mikro-orm/core'; +import { BaseEntity } from '../_BaseEntity'; +import { User } from '../user/_User'; + +@Entity() +export class APIKey extends BaseEntity { + @Property() + key!: string; + + @ManyToOne(() => User) + user!: User; +} diff --git a/src/apps/_app/entities/app/_App.ts b/src/apps/_app/entities/app/_App.ts new file mode 100644 index 0000000..61f6fee --- /dev/null +++ b/src/apps/_app/entities/app/_App.ts @@ -0,0 +1,12 @@ +import { Entity, Property, OneToMany, Collection } from '@mikro-orm/core'; +import { BaseEntity } from '../_BaseEntity'; +import { TenantApp } from '../tenant/TenantApps'; + +@Entity() +export class App extends BaseEntity { + @Property() + name!: string; + + @OneToMany(() => TenantApp, (tenantApp) => tenantApp.app) + tenantApps = new Collection(this); +} diff --git a/src/apps/_app/entities/credentials/_Credential.ts b/src/apps/_app/entities/credentials/_Credential.ts new file mode 100644 index 0000000..eabd44b --- /dev/null +++ b/src/apps/_app/entities/credentials/_Credential.ts @@ -0,0 +1,45 @@ +import { Entity, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../_BaseEntity'; +import { User } from '../user/_User'; +import { FastifyInstance } from 'fastify'; +import { App } from '../app/_App'; + +interface CryptoMethods { + encrypt(text: string): string; + decrypt(text: string): string; +} + +declare module 'fastify' { + interface FastifyInstance extends CryptoMethods {} +} + +@Entity() +export class Credential extends BaseEntity { + @ManyToOne(() => User) + user!: User; + + @ManyToOne(() => App) + app!: App; + + @Property({ columnType: 'text', nullable: true }) + encryptedCredentials?: string; + + getDecryptedCredentials(): string | undefined { + if (!this.encryptedCredentials) return undefined; + return this.decrypt(this.encryptedCredentials); + } + + setEncryptedCredentials(value: string): void { + this.encryptedCredentials = this.encrypt(value); + } + + private encrypt(text: string): string { + return (this.constructor as typeof Credential).fastifyInstance.encrypt(text); + } + + private decrypt(text: string): string { + return (this.constructor as typeof Credential).fastifyInstance.decrypt(text); + } + + static fastifyInstance: FastifyInstance; +} diff --git a/src/apps/_app/entities/tenant/TenantApps.ts b/src/apps/_app/entities/tenant/TenantApps.ts new file mode 100644 index 0000000..6f5d8d8 --- /dev/null +++ b/src/apps/_app/entities/tenant/TenantApps.ts @@ -0,0 +1,17 @@ +import { Entity, ManyToOne } from '@mikro-orm/core'; +import { BaseEntity } from '../_BaseEntity'; +import { Tenant } from './_Tenant'; +import { App } from '../app/_App'; +import { User } from '../user/_User'; + +@Entity() +export class TenantApp extends BaseEntity { + @ManyToOne(() => Tenant) + tenant!: Tenant; + + @ManyToOne(() => App) + app!: App; + + @ManyToOne(() => User) + user!: User; +} diff --git a/src/apps/_app/entities/tenant/_Tenant.ts b/src/apps/_app/entities/tenant/_Tenant.ts new file mode 100644 index 0000000..ae64353 --- /dev/null +++ b/src/apps/_app/entities/tenant/_Tenant.ts @@ -0,0 +1,12 @@ +import { Entity, Property, OneToMany, Collection } from '@mikro-orm/core'; +import { BaseEntity } from '../_BaseEntity'; +import { TenantApp } from './TenantApps'; + +@Entity() +export class Tenant extends BaseEntity { + @Property() + name!: string; + + @OneToMany(() => TenantApp, (tenantApp) => tenantApp.tenant) + tenantApps = new Collection(this); +} diff --git a/src/apps/_app/entities/user/UserRole.ts b/src/apps/_app/entities/user/UserRole.ts new file mode 100644 index 0000000..a28f27d --- /dev/null +++ b/src/apps/_app/entities/user/UserRole.ts @@ -0,0 +1,14 @@ +import { Entity, Property, ManyToMany, Collection } from '@mikro-orm/core'; +import { BaseEntity } from '../_BaseEntity'; +import { User } from './_User'; + +import type { UserRoleType } from '@/constants/roles'; + +@Entity() +export class UserRole extends BaseEntity { + @Property() + name!: UserRoleType; + + @ManyToMany(() => User, (user) => user.roles) + users = new Collection(this); +} diff --git a/src/apps/_app/entities/user/_User.ts b/src/apps/_app/entities/user/_User.ts new file mode 100644 index 0000000..75abc06 --- /dev/null +++ b/src/apps/_app/entities/user/_User.ts @@ -0,0 +1,46 @@ +// User.ts +import { + Entity, + Property, + ManyToMany, + Collection, + OneToMany, + BeforeCreate, + BeforeUpdate, + Unique, +} from '@mikro-orm/core'; +import bcrypt from 'bcrypt'; +import { BaseEntity } from '../_BaseEntity'; +import { TenantApp } from '../tenant/TenantApps'; +import { UserRole } from './UserRole'; +import { Credential } from '../credentials/_Credential'; + +@Entity() +export class User extends BaseEntity { + @Property() + @Unique() + username!: string; + + @Property({ hidden: true }) + password!: string; + + @Property() + email!: string; + + @ManyToMany(() => UserRole, (role) => role.users, { owner: true }) + roles = new Collection(this); + + @OneToMany(() => TenantApp, (tenantApp) => tenantApp.user) + tenantApps = new Collection(this); + + @OneToMany(() => Credential, (credential) => credential.user) + credentials = new Collection(this); + + @BeforeCreate() + @BeforeUpdate() + async hashPassword() { + if (this.password && !this.password.startsWith('$2b$')) { + this.password = await bcrypt.hash(this.password, 12); + } + } +} diff --git a/src/apps/_app/http/controllers/UserController.ts b/src/apps/_app/http/controllers/UserController.ts new file mode 100644 index 0000000..1f5d9c4 --- /dev/null +++ b/src/apps/_app/http/controllers/UserController.ts @@ -0,0 +1,109 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { UserService } from '../../services/UserService'; +import { UserRoleType } from '@/constants/roles'; +import jwt from 'jsonwebtoken'; + +export type UserRequestBody = { + username: string; + password: string; + email: string; + roleName: UserRoleType; +}; +export class UserController { + constructor(private userService: UserService) {} + + async getAllUsers(req: FastifyRequest, reply: FastifyReply) { + try { + const users = await this.userService.getAllUsers(); + return reply.send(users); + } catch (error) { + console.error('Error in getAllUsers:', error); + return reply.code(500).send(error); + } + } + + async getUserById(req: FastifyRequest<{ Params: { id: number } }>, reply: FastifyReply) { + try { + const user = await this.userService.getUserById(req.params.id); + if (user) { + return reply.send(user); + } else { + return reply.code(404).send({ message: 'User not found' }); + } + } catch (error) { + console.error('Error in getUserById:', error); + return reply.code(500).send(error); + } + } + + async createUser(req: FastifyRequest<{ Body: UserRequestBody }>, reply: FastifyReply) { + try { + const { username, password, email, roleName } = req.body as UserRequestBody; + + const existingUser = await this.userService.getUserByUsername(username); + + if (existingUser) { + return reply.code(409).send({ message: 'Username already exists' }); + } + + const user = await this.userService.createUserWithRole( + username, + password, + email, + roleName + ); + return reply.code(201).send(user); + } catch (error) { + console.error('Error in createUser:', error); + return reply.code(500).send(error); + } + } + + async loginUser( + req: FastifyRequest<{ Body: { username: string; password: string } }>, + reply: FastifyReply + ) { + try { + const { username, password } = req.body; + const user = await this.userService.authenticateUser(username, password); + + if (!user) { + return reply.code(401).send({ + success: false, + message: 'Invalid username or password' + }); + } + + const token = jwt.sign( + { + id: user.id, + username: user.username, + roles: user.roles.map((role) => role.name), + }, + process.env.JWT_SECRET as string, + { expiresIn: '1h' } + ); + + return reply.send({ + success: true, + message: 'Authentication successful', + data: { + token, + user: { + id: user.id, + username: user.username, + email: user.email, + roles: user.roles.map((role) => role.name) + } + } + }); + } catch (error) { + console.error('Error in loginUser:', error); + return reply.code(401).send({ + success: false, + message: 'Authentication failed', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } +} diff --git a/src/apps/_app/repositories/APIKeyRepository.ts b/src/apps/_app/repositories/APIKeyRepository.ts new file mode 100644 index 0000000..eef4744 --- /dev/null +++ b/src/apps/_app/repositories/APIKeyRepository.ts @@ -0,0 +1,8 @@ +import { EntityRepository } from '@mikro-orm/core'; +import { APIKey } from '@/apps/_app/entities/apikey/_APIKey'; + +export class APIKeyRepository extends EntityRepository { + async findAPIKeyByUserId(userId: number): Promise { + return this.findOne({ user: userId }); + } +} diff --git a/src/apps/_app/repositories/UserRepository.ts b/src/apps/_app/repositories/UserRepository.ts new file mode 100644 index 0000000..9fc7d78 --- /dev/null +++ b/src/apps/_app/repositories/UserRepository.ts @@ -0,0 +1,18 @@ +import { EntityRepository } from '@mikro-orm/core'; +import { User } from '@/apps/_app/entities/user/_User'; + +export class UserRepository extends EntityRepository { + async findAllUsers(): Promise { + return this.findAll({ + populate: ['roles'], + }); + } + + async findUserById(id: number): Promise { + return this.findOne({ id }, { populate: ['roles'] }); + } + + async findUserByUsername(username: string): Promise { + return this.findOne({ username }, { populate: ['roles'] }); + } +} diff --git a/src/apps/_app/repositories/UserRoleRepository.ts b/src/apps/_app/repositories/UserRoleRepository.ts new file mode 100644 index 0000000..0d820f5 --- /dev/null +++ b/src/apps/_app/repositories/UserRoleRepository.ts @@ -0,0 +1,9 @@ +import { EntityRepository } from '@mikro-orm/core'; +import { UserRole } from '@/apps/_app/entities/user/UserRole'; +import { UserRoleType } from '@/constants/roles'; + +export class UserRoleRepository extends EntityRepository { + async findRoleByName(roleName: UserRoleType): Promise { + return this.findOne({ name: roleName }); + } +} diff --git a/src/apps/_app/routes/UserRoutes.ts b/src/apps/_app/routes/UserRoutes.ts new file mode 100644 index 0000000..ce3b36a --- /dev/null +++ b/src/apps/_app/routes/UserRoutes.ts @@ -0,0 +1,61 @@ +import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; +import { UserController, UserRequestBody } from '../http/controllers/UserController'; +import { UserService } from '../services/UserService'; +import { UserRepository } from '../repositories/UserRepository'; +import { UserRoleRepository } from '../repositories/UserRoleRepository'; +import { User } from '../entities/user/_User'; +import { UserRole } from '../entities/user/UserRole'; +import auth from '../../../middleware/Auth'; + +declare module 'fastify' { + interface FastifyInstance { + userController: (request: FastifyRequest) => UserController; + } +} + +const userRoutes: FastifyPluginAsync = async (app) => { + app.decorate('userController', (request: FastifyRequest) => { + const userRepository = new UserRepository(request.em, User); + const userRoleRepository = new UserRoleRepository(request.em, UserRole); + const userService = new UserService(request.em, userRepository, userRoleRepository); + return new UserController(userService); + }); + + app.get( + '/', + { preHandler: auth(['admin', 'user']) }, + async (request: FastifyRequest, reply: FastifyReply) => { + const userController = app.userController(request); + return userController.getAllUsers(request, reply); + } + ); + + app.get( + '/:id', + async (request: FastifyRequest<{ Params: { id: number } }>, reply: FastifyReply) => { + const userController = app.userController(request); + return userController.getUserById(request, reply); + } + ); + + app.post( + '/', + async (request: FastifyRequest<{ Body: UserRequestBody }>, reply: FastifyReply) => { + const userController = app.userController(request); + return userController.createUser(request, reply); + } + ); + + app.post( + '/login', + async ( + request: FastifyRequest<{ Body: { username: string; password: string } }>, + reply: FastifyReply + ) => { + const userController = app.userController(request); + return userController.loginUser(request, reply); + } + ); +}; + +export default userRoutes; diff --git a/src/apps/_app/services/APIKeyService.ts b/src/apps/_app/services/APIKeyService.ts new file mode 100644 index 0000000..2163200 --- /dev/null +++ b/src/apps/_app/services/APIKeyService.ts @@ -0,0 +1,18 @@ +import { EntityManager } from '@mikro-orm/core'; +import { APIKeyRepository } from '../repositories/APIKeyRepository'; +import { APIKey } from '../entities/apikey/_APIKey'; + +export class APIKeyService { + private apiKeyRepository: APIKeyRepository; + private em: EntityManager; + + constructor(em: EntityManager) { + this.em = em; + this.apiKeyRepository = this.em.getRepository(APIKey); + } + + async getAPIKeyForUser(userId: number): Promise { + const apiKey = await this.apiKeyRepository.findAPIKeyByUserId(userId); + return apiKey ? apiKey.key : null; + } +} diff --git a/src/apps/_app/services/UserService.ts b/src/apps/_app/services/UserService.ts new file mode 100644 index 0000000..3b36bb8 --- /dev/null +++ b/src/apps/_app/services/UserService.ts @@ -0,0 +1,83 @@ +import bcrypt from 'bcrypt'; +import { EntityManager } from '@mikro-orm/core'; +import { User } from '../entities/user/_User'; +import { UserRepository } from '../repositories/UserRepository'; +import { UserRoleRepository } from '../repositories/UserRoleRepository'; +import { UserRoleType } from '@/constants/roles'; + +export class UserService { + constructor( + private readonly em: EntityManager, + private readonly userRepository: UserRepository, + private readonly userRoleRepository: UserRoleRepository + ) {} + + getAllUsers() { + try { + return this.userRepository.findAllUsers(); + } catch (error) { + throw new Error('Error fetching users'); + } + } + + async getUserById(id: number) { + try { + return await this.userRepository.findUserById(id); + } catch (error) { + throw new Error('Error fetching user by ID'); + } + } + + async getUserByUsername(username: string) { + try { + return await this.userRepository.findUserByUsername(username); + } catch (error) { + throw new Error('Error fetching user by username'); + } + } + + async createUserWithRole( + username: string, + password: string, + email: string, + roleName: UserRoleType + ) { + try { + const user = new User(); + user.username = username; + user.password = password; // Let the entity handle password hashing + user.email = email; + + const role = await this.userRoleRepository.findRoleByName(roleName); + if (!role) { + throw new Error(`Role '${roleName}' does not exist!`); + } + + user.roles.add(role); + await this.em.persistAndFlush(user); + + return user; + } catch (error) { + console.error('Error creating user:', error); + throw new Error('Error creating user with role'); + } + } + + async authenticateUser(username: string, password: string) { + try { + const user = await this.userRepository.findUserByUsername(username); + if (!user) { + throw new Error('User not found'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + throw new Error('Invalid password'); + } + + return user; + } catch (error) { + throw new Error('Error during authentication'); + } + } +} diff --git a/src/apps/_app/tests/auth.test.ts b/src/apps/_app/tests/auth.test.ts new file mode 100644 index 0000000..ae2c0b7 --- /dev/null +++ b/src/apps/_app/tests/auth.test.ts @@ -0,0 +1,251 @@ +import { FastifyInstance } from 'fastify'; +import { buildApp } from '../../../app'; +import { UserService } from '../services/UserService'; +import { UserRoleType } from '@/constants/roles'; +import jwt from 'jsonwebtoken'; +import { describe, beforeAll, afterAll, it, expect } from '@jest/globals'; +import { MikroORM, EntityManager, IDatabaseDriver, Connection } from '@mikro-orm/core'; + +// Extend FastifyInstance to include container +declare module 'fastify' { + interface FastifyInstance { + container: { + resolve: (name: string) => any; + }; + } +} + +describe('Authentication System Tests', () => { + let appInstance: FastifyInstance; + let userService: UserService; + let testUserToken: string; + let testAdminToken: string; + let orm: EntityManager>; + + const testUser = { + username: 'testuser', + password: 'TestPass123!', + email: 'test@example.com', + roleName: 'user' as UserRoleType + }; + + const testAdmin = { + username: 'testadmin', + password: 'AdminPass123!', + email: 'admin@example.com', + roleName: 'admin' as UserRoleType + }; + + beforeAll(async () => { + appInstance = await buildApp(); + userService = appInstance.container.resolve('userService'); + orm = appInstance.orm.em; + + // Create test users + await userService.createUserWithRole( + testUser.username, + testUser.password, + testUser.email, + testUser.roleName + ); + + await userService.createUserWithRole( + testAdmin.username, + testAdmin.password, + testAdmin.email, + testAdmin.roleName + ); + }); + + afterAll(async () => { + // Clean up test users using the entity manager + const em = orm.fork(); + const user = await em.findOne('User', { username: testUser.username }); + const admin = await em.findOne('User', { username: testAdmin.username }); + + if (user) { + await em.removeAndFlush(user); + } + + if (admin) { + await em.removeAndFlush(admin); + } + + await appInstance.close(); + }); + + describe('Login Endpoint Tests', () => { + it('should successfully login a regular user', async () => { + const response = await appInstance.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + username: testUser.username, + password: testUser.password + } + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data.success).toBe(true); + expect(data.message).toBe('Authentication successful'); + expect(data.data.token).toBeDefined(); + expect(data.data.user).toBeDefined(); + expect(data.data.user.username).toBe(testUser.username); + expect(data.data.user.roles).toContain('user'); + + testUserToken = data.data.token; + }); + + it('should successfully login an admin user', async () => { + const response = await appInstance.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + username: testAdmin.username, + password: testAdmin.password + } + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data.success).toBe(true); + expect(data.data.user.roles).toContain('admin'); + + testAdminToken = data.data.token; + }); + + it('should fail login with incorrect password', async () => { + const response = await appInstance.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + username: testUser.username, + password: 'wrongpassword' + } + }); + + expect(response.statusCode).toBe(401); + const data = JSON.parse(response.payload); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid username or password'); + }); + + it('should fail login with non-existent username', async () => { + const response = await appInstance.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + username: 'nonexistentuser', + password: 'anypassword' + } + }); + + expect(response.statusCode).toBe(401); + const data = JSON.parse(response.payload); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid username or password'); + }); + + it('should fail login with missing credentials', async () => { + const response = await appInstance.inject({ + method: 'POST', + url: '/api/auth/login', + payload: {} + }); + + expect(response.statusCode).toBe(400); + const data = JSON.parse(response.payload); + expect(data.success).toBe(false); + }); + }); + + describe('Token Validation Tests', () => { + it('should validate a valid token', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/api/auth/validate', + headers: { + Authorization: `Bearer ${testUserToken}` + } + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data.success).toBe(true); + expect(data.data.valid).toBe(true); + }); + + it('should reject an invalid token', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/api/auth/validate', + headers: { + Authorization: 'Bearer invalid.token.here' + } + }); + + expect(response.statusCode).toBe(401); + const data = JSON.parse(response.payload); + expect(data.success).toBe(false); + }); + + it('should reject an expired token', async () => { + // Create an expired token + const expiredToken = jwt.sign( + { id: 1, username: testUser.username, roles: ['user'] }, + process.env.JWT_SECRET as string, + { expiresIn: '-1h' } + ); + + const response = await appInstance.inject({ + method: 'GET', + url: '/api/auth/validate', + headers: { + Authorization: `Bearer ${expiredToken}` + } + }); + + expect(response.statusCode).toBe(401); + const data = JSON.parse(response.payload); + expect(data.success).toBe(false); + }); + + it('should reject a request without token', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/api/auth/validate' + }); + + expect(response.statusCode).toBe(401); + const data = JSON.parse(response.payload); + expect(data.success).toBe(false); + }); + }); + + describe('Role-Based Access Tests', () => { + it('should allow admin to access admin-only endpoint', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/api/admin/dashboard', + headers: { + Authorization: `Bearer ${testAdminToken}` + } + }); + + expect(response.statusCode).toBe(200); + }); + + it('should deny regular user access to admin-only endpoint', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/api/admin/dashboard', + headers: { + Authorization: `Bearer ${testUserToken}` + } + }); + + expect(response.statusCode).toBe(403); + }); + }); +}); \ No newline at end of file diff --git a/src/apps/canvas-api/CanvasClient.ts b/src/apps/canvas-api/CanvasClient.ts new file mode 100644 index 0000000..78031a3 --- /dev/null +++ b/src/apps/canvas-api/CanvasClient.ts @@ -0,0 +1,66 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; + +export class CanvasClient { + private axiosInstance: AxiosInstance; + private accessToken: string; + + constructor(accessToken: string = process.env.CANVAS_API_TOKEN || '') { + this.accessToken = accessToken; + this.axiosInstance = axios.create({ + baseURL: 'https://your-canvas-domain.com/api/v1/', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}`, + }, + }); + } + + async get(endpoint: string, params?: Record): Promise { + try { + const response: AxiosResponse = await this.axiosInstance.get(endpoint, { + params, + }); + return response.data; + } catch (error) { + this.handleError(error); + } + } + + async post(endpoint: string, data?: object): Promise { + try { + const response: AxiosResponse = await this.axiosInstance.post(endpoint, data); + return response.data; + } catch (error) { + this.handleError(error); + } + } + + async put(endpoint: string, data?: object): Promise { + try { + const response: AxiosResponse = await this.axiosInstance.put(endpoint, data); + return response.data; + } catch (error) { + this.handleError(error); + } + } + + async delete(endpoint: string, data?: object): Promise { + try { + const response: AxiosResponse = await this.axiosInstance.delete(endpoint, { + data, + }); + return response.data; + } catch (error) { + this.handleError(error); + } + } + + private handleError(error: unknown): never { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw axiosError.response.data; + } else { + throw new Error('Network error or no response from server'); + } + } +} diff --git a/src/apps/canvas-api/canvasRouter.ts b/src/apps/canvas-api/canvasRouter.ts new file mode 100644 index 0000000..afca13c --- /dev/null +++ b/src/apps/canvas-api/canvasRouter.ts @@ -0,0 +1,10 @@ +import { FastifyPluginAsync } from 'fastify'; +// import configRoutes from './routes/ConfigRoutes'; +import dummyGradesRoutes from './routes/DummyGradesRoutes'; + +const canvasRoutes: FastifyPluginAsync = async (app) => { + // app.register(configRoutes, { prefix: '/' }); + app.register(dummyGradesRoutes, { prefix: '/' }); +}; + +export default canvasRoutes; diff --git a/src/apps/canvas-api/config.ts b/src/apps/canvas-api/config.ts new file mode 100644 index 0000000..81f7400 --- /dev/null +++ b/src/apps/canvas-api/config.ts @@ -0,0 +1 @@ +// Config variables for mews app \ No newline at end of file diff --git a/src/apps/canvas-api/http/controllers/ConfigController.ts b/src/apps/canvas-api/http/controllers/ConfigController.ts new file mode 100644 index 0000000..ae830e9 --- /dev/null +++ b/src/apps/canvas-api/http/controllers/ConfigController.ts @@ -0,0 +1,21 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { ConfigService } from '../../services/_ConfigService'; + +export class ConfigController { + private configService: ConfigService; + + constructor(configService: ConfigService) { + this.configService = configService; + } + + async getConfig(req: FastifyRequest, reply: FastifyReply) { + try { + // TODO: remove userId and make a id/role checker for user + add tenant + const config = await this.configService.getConfig(); + reply.send(config); + } catch (error) { + console.log(error); + reply.code(500).send({ message: 'Internal Server Error' }); + } + } +} diff --git a/src/apps/canvas-api/http/controllers/DummyGradesController.ts b/src/apps/canvas-api/http/controllers/DummyGradesController.ts new file mode 100644 index 0000000..f5b03e1 --- /dev/null +++ b/src/apps/canvas-api/http/controllers/DummyGradesController.ts @@ -0,0 +1,58 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { DummyGradesService } from '../../services/DummyGradesService'; +import { + DummyGradeResponse, + DummyGradeCreateBody, + DummyGradeUpdateParams, + DummyGradeDeleteParams +} from '../../types/DummyGradeTypes'; + +export class DummyGradesController { + private dummyGradesService: DummyGradesService; + + constructor(dummyGradesService: DummyGradesService) { + this.dummyGradesService = dummyGradesService; + } + + async getAllDummyGrades(req: FastifyRequest, reply: FastifyReply): Promise { + try { + const response = await this.dummyGradesService.getAllDummyGrades(); + reply.send(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Internal Server Error'; + reply.status(500).send({ message: errorMessage }); + } + } + + async addDummyGrade(req: FastifyRequest<{ Body: DummyGradeCreateBody }>, reply: FastifyReply): Promise { + try { + const { body } = req; + const response = await this.dummyGradesService.addDummyGrade(body); + reply.send(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Internal Server Error'; + reply.status(500).send({ message: errorMessage }); + } + } + + async updateDummyGrade(req: FastifyRequest<{ Body: DummyGradeUpdateParams }>, reply: FastifyReply): Promise { + try { + const { body } = req; + const response = await this.dummyGradesService.updateDummyGrade(body); + reply.send(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Internal Server Error'; + reply.status(500).send({ message: errorMessage }); + } + } + + async deleteDummyGrade(req: FastifyRequest<{ Body: DummyGradeDeleteParams }>, reply: FastifyReply): Promise { + try { + await this.dummyGradesService.deleteDummyGrade(req.body); + reply.send({ success: true, message: 'Dummy grade deleted successfully' }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Internal Server Error'; + reply.status(500).send({ message: errorMessage }); + } + } +} \ No newline at end of file diff --git a/src/apps/canvas-api/http/views/bookingWidget1.html b/src/apps/canvas-api/http/views/bookingWidget1.html new file mode 100644 index 0000000..159cf8b --- /dev/null +++ b/src/apps/canvas-api/http/views/bookingWidget1.html @@ -0,0 +1,54 @@ + + + + + + + Mews Booking Engine Example + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/canvas-api/http/views/bookingWidget2 copy 2.html b/src/apps/canvas-api/http/views/bookingWidget2 copy 2.html new file mode 100644 index 0000000..358344e --- /dev/null +++ b/src/apps/canvas-api/http/views/bookingWidget2 copy 2.html @@ -0,0 +1,64 @@ + + + + + + + My page + + + +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/apps/canvas-api/http/views/bookingWidget2.html b/src/apps/canvas-api/http/views/bookingWidget2.html new file mode 100644 index 0000000..70dcd2a --- /dev/null +++ b/src/apps/canvas-api/http/views/bookingWidget2.html @@ -0,0 +1,91 @@ + + + + + + + Mews Booking Engine with Discount Code + + + + + + + + +
+ +
+ + + + + \ No newline at end of file diff --git a/src/apps/canvas-api/http/views/demo_booking_widget.html b/src/apps/canvas-api/http/views/demo_booking_widget.html new file mode 100644 index 0000000..749bad0 --- /dev/null +++ b/src/apps/canvas-api/http/views/demo_booking_widget.html @@ -0,0 +1,89 @@ + + + + + + + Mews Booking Engine with Discount Code + + + + + + + + + + + +
+ +
+ + + + + \ No newline at end of file diff --git a/src/apps/canvas-api/routes/ConfigRoutes.ts b/src/apps/canvas-api/routes/ConfigRoutes.ts new file mode 100644 index 0000000..64c9487 --- /dev/null +++ b/src/apps/canvas-api/routes/ConfigRoutes.ts @@ -0,0 +1,23 @@ +import { FastifyPluginAsync } from 'fastify'; +import { CanvasClient } from '../CanvasClient'; + +// Controllers +import { ConfigController } from '../http/controllers/ConfigController'; + +// Services +import { ConfigService } from '../services/_ConfigService'; + + +// TODO: split routes +const configRoutes: FastifyPluginAsync = async (app) => { + const canvasClient = new CanvasClient(); + + const configService = new ConfigService(canvasClient); + const configController = new ConfigController(configService); + + + app.post('/config', configController.getConfig.bind(configController)); + +}; + +export default configRoutes; diff --git a/src/apps/canvas-api/routes/DummyGradesRoutes.ts b/src/apps/canvas-api/routes/DummyGradesRoutes.ts new file mode 100644 index 0000000..4027de1 --- /dev/null +++ b/src/apps/canvas-api/routes/DummyGradesRoutes.ts @@ -0,0 +1,24 @@ +import { FastifyPluginAsync } from 'fastify'; +import { DummyGradesController } from '../http/controllers/DummyGradesController'; +import { DummyGradesService } from '../services/DummyGradesService'; +import { CanvasClient } from '../CanvasClient'; + +const dummyGradesRoutes: FastifyPluginAsync = async (app) => { + const canvasClient = new CanvasClient(); + const dummyGradesService = new DummyGradesService(canvasClient); + const dummyGradesController = new DummyGradesController(dummyGradesService); + + // Get all dummy grades + app.get('/', dummyGradesController.getAllDummyGrades.bind(dummyGradesController)); + + // Add a new dummy grade + app.post('/add', dummyGradesController.addDummyGrade.bind(dummyGradesController)); + + // Update a dummy grade + app.put('/update', dummyGradesController.updateDummyGrade.bind(dummyGradesController)); + + // Delete a dummy grade + app.delete('/delete', dummyGradesController.deleteDummyGrade.bind(dummyGradesController)); +}; + +export default dummyGradesRoutes; \ No newline at end of file diff --git a/src/apps/canvas-api/services/DummyGradesService.ts b/src/apps/canvas-api/services/DummyGradesService.ts new file mode 100644 index 0000000..274ebe3 --- /dev/null +++ b/src/apps/canvas-api/services/DummyGradesService.ts @@ -0,0 +1,96 @@ +import { CanvasClient } from '../CanvasClient'; +import { + DummyGradeResponse, + DummyGradeCreateBody, + DummyGradeUpdateParams, + DummyGradeDeleteParams +} from '@/apps/canvas-api/types/DummyGradeTypes'; + +export class DummyGradesService { + private canvas: CanvasClient; + + constructor(canvasClient: CanvasClient) { + this.canvas = canvasClient; + } + + async getAllDummyGrades(): Promise { + // Return mock data + return { + success: true, + message: 'Dummy grades retrieved successfully', + data: { + grades: [ + { + id: 1, + student_id: 101, + course_id: 1, + assignment_id: 1, + score: 85, + grade: 'B', + submitted_at: '2024-03-15T10:00:00Z', + graded_at: '2024-03-16T14:30:00Z' + }, + { + id: 2, + student_id: 102, + course_id: 1, + assignment_id: 1, + score: 92, + grade: 'A', + submitted_at: '2024-03-15T11:00:00Z', + graded_at: '2024-03-16T15:00:00Z' + } + ] + } + }; + } + + async addDummyGrade(payload: DummyGradeCreateBody): Promise { + // Return mock success response + return { + success: true, + message: 'Dummy grade added successfully', + data: { + id: 3, + student_id: payload.student_id, + course_id: payload.course_id, + assignment_id: payload.assignment_id, + score: payload.score, + grade: this.calculateGrade(payload.score), + submitted_at: new Date().toISOString(), + graded_at: new Date().toISOString() + } + }; + } + + async updateDummyGrade(payload: DummyGradeUpdateParams): Promise { + // Return mock success response + return { + success: true, + message: 'Dummy grade updated successfully', + data: { + id: payload.id, + student_id: payload.student_id, + course_id: payload.course_id, + assignment_id: payload.assignment_id, + score: payload.score, + grade: this.calculateGrade(payload.score), + submitted_at: payload.submitted_at || new Date().toISOString(), + graded_at: new Date().toISOString() + } + }; + } + + async deleteDummyGrade(payload: DummyGradeDeleteParams): Promise { + // Return mock success response + return; + } + + private calculateGrade(score: number): string { + if (score >= 90) return 'A'; + if (score >= 80) return 'B'; + if (score >= 70) return 'C'; + if (score >= 60) return 'D'; + return 'F'; + } +} \ No newline at end of file diff --git a/src/apps/canvas-api/services/_ConfigService.ts b/src/apps/canvas-api/services/_ConfigService.ts new file mode 100644 index 0000000..7c41553 --- /dev/null +++ b/src/apps/canvas-api/services/_ConfigService.ts @@ -0,0 +1,22 @@ +import { CanvasClient } from '../CanvasClient'; + +export class ConfigService { + private canvas: CanvasClient; + + constructor(canvasClient: CanvasClient) { + this.canvas = canvasClient; + } + + async getConfig(): Promise { + return this.canvas.get('configuration/get'); + } + + async validateVoucher(payload: object): Promise { + // const payload = { + // HotelId: hotelId, + // VoucherCode: voucherCode, + // }; + + return this.canvas.post('vouchers/validate', payload); + } +} diff --git a/src/apps/canvas-api/types/DummyGradeTypes.ts b/src/apps/canvas-api/types/DummyGradeTypes.ts new file mode 100644 index 0000000..673ec5f --- /dev/null +++ b/src/apps/canvas-api/types/DummyGradeTypes.ts @@ -0,0 +1,46 @@ +export interface DummyGrade { + id: number; + student_id: number; + course_id: number; + assignment_id: number; + score: number; + grade: string; + submitted_at: string; + graded_at: string; +} + +export interface DummyGradeResponse { + success: boolean; + message: string; + data: { + grades?: DummyGrade[]; + id?: number; + student_id?: number; + course_id?: number; + assignment_id?: number; + score?: number; + grade?: string; + submitted_at?: string; + graded_at?: string; + }; +} + +export interface DummyGradeCreateBody { + student_id: number; + course_id: number; + assignment_id: number; + score: number; +} + +export interface DummyGradeUpdateParams { + id: number; + student_id: number; + course_id: number; + assignment_id: number; + score: number; + submitted_at?: string; +} + +export interface DummyGradeDeleteParams { + id: number; +} \ No newline at end of file diff --git a/src/constants/roles.ts b/src/constants/roles.ts new file mode 100644 index 0000000..d456132 --- /dev/null +++ b/src/constants/roles.ts @@ -0,0 +1,7 @@ +export const PLATFORMROLES = { + Admin: 'admin', + Manager: 'manager', + User: 'user', +} as const; + +export type UserRoleType = (typeof PLATFORMROLES)[keyof typeof PLATFORMROLES]; diff --git a/src/database/migrations/Migration20240415134130.ts b/src/database/migrations/Migration20240415134130.ts new file mode 100644 index 0000000..92af766 --- /dev/null +++ b/src/database/migrations/Migration20240415134130.ts @@ -0,0 +1,43 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240415134130 extends Migration { + + async up(): Promise { + this.addSql('create table "app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null);'); + + this.addSql('create table "tenant" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null);'); + + this.addSql('create table "user" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "username" varchar(255) not null);'); + + this.addSql('create table "tenant_app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "tenant_id" int not null, "app_id" int not null, "user_id" int not null);'); + + this.addSql('create table "apikey" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "key" varchar(255) not null, "user_id" int not null);'); + + this.addSql('alter table "tenant_app" add constraint "tenant_app_tenant_id_foreign" foreign key ("tenant_id") references "tenant" ("id") on update cascade;'); + this.addSql('alter table "tenant_app" add constraint "tenant_app_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;'); + this.addSql('alter table "tenant_app" add constraint "tenant_app_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;'); + + this.addSql('alter table "apikey" add constraint "apikey_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;'); + } + + async down(): Promise { + this.addSql('alter table "tenant_app" drop constraint "tenant_app_app_id_foreign";'); + + this.addSql('alter table "tenant_app" drop constraint "tenant_app_tenant_id_foreign";'); + + this.addSql('alter table "tenant_app" drop constraint "tenant_app_user_id_foreign";'); + + this.addSql('alter table "apikey" drop constraint "apikey_user_id_foreign";'); + + this.addSql('drop table if exists "app" cascade;'); + + this.addSql('drop table if exists "tenant" cascade;'); + + this.addSql('drop table if exists "user" cascade;'); + + this.addSql('drop table if exists "tenant_app" cascade;'); + + this.addSql('drop table if exists "apikey" cascade;'); + } + +} diff --git a/src/database/migrations/Migration20240415211308.ts b/src/database/migrations/Migration20240415211308.ts new file mode 100644 index 0000000..cd6538c --- /dev/null +++ b/src/database/migrations/Migration20240415211308.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240415211308 extends Migration { + + async up(): Promise { + this.addSql('create table "user_role" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null);'); + + this.addSql('create table "user_roles" ("user_id" int not null, "user_role_id" int not null, constraint "user_roles_pkey" primary key ("user_id", "user_role_id"));'); + + this.addSql('alter table "user_roles" add constraint "user_roles_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade on delete cascade;'); + this.addSql('alter table "user_roles" add constraint "user_roles_user_role_id_foreign" foreign key ("user_role_id") references "user_role" ("id") on update cascade on delete cascade;'); + + this.addSql('alter table "user" add column "password" varchar(255) not null, add column "email" varchar(255) not null;'); + } + + async down(): Promise { + this.addSql('alter table "user_roles" drop constraint "user_roles_user_role_id_foreign";'); + + this.addSql('drop table if exists "user_role" cascade;'); + + this.addSql('drop table if exists "user_roles" cascade;'); + + this.addSql('alter table "user" drop column "password", drop column "email";'); + } + +} diff --git a/src/database/migrations/Migration20240415223053.ts b/src/database/migrations/Migration20240415223053.ts new file mode 100644 index 0000000..a88d165 --- /dev/null +++ b/src/database/migrations/Migration20240415223053.ts @@ -0,0 +1,45 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240415223053 extends Migration { + + async up(): Promise { + this.addSql('alter table "app" alter column "created_at" type timestamptz using ("created_at"::timestamptz);'); + this.addSql('alter table "app" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);'); + + this.addSql('alter table "tenant" alter column "created_at" type timestamptz using ("created_at"::timestamptz);'); + this.addSql('alter table "tenant" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);'); + + this.addSql('alter table "user" alter column "created_at" type timestamptz using ("created_at"::timestamptz);'); + this.addSql('alter table "user" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);'); + + this.addSql('alter table "tenant_app" alter column "created_at" type timestamptz using ("created_at"::timestamptz);'); + this.addSql('alter table "tenant_app" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);'); + + this.addSql('alter table "apikey" alter column "created_at" type timestamptz using ("created_at"::timestamptz);'); + this.addSql('alter table "apikey" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);'); + + this.addSql('alter table "user_role" alter column "created_at" type timestamptz using ("created_at"::timestamptz);'); + this.addSql('alter table "user_role" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);'); + } + + async down(): Promise { + this.addSql('alter table "app" alter column "created_at" type jsonb using ("created_at"::jsonb);'); + this.addSql('alter table "app" alter column "updated_at" type jsonb using ("updated_at"::jsonb);'); + + this.addSql('alter table "tenant" alter column "created_at" type jsonb using ("created_at"::jsonb);'); + this.addSql('alter table "tenant" alter column "updated_at" type jsonb using ("updated_at"::jsonb);'); + + this.addSql('alter table "user" alter column "created_at" type jsonb using ("created_at"::jsonb);'); + this.addSql('alter table "user" alter column "updated_at" type jsonb using ("updated_at"::jsonb);'); + + this.addSql('alter table "tenant_app" alter column "created_at" type jsonb using ("created_at"::jsonb);'); + this.addSql('alter table "tenant_app" alter column "updated_at" type jsonb using ("updated_at"::jsonb);'); + + this.addSql('alter table "apikey" alter column "created_at" type jsonb using ("created_at"::jsonb);'); + this.addSql('alter table "apikey" alter column "updated_at" type jsonb using ("updated_at"::jsonb);'); + + this.addSql('alter table "user_role" alter column "created_at" type jsonb using ("created_at"::jsonb);'); + this.addSql('alter table "user_role" alter column "updated_at" type jsonb using ("updated_at"::jsonb);'); + } + +} diff --git a/src/database/migrations/Migration20240415225116.ts b/src/database/migrations/Migration20240415225116.ts new file mode 100644 index 0000000..d70b4a7 --- /dev/null +++ b/src/database/migrations/Migration20240415225116.ts @@ -0,0 +1,9 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240415225116 extends Migration { + async up(): Promise { + this.addSql( + 'alter table "user" add constraint "user_username_unique" unique ("username");' + ); + } +} diff --git a/src/database/scripts/migrate.ts b/src/database/scripts/migrate.ts new file mode 100644 index 0000000..0acd5c5 --- /dev/null +++ b/src/database/scripts/migrate.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; +import { MikroORM } from '@mikro-orm/core'; +import mikroOrmConfig from '../../../mikro-orm.config'; + +(async () => { + const orm = await MikroORM.init(mikroOrmConfig); + const migrator = orm.getMigrator(); + + await migrator.up(); + await orm.close(true); +})(); diff --git a/src/database/seeds/RoleSeed.ts b/src/database/seeds/RoleSeed.ts new file mode 100644 index 0000000..e126824 --- /dev/null +++ b/src/database/seeds/RoleSeed.ts @@ -0,0 +1,26 @@ +import { EntityManager } from '@mikro-orm/core'; +import { UserRole } from '@/apps/_app/entities/user/UserRole'; +import { PLATFORMROLES } from '@/constants/roles'; + +export class RoleSeed { + constructor(private readonly em: EntityManager) {} + + async run() { + console.log('Seeding roles...'); + + const roles = Object.values(PLATFORMROLES); + + for (const roleName of roles) { + const existingRole = await this.em.findOne(UserRole, { name: roleName }); + + if (!existingRole) { + const role = new UserRole(); + role.name = roleName; + await this.em.persistAndFlush(role); + console.log(`Created role: ${roleName}`); + } + } + + console.log('Role seeding completed!'); + } +} \ No newline at end of file diff --git a/src/database/seeds/UserSeed.ts b/src/database/seeds/UserSeed.ts new file mode 100644 index 0000000..5cde531 --- /dev/null +++ b/src/database/seeds/UserSeed.ts @@ -0,0 +1,53 @@ +import { EntityManager } from '@mikro-orm/core'; +import { User } from '@/apps/_app/entities/user/_User'; +import { UserRole } from '@/apps/_app/entities/user/UserRole'; +import { PLATFORMROLES, UserRoleType } from '@/constants/roles'; +import bcrypt from 'bcrypt'; + +interface SeedUser { + username: string; + email: string; + password: string; + role: UserRoleType; +} + +export class UserSeed { + private readonly seedUsers: SeedUser[] = [ + { + username: process.env.DEFAULT_ADMIN_USERNAME || 'liquidrinu', + email: process.env.DEFAULT_ADMIN_EMAIL || 'liquidrinu@gmail.com', + password: process.env.DEFAULT_ADMIN_PASSWORD || 'admin123', + role: PLATFORMROLES.Admin as UserRoleType + } + ]; + + constructor(private readonly em: EntityManager) {} + + async run() { + console.log('Seeding users...'); + + for (const seedUser of this.seedUsers) { + const existingUser = await this.em.findOne(User, { username: seedUser.username }); + + if (!existingUser) { + const user = new User(); + user.username = seedUser.username; + user.email = seedUser.email; + user.password = await bcrypt.hash(seedUser.password, 12); + + const role = await this.em.findOne(UserRole, { name: seedUser.role }); + if (!role) { + throw new Error(`Role ${seedUser.role} not found. Please run RoleSeed first.`); + } + + user.roles.add(role); + await this.em.persistAndFlush(user); + console.log(`Created user: ${seedUser.username} with role ${seedUser.role}`); + } else { + console.log(`User ${seedUser.username} already exists, skipping...`); + } + } + + console.log('User seeding completed!'); + } +} \ No newline at end of file diff --git a/src/database/seeds/index.ts b/src/database/seeds/index.ts new file mode 100644 index 0000000..382b9db --- /dev/null +++ b/src/database/seeds/index.ts @@ -0,0 +1,29 @@ +import { EntityManager } from '@mikro-orm/core'; +import { UserSeed } from './UserSeed'; +import { RoleSeed } from './RoleSeed'; + +export class DatabaseSeeder { + constructor(private readonly em: EntityManager) {} + + async run() { + console.log('Starting database seeding...'); + + const fork = this.em.fork(); + + try { + // Seed roles first since users depend on them + const roleSeeder = new RoleSeed(fork); + await roleSeeder.run(); + + // Then seed users + const userSeeder = new UserSeed(fork); + await userSeeder.run(); + + await fork.flush(); + console.log('Database seeding completed!'); + } catch (error) { + console.error('Error during seeding:', error); + throw error; + } + } +} diff --git a/src/database/seeds/run-seed.ts b/src/database/seeds/run-seed.ts new file mode 100644 index 0000000..e20bccd --- /dev/null +++ b/src/database/seeds/run-seed.ts @@ -0,0 +1,23 @@ +import { MikroORM } from '@mikro-orm/core'; +import { DatabaseSeeder } from './index'; +import config from '../../../mikro-orm.config'; + +async function runSeeder() { + console.log('Initializing database connection...'); + const orm = await MikroORM.init(config); + + try { + const seeder = new DatabaseSeeder(orm.em); + await seeder.run(); + } catch (error) { + console.error('Error during seeding:', error); + process.exit(1); + } finally { + await orm.close(); + } +} + +runSeeder().catch(error => { + console.error('Fatal error during seeding:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/database/test-connection.ts b/src/database/test-connection.ts new file mode 100644 index 0000000..26ac19d --- /dev/null +++ b/src/database/test-connection.ts @@ -0,0 +1,24 @@ +import { MikroORM } from '@mikro-orm/core'; +import config from '../../mikro-orm.config'; + +async function testConnection() { + console.log('Initializing database connection...'); + const orm = await MikroORM.init(config); + + try { + console.log('Connection successful!'); + console.log('Database name:', orm.config.get('dbName')); + console.log('Host:', orm.config.get('host')); + console.log('Port:', orm.config.get('port')); + } catch (error) { + console.error('Connection failed:', error); + } finally { + await orm.close(); + console.log('Connection closed'); + } +} + +testConnection().catch(error => { + console.error('Test failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/middleware/Auth.ts b/src/middleware/Auth.ts new file mode 100644 index 0000000..72150c6 --- /dev/null +++ b/src/middleware/Auth.ts @@ -0,0 +1,32 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import jwt from 'jsonwebtoken'; +import { UserRoleType } from '@/constants/roles'; + +interface UserPayload extends jwt.JwtPayload { + roles: UserRoleType; +} + +const auth = (requiredRoles: UserRoleType[]) => async (request: FastifyRequest, reply: FastifyReply) => { + try { + const token = request.headers.authorization?.split(' ')[1]; + + if (!token) { + return reply.code(401).send({ error: 'Authentication failed: No token provided' }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as UserPayload; + + const hasRequiredRole = requiredRoles.some((role) => decoded.roles && decoded.roles.includes(role)); + + if (!hasRequiredRole) { + return reply.code(403).send({ error: 'Unauthorized: Insufficient role' }); + } + + request.user = decoded; + } catch (error) { + const typedError = error as Error; + return reply.code(401).send({ error: 'Authentication failed', info: typedError.message }); + } +}; + +export default auth; diff --git a/src/plugins/Crypto.ts b/src/plugins/Crypto.ts new file mode 100644 index 0000000..d4fdec7 --- /dev/null +++ b/src/plugins/Crypto.ts @@ -0,0 +1,33 @@ +// import fp from 'fastify-plugin'; +// import crypto from 'crypto'; +// import { FastifyInstance, FastifyPluginAsync } from 'fastify'; + +// const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY as string; // Ensure this is 32 bytes for AES-256 +// const IV_LENGTH = 16; // AES block size + +// const cryptoPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { +// fastify.decorate('encrypt', (text: string): string => { +// const iv = crypto.randomBytes(IV_LENGTH); +// const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv); +// let encrypted = cipher.update(text); +// encrypted = Buffer.concat([encrypted, cipher.final()]); +// return iv.toString('hex') + ':' + encrypted.toString('hex'); +// }); + +// fastify.decorate('decrypt', (text: string): string => { +// const textParts = text.split(':'); +// const iv = Buffer.from(textParts.shift()!, 'hex'); +// const encryptedText = Buffer.from(textParts.join(':'), 'hex'); +// const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv); +// let decrypted = decipher.update(encryptedText); +// decrypted = Buffer.concat([decrypted, decipher.final()]); +// return decrypted.toString(); +// }); +// }; + +// export default fp(cryptoPlugin, { +// name: 'cryptoPlugin', +// decorators: { +// fastify: ['encrypt', 'decrypt'], +// }, +// }); diff --git a/src/plugins/JWT.ts b/src/plugins/JWT.ts new file mode 100644 index 0000000..a62e83c --- /dev/null +++ b/src/plugins/JWT.ts @@ -0,0 +1,20 @@ +// import { FastifyPluginAsync } from 'fastify'; +// import fJwt, { FastifyJWTOptions } from '@fastify/jwt'; + +// const jwtPlugin: FastifyPluginAsync = async (fastify) => { +// await fastify.register(fJwt, { +// secret: process.env.JWT_SECRET || 'your-secret-here', +// }); +// }; + +// const authenticateDecorator: FastifyPluginAsync = async (fastify) => { +// fastify.decorate('authenticate', async (request, reply) => { +// try { +// await request.jwtVerify(); +// } catch (err) { +// reply.send(err); +// } +// }); +// }; + +// export { jwtPlugin, authenticateDecorator }; diff --git a/src/plugins/README.md b/src/plugins/README.md new file mode 100644 index 0000000..1e61ee5 --- /dev/null +++ b/src/plugins/README.md @@ -0,0 +1,16 @@ +# Plugins Folder + +Plugins define behavior that is common to all the routes in your +application. Authentication, caching, templates, and all the other cross +cutting concerns should be handled by plugins placed in this folder. + +Files in this folder are typically defined through the +[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module, +making them non-encapsulated. They can define decorators and set hooks +that will then be used in the rest of your application. + +Check out: + +* [The hitchhiker's guide to plugins](https://fastify.dev/docs/latest/Guides/Plugins-Guide/) +* [Fastify decorators](https://fastify.dev/docs/latest/Reference/Decorators/). +* [Fastify lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/). diff --git a/src/plugins/clients/piggy/piggyRequest.ts b/src/plugins/clients/piggy/piggyRequest.ts new file mode 100644 index 0000000..9ed3e06 --- /dev/null +++ b/src/plugins/clients/piggy/piggyRequest.ts @@ -0,0 +1,56 @@ +import { FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import axios, { AxiosRequestConfig, Method, AxiosError } from 'axios'; +import { HttpMethod } from '../../../plugins/shared/types'; + +interface ApiRequestOptions { + baseURL: string; +} + +interface ApiKey { + apikey: string; +} + +const apiRequestPlugin: FastifyPluginAsync = async (fastify, options) => { + const { baseURL } = options; + + fastify.decorate( + 'makeApiRequest', + async (method: HttpMethod, url: string, apiKey: ApiKey, body?: Record) => { + const config: AxiosRequestConfig = { + method: method as Method, + url: baseURL + url, + data: body, + headers: { Authorization: `Bearer ${apiKey.apikey}` }, + }; + + try { + const response = await axios(config); + return response.data; + } catch (err: unknown) { + const axiosError = err as AxiosError; + if (axiosError.response) { + return { + error: true, + statusCode: axiosError.response.status, + message: axiosError.response.data, + }; + } else if (axiosError.request) { + return { + error: true, + statusCode: 504, + message: 'No response from server', + }; + } else { + return { + error: true, + statusCode: 500, + message: axiosError.message || 'Server error', + }; + } + } + } + ); +}; + +export default fp(apiRequestPlugin); diff --git a/src/plugins/sensible.ts b/src/plugins/sensible.ts new file mode 100644 index 0000000..037ab95 --- /dev/null +++ b/src/plugins/sensible.ts @@ -0,0 +1,11 @@ +import fp from 'fastify-plugin'; +import sensible, { SensibleOptions } from '@fastify/sensible'; + +/** + * This plugins adds some utilities to handle http errors + * + * @see https://github.com/fastify/fastify-sensible + */ +export default fp(async (fastify) => { + fastify.register(sensible); +}); diff --git a/src/plugins/shared/types.ts b/src/plugins/shared/types.ts new file mode 100644 index 0000000..128f1c2 --- /dev/null +++ b/src/plugins/shared/types.ts @@ -0,0 +1,9 @@ +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE', + HEAD = 'HEAD', + OPTIONS = 'OPTIONS', +} diff --git a/src/plugins/support.ts b/src/plugins/support.ts new file mode 100644 index 0000000..f3d1230 --- /dev/null +++ b/src/plugins/support.ts @@ -0,0 +1,20 @@ +import fp from 'fastify-plugin' + +export interface SupportPluginOptions { + // Specify Support plugin options here +} + +// The use of fastify-plugin is required to be able +// to export the decorators to the outer scope +export default fp(async (fastify) => { + fastify.decorate('someSupport', function () { + return 'hugs' + }) +}) + +// When using .decorate you have to specify added properties for Typescript +declare module 'fastify' { + export interface FastifyInstance { + someSupport(): string; + } +} diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..df61436 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,31 @@ +import { FastifyPluginAsync } from 'fastify'; +import userRoutes from './apps/_app/routes/UserRoutes'; +import canvasRoutes from './apps/canvas-api/canvasRouter'; + +const routesPlugin: FastifyPluginAsync = async (app) => { + try { + /////////////////////////////////////// + // TODO: Define routes in separate list + await app.register(userRoutes, { prefix: '/app/users' }); + await app.register(canvasRoutes, { prefix: '/canvas-api' }); + + + app.setErrorHandler((error, request, reply) => { + if (!reply.sent) { + // Ensure it's not already sent + reply.code(500).send({ error: 'Server error' }); + } + }); + + app.setNotFoundHandler((request, reply) => { + reply.code(404).send({ message: 'Route not found' }); + }); + + // TODO: catch-all routes here or not? + } catch (error) { + app.log.error(error, 'Failed to register routes'); + throw error; + } +}; + +export default routesPlugin; diff --git a/src/shared/exceptions/BadRequest.ts b/src/shared/exceptions/BadRequest.ts new file mode 100644 index 0000000..d60479c --- /dev/null +++ b/src/shared/exceptions/BadRequest.ts @@ -0,0 +1,12 @@ +import { HttpStatusCode } from 'axios'; + +class BadRequest extends Error { + public httpErrorStatusCode = HttpStatusCode.BadRequest; + constructor(msg?: string) { + super(msg); + // Set the prototype explicitly. + Object.setPrototypeOf(this, BadRequest.prototype); + } +} + +export default BadRequest; diff --git a/src/shared/exceptions/Conflict.ts b/src/shared/exceptions/Conflict.ts new file mode 100644 index 0000000..ea7e28e --- /dev/null +++ b/src/shared/exceptions/Conflict.ts @@ -0,0 +1,15 @@ +import { HttpStatusCode } from 'axios'; + +class Conflict extends Error { + public httpErrorStatusCode = HttpStatusCode.Conflict; + public error: unknown; + //todo do this for then rest of the exceptions aswell + constructor(msg: object) { + super(JSON.stringify(msg)); + // Set the prototype explicitly. + Object.setPrototypeOf(this, Conflict.prototype); + this.error = msg; + } +} + +export default Conflict; diff --git a/src/shared/exceptions/EntityNotFound.ts b/src/shared/exceptions/EntityNotFound.ts new file mode 100644 index 0000000..923c776 --- /dev/null +++ b/src/shared/exceptions/EntityNotFound.ts @@ -0,0 +1,12 @@ +import { HttpStatusCode } from 'axios'; + +class EntityNotFound extends Error { + public httpErrorStatusCode = HttpStatusCode.NotFound; + constructor(msg?: string) { + super(msg); + // Set the prototype explicitly. + Object.setPrototypeOf(this, EntityNotFound.prototype); + } +} + +export default EntityNotFound; diff --git a/src/shared/exceptions/ErrorResponse.ts b/src/shared/exceptions/ErrorResponse.ts new file mode 100644 index 0000000..581ee6f --- /dev/null +++ b/src/shared/exceptions/ErrorResponse.ts @@ -0,0 +1,11 @@ +import { HttpStatusCode } from 'axios'; + +class ErrorResponse extends Error { + public httpErrorStatusCode = HttpStatusCode.BadRequest; + constructor(msg: string) { + super(msg); + Object.setPrototypeOf(this, ErrorResponse.prototype); + } +} + +export default ErrorResponse; diff --git a/src/shared/exceptions/Forbidden.ts b/src/shared/exceptions/Forbidden.ts new file mode 100644 index 0000000..5fae261 --- /dev/null +++ b/src/shared/exceptions/Forbidden.ts @@ -0,0 +1,12 @@ +import { HttpStatusCode } from 'axios'; + +class Forbidden extends Error { + public httpErrorStatusCode = HttpStatusCode.Forbidden; + constructor(msg: string = 'Forbidden') { + super(msg); + // Set the prototype explicitly. + Object.setPrototypeOf(this, Forbidden.prototype); + } +} + +export default Forbidden; diff --git a/src/shared/exceptions/Ok.ts b/src/shared/exceptions/Ok.ts new file mode 100644 index 0000000..b579dea --- /dev/null +++ b/src/shared/exceptions/Ok.ts @@ -0,0 +1,12 @@ +import { HttpStatusCode } from 'axios'; + +class OK extends Error { + public httpErrorStatusCode = HttpStatusCode.Ok; + constructor(msg: string) { + super(msg); + // Set the prototype explicitly. + Object.setPrototypeOf(this, OK.prototype); + } +} + +export default OK; diff --git a/src/shared/exceptions/TooManyRequests.ts b/src/shared/exceptions/TooManyRequests.ts new file mode 100644 index 0000000..617467d --- /dev/null +++ b/src/shared/exceptions/TooManyRequests.ts @@ -0,0 +1,15 @@ +// import { HttpStatusCode } from 'axios'; + +// class TooManyRequests extends Error { +// public httpErrorStatusCode = HttpStatusCode.TooManyRequests; +// constructor(msg: string, customErrorCode?: HttpStatusCode) { +// super(msg); + +// this.httpErrorStatusCode = this.httpErrorStatusCode ?? customErrorCode; + +// // Set the prototype explicitly. +// Object.setPrototypeOf(this, TooManyRequests.prototype); +// } +// } + +// export default TooManyRequests; diff --git a/src/shared/exceptions/UnableToProceed.ts b/src/shared/exceptions/UnableToProceed.ts new file mode 100644 index 0000000..e69826d --- /dev/null +++ b/src/shared/exceptions/UnableToProceed.ts @@ -0,0 +1,15 @@ +import { HttpStatusCode } from 'axios'; + +class UnableToProceed extends Error { + public httpErrorStatusCode = HttpStatusCode.BadRequest; + constructor(msg: string, customErrorCode?: typeof HttpStatusCode) { + super(msg); + + this.httpErrorStatusCode = this.httpErrorStatusCode ?? customErrorCode; + + // Set the prototype explicitly. + Object.setPrototypeOf(this, UnableToProceed.prototype); + } +} + +export default UnableToProceed; diff --git a/src/shared/exceptions/Unauthorized.ts b/src/shared/exceptions/Unauthorized.ts new file mode 100644 index 0000000..b3b56ad --- /dev/null +++ b/src/shared/exceptions/Unauthorized.ts @@ -0,0 +1,12 @@ +import { HttpStatusCode } from 'axios'; + +class Unauthorized extends Error { + public httpErrorStatusCode = HttpStatusCode.Unauthorized; + constructor(msg: string = 'Unauthorized') { + super(msg); + // Set the prototype explicitly. + Object.setPrototypeOf(this, Unauthorized.prototype); + } +} + +export default Unauthorized; diff --git a/src/shared/exceptions/ValidationError.ts b/src/shared/exceptions/ValidationError.ts new file mode 100644 index 0000000..004c6d5 --- /dev/null +++ b/src/shared/exceptions/ValidationError.ts @@ -0,0 +1,12 @@ +// import { HttpStatusCode } from 'axios'; + +// class ValidationError extends Error { +// public httpErrorStatusCode = HttpStatusCode.BadRequest; +// constructor(msg: string) { +// super(msg); +// // Set the prototype explicitly. +// Object.setPrototypeOf(this, ValidationError.prototype); +// } +// } + +// export default ValidationError; diff --git a/src/shared/interfaces/HttpResponse.ts b/src/shared/interfaces/HttpResponse.ts new file mode 100644 index 0000000..e275c2d --- /dev/null +++ b/src/shared/interfaces/HttpResponse.ts @@ -0,0 +1,39 @@ +import ErrorResponse from '../exceptions/ErrorResponse'; + +export class HttpResponse { + public url: string; + public response: responseObject; + public code: number; + public requestBody: requestBody | undefined + + constructor(response: responseObject, code: number, url: string, requestBody?: requestBody) { + this.url = url; + this.response = response; + this.code = code; + this.requestBody = requestBody; + } + + public isErrorResponseCode() { + return this.code >= 400; + } + + public handleErrorResponse() { + if (this.isErrorResponseCode()) { + throw new ErrorResponse( + `error connecting with external service, status code: ${ + this.code + }, response: ${JSON.stringify(this.response)}` + ); + } + } + + public handleEmptyResponse() { + if ( + this.response === null || + this.response === undefined || + this.response === '' + ) { + throw new ErrorResponse('empty response'); + } + } +} diff --git a/src/shared/utils/ValidationError.ts b/src/shared/utils/ValidationError.ts new file mode 100644 index 0000000..793e9de --- /dev/null +++ b/src/shared/utils/ValidationError.ts @@ -0,0 +1,9 @@ +export class ValidationError extends Error { + constructor(message: string, public details?: unknown) { + super(message); + this.name = 'ValidationError'; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ValidationError); + } + } +} diff --git a/src/tests/setup.ts b/src/tests/setup.ts new file mode 100644 index 0000000..965403a --- /dev/null +++ b/src/tests/setup.ts @@ -0,0 +1,35 @@ +import dotenv from 'dotenv'; +import { MikroORM } from '@mikro-orm/core'; +import config from '../../mikro-orm.config'; +import { DatabaseSeeder } from '../database/seeds'; + +// Load environment variables +dotenv.config({ path: '.env.test' }); + +// Set default environment variables for testing +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test_db'; + +let orm: MikroORM; + +// Global setup +beforeAll(async () => { + // Initialize the database connection + orm = await MikroORM.init(config); + + // Run migrations + const migrator = orm.getMigrator(); + await migrator.up(); + + // Seed the database + const seeder = new DatabaseSeeder(orm.em); + await seeder.run(); +}); + +// Global cleanup +afterAll(async () => { + if (orm) { + await orm.close(); + } +}); \ No newline at end of file diff --git a/src/types/fastify.types.d.ts b/src/types/fastify.types.d.ts new file mode 100644 index 0000000..8dfda6d --- /dev/null +++ b/src/types/fastify.types.d.ts @@ -0,0 +1,37 @@ +import { EntityManager } from '@mikro-orm/core'; +import { FastifyReply } from 'fastify'; +import { SignOptions, VerifyOptions } from 'jsonwebtoken'; + +declare module 'fastify' { + interface AppOptions { + jwt: { + sign: (payload: object, options?: SignOptions) => string; + verify: (token: string, options?: VerifyOptions) => object; + }; + } + + interface FastifyInstance { + user?: User; + + orm: { + em: EntityManager; + }; + jwt: { + sign: (payload: object, options?: SignOptions) => string; + verify: (token: string, options?: VerifyOptions) => object; + }; + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } + + interface FastifyRequest { + diContainer: DIContainer; + em: EntityManager; + jwtVerify: () => Promise; + // controllers + } +} +declare module 'fastify' { + interface FastifyRequest { + userDetails?: jwt.JwtPayload & { roles: string[] }; + } +} diff --git a/test/integration/user.test.ts b/test/integration/user.test.ts new file mode 100644 index 0000000..468b32b --- /dev/null +++ b/test/integration/user.test.ts @@ -0,0 +1,140 @@ +import { test } from 'tap'; +import Fastify from 'fastify'; +import app from '../../src/app'; +import { MikroORM } from '@mikro-orm/core'; +import mikroOrmConfig from '../../mikro-orm.config'; +import { User } from '@/apps/_app/entities/user/_User'; + +test('User API', async (t) => { + // Setup + const fastify = Fastify(); + await fastify.register(app); + const orm = await MikroORM.init(mikroOrmConfig); + const em = orm.em.fork(); + + // Cleanup function + const cleanup = async () => { + await fastify.close(); + await orm.close(); + }; + + try { + t.test('POST /api/v1/app/users', async (t) => { + const response = await fastify.inject({ + method: 'POST', + url: '/api/v1/app/users', + payload: { + username: 'apitestuser', + password: 'testpass123', + email: 'api@example.com', + roleName: 'user' + } + }); + + t.equal(response.statusCode, 201, 'should return 201 status code'); + const body = JSON.parse(response.payload); + t.ok(body.id, 'should return user with id'); + t.equal(body.username, 'apitestuser', 'should create user with correct username'); + + // Cleanup this test's user + const user = await em.findOne(User, { username: 'apitestuser' }); + if (user) await em.removeAndFlush(user); + }); + + t.test('POST /api/v1/app/users/login', async (t) => { + // First create a user + await fastify.inject({ + method: 'POST', + url: '/api/v1/app/users', + payload: { + username: 'logintestuser', + password: 'testpass123', + email: 'login@example.com', + roleName: 'user' + } + }); + + const response = await fastify.inject({ + method: 'POST', + url: '/api/v1/app/users/login', + payload: { + username: 'logintestuser', + password: 'testpass123' + } + }); + + t.equal(response.statusCode, 200, 'should return 200 status code'); + const body = JSON.parse(response.payload); + t.ok(body.token, 'should return JWT token'); + t.equal(body.message, 'Authentication successful', 'should return success message'); + + // Test with invalid credentials + const invalidResponse = await fastify.inject({ + method: 'POST', + url: '/api/v1/app/users/login', + payload: { + username: 'logintestuser', + password: 'wrongpassword' + } + }); + + t.equal(invalidResponse.statusCode, 401, 'should return 401 for invalid credentials'); + + // Cleanup this test's user + const user = await em.findOne(User, { username: 'logintestuser' }); + if (user) await em.removeAndFlush(user); + }); + + t.test('GET /api/v1/app/users (requires auth)', async (t) => { + // First create and login as a user + await fastify.inject({ + method: 'POST', + url: '/api/v1/app/users', + payload: { + username: 'authtestuser', + password: 'testpass123', + email: 'auth@example.com', + roleName: 'user' + } + }); + + const loginResponse = await fastify.inject({ + method: 'POST', + url: '/api/v1/app/users/login', + payload: { + username: 'authtestuser', + password: 'testpass123' + } + }); + + const { token } = JSON.parse(loginResponse.payload); + + const response = await fastify.inject({ + method: 'GET', + url: '/api/v1/app/users', + headers: { + Authorization: `Bearer ${token}` + } + }); + + t.equal(response.statusCode, 200, 'should return 200 with valid token'); + const body = JSON.parse(response.payload); + t.ok(Array.isArray(body), 'should return array of users'); + + // Test without token + const noAuthResponse = await fastify.inject({ + method: 'GET', + url: '/api/v1/app/users' + }); + + t.equal(noAuthResponse.statusCode, 401, 'should return 401 without token'); + + // Cleanup this test's user + const user = await em.findOne(User, { username: 'authtestuser' }); + if (user) await em.removeAndFlush(user); + }); + } finally { + // Ensure cleanup happens even if tests fail + await cleanup(); + } +}); \ No newline at end of file diff --git a/test/services/UserService.test.ts b/test/services/UserService.test.ts new file mode 100644 index 0000000..9ac46b0 --- /dev/null +++ b/test/services/UserService.test.ts @@ -0,0 +1,76 @@ +import { test } from 'tap'; +import { MikroORM } from '@mikro-orm/core'; +import { UserService } from '@/apps/_app/services/UserService'; +import { UserRepository } from '@/apps/_app/repositories/UserRepository'; +import { UserRoleRepository } from '@/apps/_app/repositories/UserRoleRepository'; +import { User } from '@/apps/_app/entities/user/_User'; +import { UserRole } from '@/apps/_app/entities/user/UserRole'; +import mikroOrmConfig from '../../mikro-orm.config'; + +test('UserService', async (t) => { + // Setup + const orm = await MikroORM.init(mikroOrmConfig); + const em = orm.em.fork(); + + const userRepository = new UserRepository(em, User); + const userRoleRepository = new UserRoleRepository(em, UserRole); + const userService = new UserService(em, userRepository, userRoleRepository); + + t.test('createUserWithRole', async (t) => { + const username = 'testuser'; + const password = 'testpass123'; + const email = 'test@example.com'; + const roleName = 'user'; + + const user = await userService.createUserWithRole(username, password, email, roleName); + + t.ok(user, 'should create a user'); + t.equal(user.username, username, 'should set correct username'); + t.equal(user.email, email, 'should set correct email'); + t.ok(user.roles.length > 0, 'should assign roles'); + t.equal(user.roles[0].name, roleName, 'should assign correct role'); + + // Cleanup + await em.removeAndFlush(user); + }); + + t.test('authenticateUser', async (t) => { + const username = 'authuser'; + const password = 'authpass123'; + const email = 'auth@example.com'; + const roleName = 'user'; + + // Create a user first + const user = await userService.createUserWithRole(username, password, email, roleName); + + t.test('with valid credentials', async (t) => { + const authenticatedUser = await userService.authenticateUser(username, password); + t.ok(authenticatedUser, 'should authenticate successfully'); + t.equal(authenticatedUser.username, username, 'should return correct user'); + }); + + t.test('with invalid password', async (t) => { + try { + await userService.authenticateUser(username, 'wrongpassword'); + t.fail('should throw error for invalid password'); + } catch (error) { + t.ok(error, 'should throw error'); + } + }); + + t.test('with non-existent user', async (t) => { + try { + await userService.authenticateUser('nonexistent', password); + t.fail('should throw error for non-existent user'); + } catch (error) { + t.ok(error, 'should throw error'); + } + }); + + // Cleanup + await em.removeAndFlush(user); + }); + + // Cleanup + await orm.close(); +}); \ No newline at end of file diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..2cb394f --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "baseUrl": "..", + "paths": { + "@/*": ["src/*"] + }, + "types": ["node", "tap"] + }, + "include": ["../src/**/*.ts", "./**/*.ts"], + "exclude": ["../node_modules", "../dist"] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aa3398b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": false, + "strictNullChecks": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noUnusedLocals": false, + "noImplicitAny": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "resolveJsonModule": true, + "removeComments": true, + "newLine": "lf", + "skipLibCheck": true, + "lib": ["ESNext"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}