diff --git a/.env.example b/.env.example index fef06ab3..f2e3ba2f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,21 @@ -API_PORT = 5000 +####################### +# Default config file # +####################### + +#* If a parameter is noted as a LIST, it means that you can separate +#* multiple values with a ';', DO NOT add a ';' at the end of the line, +#* only between values + +#! ALL time value are expressed in milliseconds + +API_PORT = '4000' +API_URL = 'http://localhost:4000' +DEBUG = 'true' + # Allowed URLs for CORS 'Access-Control-Allow-Origin' header -CORS_ORIGIN_WHITELIST = 'https://ae.utbm.fr;' -DEBUG = true +# -> LIST +# -> Accepted values: URLs or '*' +CORS_ORIGIN_WHITELIST = 'https://ae.utbm.fr' # Postgres configuration # You can avoid user password when developing on local machine @@ -9,38 +23,48 @@ POSTGRES_DB = 'ae_test' POSTGRES_HOST = '127.0.0.1' POSTGRES_PORT = '5432' POSTGRES_USER = 'postgres' -POSTGRES_PASSWORD = 'postgres' +POSTGRES_PASSWORD = 'postgres' # optional # JWT configuration # Note: changing the key will invalidate all existing tokens JWT_KEY = 'secret_jwt_key' -JWT_EXPIRATION_TIME = 604800000 # 1 week in milliseconds +# Expiration time in milliseconds +JWT_EXPIRATION_TIME = '604800000' # 1 week # Base directory for generic uploaded files FILES_BASE_DIR = './public' -# Users configuration +# Users files root directory +USERS_BASE_PATH = './public/users' # Delay before allowing the user to update its profile picture -USERS_PICTURES_DELAY = 604800000 # 1 week in milliseconds -USERS_PICTURES_PATH = './public/users/pictures' -USERS_BANNERS_PATH = './public/users/banners' -USERS_PATH = './public/users' +USERS_PICTURES_DELAY = '604800000' # 1 week +# Delay before deleting unverified users +USERS_VERIFICATION_DELAY = '604800000' # 1 week -# Promotion configuration -PROMOTION_LOGO_PATH = './public/promotions' +# Promotion files root directory +PROMOTION_BASE_PATH = './public/promotions' # Emailing configuration # Note: use your own UTBM email account when developing on local machine EMAIL_HOST = 'smtp.domain.com' -EMAIL_PORT = 465 -EMAIL_SECURE = true +EMAIL_PORT = '465' +EMAIL_SECURE = 'true' EMAIL_AUTH_USER = 'example@domain.com' EMAIL_AUTH_PASS = 'azertyuiop' -EMAIL_ENABLED = true +EMAIL_ENABLED = 'false' -# Whitelisted emails/host can be used to register -WHITELISTED_HOSTS = +# Whitelisted hosts can be used to register +# -> Bypass email validation +# -> LIST +WHITELISTED_HOSTS = '@gmail.com' +# Whitelisted emails can be used to register, despite their host +# being blacklisted +# -> Bypass email validation +# -> LIST WHITELISTED_EMAILS = 'ae.info@utbm.fr;ae@utbm.fr' -# Blacklisted emails/host cannot be used to register -BLACKLISTED_HOSTS = '@utbm.fr;' -BLACKLISTED_EMAILS = + +# Blacklisted hosts/emails cannot be used to register, +# unless they are whitelisted in WHITELISTED_HOSTS or WHITELISTED_EMAILS +# -> LISTs +BLACKLISTED_HOSTS = '@utbm.fr' +BLACKLISTED_EMAILS = 'example@pirate.ru' diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e5483253..e3acc1e9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -20,6 +20,7 @@ jobs: - name: Setup PostgresSQL uses: ikalnytskyi/action-setup-postgres@v4 with: + # Should match .env.example values database: ae_test port: 5432 username: postgres @@ -41,20 +42,14 @@ jobs: - name: Install dependencies run: pnpm install + - name: Setup environment variables + run: pnpm workflow:setup_env + - name: Setup database for tests run: pnpm workflow:init_db - name: Run tests run: pnpm workflow:test - env: - POSTGRES_DB: ae_test - POSTGRES_HOST: '127.0.0.1' - POSTGRES_PORT: 5432 - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - DEBUG: true - EMAIL_ENABLED: false - JWT_KEY: jwt_key - name: Export coverage results uses: actions/upload-artifact@v3 diff --git a/package.json b/package.json index f1b1dde5..7ab6ba08 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "start:prod": "pnpm db:migrate && node dist/src/main.js", "workflow:lint": "eslint \"{src,tests}/**/*.ts\" --max-warnings=0", "workflow:test": "jest --config jest.config.ts --forceExit", - "workflow:init_db": "pnpm ts-node tests/database.setup.ts" + "workflow:init_db": "pnpm ts-node tests/database.setup.ts", + "workflow:setup_env": "pnpm ts-node tests/env.setup.ts" }, "dependencies": { "@ae_utbm/typings": "file:src/exported", @@ -74,7 +75,7 @@ "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^4.2.1", - "jest": "29.6.1", + "jest": "^29.7.0", "jest-extended": "^4.0.2", "prettier": "^2.8.8", "source-map-support": "^0.5.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0360ef6a..f904639b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,11 +161,11 @@ devDependencies: specifier: ^4.2.1 version: 4.2.1(eslint-config-prettier@9.0.0)(eslint@8.51.0)(prettier@2.8.8) jest: - specifier: 29.6.1 - version: 29.6.1(@types/node@20.5.7)(ts-node@10.9.1) + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.5.7)(ts-node@10.9.1) jest-extended: specifier: ^4.0.2 - version: 4.0.2(jest@29.6.1) + version: 4.0.2(jest@29.7.0) prettier: specifier: ^2.8.8 version: 2.8.8 @@ -177,7 +177,7 @@ devDependencies: version: 6.3.3 ts-jest: specifier: 29.1.1 - version: 29.1.1(@babel/core@7.23.2)(jest@29.6.1)(typescript@5.2.2) + version: 29.1.1(@babel/core@7.23.2)(jest@29.7.0)(typescript@5.2.2) ts-loader: specifier: ^9.5.0 version: 9.5.0(typescript@5.2.2)(webpack@5.89.0) @@ -4461,7 +4461,7 @@ packages: jest-util: 29.7.0 dev: true - /jest-extended@4.0.2(jest@29.6.1): + /jest-extended@4.0.2(jest@29.7.0): resolution: {integrity: sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -4470,7 +4470,7 @@ packages: jest: optional: true dependencies: - jest: 29.6.1(@types/node@20.5.7)(ts-node@10.9.1) + jest: 29.7.0(@types/node@20.5.7)(ts-node@10.9.1) jest-diff: 29.7.0 jest-get-type: 29.6.3 dev: true @@ -4727,8 +4727,8 @@ packages: supports-color: 8.1.1 dev: true - /jest@29.6.1(@types/node@20.5.7)(ts-node@10.9.1): - resolution: {integrity: sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==} + /jest@29.7.0(@types/node@20.5.7)(ts-node@10.9.1): + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true peerDependencies: @@ -6588,7 +6588,7 @@ packages: typescript: 5.2.2 dev: true - /ts-jest@29.1.1(@babel/core@7.23.2)(jest@29.6.1)(typescript@5.2.2): + /ts-jest@29.1.1(@babel/core@7.23.2)(jest@29.7.0)(typescript@5.2.2): resolution: {integrity: sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -6612,7 +6612,7 @@ packages: '@babel/core': 7.23.2 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.6.1(@types/node@20.5.7)(ts-node@10.9.1) + jest: 29.7.0(@types/node@20.5.7)(ts-node@10.9.1) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 diff --git a/src/app.module.ts b/src/app.module.ts index 79c2a7ae..b83d5967 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,7 +2,6 @@ import { join, sep } from 'path'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { AcceptLanguageResolver, I18nModule } from 'nestjs-i18n'; @@ -18,15 +17,9 @@ import { RolesModule } from '@modules/roles/roles.module'; import { TranslateModule } from '@modules/translate/translate.module'; import { UsersModule } from '@modules/users/users.module'; -import env from './env'; - @Module({ imports: [ AuthModule, - ConfigModule.forRoot({ - isGlobal: true, - load: [env], - }), EmailsModule, FilesModule, I18nModule.forRoot({ diff --git a/src/env.ts b/src/env.ts index fd6b28d4..385f2247 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,47 +1,90 @@ /* istanbul ignore file */ -import { join } from 'path'; - -export type Config = typeof config; - -const config = () => ({ - production: process.env['DEBUG'] !== 'true', - api_url: - process.env['DEBUG'] === 'true' - ? `http://localhost:${parseInt(process.env['API_PORT'], 10) || 3000}` - : 'https://ae.utbm.fr/api', - port: parseInt(process.env['API_PORT'], 10) || 3000, - cors: process.env['DEBUG'] === 'true' ? ['*'] : process.env['CORS_ORIGIN_WHITELIST']?.split(';'), - auth: { - jwtKey: process.env['JWT_KEY'], - jwtExpirationTime: parseInt(process.env['JWT_EXPIRATION_TIME'], 10) || 60 * 60 * 24 * 7 * 1000, // 1 week - }, - files: { - baseDir: join(process.cwd(), process.env['FILES_BASE_DIR'] || './public'), - users: join(process.cwd(), process.env['USERS_PATH'] || './public/users'), - promotions: join(process.cwd(), process.env['PROMOTIONS_LOGO_PATH'] || './public/promotions'), - }, - users: { - verification_token_validity: 7, // number of days before the account being deleted - picture_cooldown: parseInt(process.env['USERS_PICTURES_DELAY'], 10) || 60 * 60 * 24 * 7 * 1000, // 1 week - }, - email: { - enabled: process.env['EMAIL_ENABLED'] === 'true', - host: process.env['EMAIL_HOST'], - port: parseInt(process.env['EMAIL_PORT'], 10) || 465, - secure: process.env['EMAIL_SECURE'] === 'true', - auth: { - user: process.env['EMAIL_AUTH_USER'], - pass: process.env['EMAIL_AUTH_PASS'], - }, - whitelist: { - hosts: process.env['WHITELISTED_HOSTS']?.split(';') ?? [], - emails: ['ae.info@utbm.fr', ...(process.env['WHITELISTED_EMAILS']?.split(';') ?? [])], - }, - blacklist: { - hosts: ['@utbm.fr', ...(process.env['BLACKLISTED_HOSTS']?.split(';') ?? [])], - emails: process.env['BLACKLISTED_EMAILS']?.split(';') ?? [], - }, - }, -}); - -export default config; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import 'dotenv/config'; + +import { Logger } from '@nestjs/common'; +import { z } from 'zod'; + +/** + * NodeJS environment variables + API specific environment variables, validated using zod + * @see https://github.com/colinhacks/zod + */ +export const env = (() => { + // Throw if the .env file is missing + if (!existsSync(join(process.cwd(), '.env'))) { + Logger.error( + "Cannot start the server, the '.env' file is missing, have you copied and edited the '.env.example' file?", + 'env.ts', + ); + process.exit(1); + } + + const refineAsEmail = (str: string) => { + str.split(';').every((s) => { + z.string().email().parse(s); + }); + return true; + }; + + const refineAsHost = (str: string) => { + str.split(';').every((s) => { + z.string().startsWith('@').parse(s); + }); + return true; + }; + + // Validate the environment variables using zod + const schema = z.object({ + API_PORT: z.coerce.number().min(0), + API_URL: z.string().url(), + + CORS_ORIGIN_WHITELIST: z.string().refine((str) => str.split(';').every((s) => s.startsWith('http') || s === '*'), { + message: 'The CORS_ORIGIN_WHITELIST environment variable must be a list of origins separated by a semicolon (;)', + }), + DEBUG: z.enum(['true', 'false']).transform((value) => value === 'true'), + + JWT_KEY: z.string().min(1), + JWT_EXPIRATION_TIME: z.coerce.number().min(0), + + POSTGRES_DB: z.string(), + POSTGRES_HOST: z.string().ip({ version: 'v4' }), + POSTGRES_PASSWORD: z.string().optional(), + POSTGRES_PORT: z.coerce.number().min(0), + POSTGRES_USER: z.string(), + + FILES_BASE_DIR: z.string().startsWith('./'), + + PROMOTION_BASE_PATH: z.string().startsWith('./'), + + USERS_PICTURES_DELAY: z.coerce.number().min(0), + USERS_VERIFICATION_DELAY: z.coerce.number().min(0), + USERS_BASE_PATH: z.string().startsWith('./'), + + EMAIL_HOST: z.string(), + EMAIL_PORT: z.coerce.number().min(0), + EMAIL_SECURE: z.enum(['true', 'false']).transform((value) => value === 'true'), + EMAIL_AUTH_USER: z.string().email(), + EMAIL_AUTH_PASS: z.string(), + EMAIL_ENABLED: z.enum(['true', 'false']).transform((value) => value === 'true'), + + WHITELISTED_HOSTS: z.string().refine(refineAsHost, { + message: 'Should be a list of emails host, each starting with arobase (@) and separated with a semicolon (;)', + }), + WHITELISTED_EMAILS: z + .string() + .refine(refineAsEmail, { message: 'Should be a list of emails separated by a semicolon (;)' }), + + BLACKLISTED_HOSTS: z.string().refine(refineAsHost, { + message: 'Should be a list of emails host, each starting with arobase (@) and separated with a semicolon (;)', + }), + BLACKLISTED_EMAILS: z + .string() + .refine(refineAsEmail, { message: 'Should be a list of emails separated by a semicolon (;)' }), + }); + + return schema.parse(process.env) as NodeJS.ProcessEnv & + Required, 'POSTGRES_PASSWORD'>> & { + POSTGRES_PASSWORD?: string; + }; +})(); diff --git a/src/main.ts b/src/main.ts index c5dd2e62..3880ef94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import env from '@env'; +import { env } from '@env'; import '@exported/global/utils'; import { AppModule } from './app.module'; @@ -14,8 +14,9 @@ import pkg from '../package.json'; */ async function bootstrap() { const app = await NestFactory.create(AppModule); - app.enableCors({ origin: env().cors }); - app.useStaticAssets(env().files.baseDir, { index: false, prefix: '/public' }); + const cors_urls = env.CORS_ORIGIN_WHITELIST.split(';'); + + app.enableCors({ origin: cors_urls.includes('*') ? '*' : cors_urls }); app.useStaticAssets('./src/swagger', { index: false, prefix: '/public' }); const config = new DocumentBuilder() @@ -39,9 +40,9 @@ async function bootstrap() { }, }); - await app.listen(env().port); + await app.listen(env.API_PORT); - Logger.log(`Server running on http://localhost:${env().port}`, 'Swagger'); + Logger.log(`Server running on http://localhost:${env.API_PORT}`, 'Swagger'); } bootstrap() diff --git a/src/mikro-orm.config.ts b/src/mikro-orm.config.ts index 17c65732..819c4220 100644 --- a/src/mikro-orm.config.ts +++ b/src/mikro-orm.config.ts @@ -6,7 +6,7 @@ import { TsMorphMetadataProvider } from '@mikro-orm/reflection'; import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; import { Logger } from '@nestjs/common'; -import 'dotenv/config'; +import { env } from '@env'; const logger = new Logger('MikroORM'); @@ -15,12 +15,12 @@ const logger = new Logger('MikroORM'); */ const config: Partial>> = { driver: PostgreSqlDriver, - dbName: process.env['POSTGRES_DB'] ?? 'ae_test', - port: parseInt(process.env['POSTGRES_PORT'], 10) ?? 5432, - host: process.env['POSTGRES_HOST'] ?? '127.0.0.1', - user: process.env['POSTGRES_USER'] ?? 'postgres', - password: process.env['POSTGRES_PASSWORD'] ?? 'postgres', - debug: (process.env['DEBUG'] ?? 'true') === 'true', + dbName: env.POSTGRES_DB, + port: env.POSTGRES_PORT, + host: env.POSTGRES_HOST, + user: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD, + debug: env.DEBUG, entities: ['./dist/src/modules/**/entities/*.entity.js'], entitiesTs: ['./src/modules/**/entities/*.entity.ts'], highlighter: new SqlHighlighter(), diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 40e53b05..2bf712c2 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { env } from '@env'; import { TranslateService } from '@modules/translate/translate.service'; import { UsersModule } from '@modules/users/users.module'; @@ -14,12 +14,10 @@ import { JwtStrategy } from './strategies/jwt.strategy'; imports: [ PassportModule, JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - secret: configService.get('auth.jwtKey'), - signOptions: { expiresIn: configService.get('auth.jwtExpirationTime') }, + useFactory: () => ({ + secret: env.JWT_KEY, + signOptions: { expiresIn: env.JWT_EXPIRATION_TIME }, }), - inject: [ConfigService], }), UsersModule, ], diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 359a1b84..7ab02553 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -2,10 +2,10 @@ import type { email } from '#types'; import type { JWTPayload } from '#types/api'; import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { compareSync } from 'bcrypt'; +import { env } from '@env'; import { TranslateService } from '@modules/translate/translate.service'; import { User } from '@modules/users/entities/user.entity'; import { UsersDataService } from '@modules/users/services/users-data.service'; @@ -18,7 +18,6 @@ export class AuthService { private readonly t: TranslateService, private readonly jwtService: JwtService, private readonly usersService: UsersDataService, - private readonly configService: ConfigService, ) {} /** @@ -70,7 +69,7 @@ export class AuthService { const bearer = token.replace('Bearer', '').trim(); try { - return this.jwtService.verify(bearer, { secret: this.configService.get('auth.jwtKey') }); + return this.jwtService.verify(bearer, { secret: env.JWT_KEY }); } catch (err) { const error = err as Error; if (error.name === 'TokenExpiredError') throw new UnauthorizedException(this.t.Errors.JWT.Expired()); diff --git a/src/modules/auth/guards/permission.guard.ts b/src/modules/auth/guards/permission.guard.ts index d54cda19..01a43941 100644 --- a/src/modules/auth/guards/permission.guard.ts +++ b/src/modules/auth/guards/permission.guard.ts @@ -2,7 +2,6 @@ import type { PERMISSION_NAMES } from '#types/api'; import type { Request } from 'express'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -14,7 +13,6 @@ import { AuthService } from '../auth.service'; export class PermissionGuard implements CanActivate { constructor( protected readonly jwtService: JwtService, - protected readonly configService: ConfigService, protected readonly userService: UsersDataService, protected readonly reflector: Reflector, protected readonly authService: AuthService, diff --git a/src/modules/auth/guards/self-or-perms.guard.ts b/src/modules/auth/guards/self-or-perms.guard.ts index f280786b..1ad1b014 100644 --- a/src/modules/auth/guards/self-or-perms.guard.ts +++ b/src/modules/auth/guards/self-or-perms.guard.ts @@ -1,5 +1,4 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -15,12 +14,11 @@ export class SelfOrPermissionGuard extends PermissionGuard implements CanActivat constructor( readonly t: TranslateService, override readonly jwtService: JwtService, - override readonly configService: ConfigService, override readonly userService: UsersDataService, override readonly reflector: Reflector, override readonly authService: AuthService, ) { - super(jwtService, configService, userService, reflector, authService); + super(jwtService, userService, reflector, authService); } override async canActivate(context: ExecutionContext) { diff --git a/src/modules/auth/guards/self-or-sub-or-perms.guard.ts b/src/modules/auth/guards/self-or-sub-or-perms.guard.ts index 5b99206c..a44937e6 100644 --- a/src/modules/auth/guards/self-or-sub-or-perms.guard.ts +++ b/src/modules/auth/guards/self-or-sub-or-perms.guard.ts @@ -1,5 +1,4 @@ import { CanActivate, ExecutionContext } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -14,12 +13,11 @@ export class SelfOrPermsOrSubGuard extends SelfOrPermissionGuard implements CanA constructor( override readonly t: TranslateService, override readonly jwtService: JwtService, - override readonly configService: ConfigService, override readonly userService: UsersDataService, override readonly reflector: Reflector, override readonly authService: AuthService, ) { - super(t, jwtService, configService, userService, reflector, authService); + super(t, jwtService, userService, reflector, authService); } override async canActivate(context: ExecutionContext) { diff --git a/src/modules/auth/guards/self-or-subscribed.guard.ts b/src/modules/auth/guards/self-or-subscribed.guard.ts index b1572a05..85d8c05c 100644 --- a/src/modules/auth/guards/self-or-subscribed.guard.ts +++ b/src/modules/auth/guards/self-or-subscribed.guard.ts @@ -1,5 +1,4 @@ import { CanActivate, ExecutionContext } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -14,12 +13,11 @@ export class SelfOrSubscribedGuard extends SubscribedGuard implements CanActivat constructor( private readonly t: TranslateService, override readonly jwtService: JwtService, - override readonly configService: ConfigService, override readonly userService: UsersDataService, override readonly reflector: Reflector, override readonly authService: AuthService, ) { - super(jwtService, configService, userService, reflector, authService); + super(jwtService, userService, reflector, authService); } override async canActivate(context: ExecutionContext) { diff --git a/src/modules/auth/guards/subscribed.guard.ts b/src/modules/auth/guards/subscribed.guard.ts index c975b841..5c168b03 100644 --- a/src/modules/auth/guards/subscribed.guard.ts +++ b/src/modules/auth/guards/subscribed.guard.ts @@ -1,7 +1,6 @@ /* istanbul ignore file */ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -13,7 +12,6 @@ import { AuthService } from '../auth.service'; export class SubscribedGuard implements CanActivate { constructor( protected readonly jwtService: JwtService, - protected readonly configService: ConfigService, protected readonly userService: UsersDataService, protected readonly reflector: Reflector, protected readonly authService: AuthService, diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index ebe0b3dc..c2f517b5 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -1,11 +1,11 @@ import type { JWTPayload } from '#types/api'; import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { z } from 'zod'; +import { env } from '@env'; import { validate } from '@utils/validate'; import { AuthService } from '../auth.service'; @@ -15,12 +15,12 @@ import { AuthService } from '../auth.service'; */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(private readonly configService: ConfigService, private readonly authService: AuthService) { + constructor(private readonly authService: AuthService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: true, - secretOrKey: configService.get('auth.jwtKey'), - signOptions: { expiresIn: configService.get('auth.jwtExpirationTime') }, + secretOrKey: env.JWT_KEY, + signOptions: { expiresIn: env.JWT_EXPIRATION_TIME }, }); } diff --git a/src/modules/emails/emails.service.ts b/src/modules/emails/emails.service.ts index e032baaa..86485b37 100644 --- a/src/modules/emails/emails.service.ts +++ b/src/modules/emails/emails.service.ts @@ -2,12 +2,11 @@ // TODO: (KEY: 3) Find a way to test emails (sending / receiving) import type { email } from '#types'; -import type { Config } from '@env'; import { BadRequestException, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Transporter, createTransport } from 'nodemailer'; +import { env } from '@env'; import { TranslateService } from '@modules/translate/translate.service'; interface EmailOptions { @@ -21,17 +20,17 @@ interface EmailOptions { export class EmailsService { readonly transporter?: Transporter; - constructor(private readonly configService: ConfigService, private readonly t: TranslateService) { + constructor(private readonly t: TranslateService) { this.transporter = - this.configService.get('email.enabled') === false + env.EMAIL_ENABLED === false ? undefined : createTransport({ - host: this.configService.get('email.host'), - port: this.configService.get('email.port'), - secure: this.configService.get('email.secure'), + host: env.EMAIL_HOST, + port: env.EMAIL_PORT, + secure: env.EMAIL_SECURE, auth: { - user: this.configService.get('email.auth.user'), - pass: this.configService.get('email.auth.pass'), + user: env.EMAIL_AUTH_USER, + pass: env.EMAIL_AUTH_PASS, }, }); } @@ -48,8 +47,15 @@ export class EmailsService { if (email.length > 60 || email.length < 6) throw new BadRequestException(this.t.Errors.Email.Malformed(email)); - const whitelisted = this.configService.get['email']['whitelist']>('email.whitelist'); - const blacklisted = this.configService.get['email']['blacklist']>('email.blacklist'); + const whitelisted = { + hosts: env.WHITELISTED_HOSTS.split(';'), + emails: env.WHITELISTED_EMAILS.split(';'), + }; + + const blacklisted = { + hosts: env.BLACKLISTED_HOSTS.split(';'), + emails: env.BLACKLISTED_EMAILS.split(';'), + }; if (whitelisted.hosts.some((host) => email.endsWith(host)) || whitelisted.emails.includes(email)) return; if (blacklisted.hosts.some((host) => email.endsWith(host)) || blacklisted.emails.includes(email)) @@ -62,7 +68,7 @@ export class EmailsService { } async sendEmail(options: EmailOptions) { - if (!this.transporter || this.configService.get('email.enabled') === false) return; + if (!this.transporter || env.EMAIL_ENABLED === false) return; await this.transporter.sendMail({ from: options.from ?? `noreply@ae.utbm.fr`, diff --git a/src/modules/files/files.service.ts b/src/modules/files/files.service.ts index fc21ad9c..e276315d 100644 --- a/src/modules/files/files.service.ts +++ b/src/modules/files/files.service.ts @@ -173,7 +173,7 @@ export class FilesService { const readable = new Readable({ read() { - const data = readFileSync(file.path, 'utf-8'); + const data = readFileSync(file.path); this.push(data); this.push(null); // Signal the end of the stream }, diff --git a/src/modules/promotions/promotions.service.ts b/src/modules/promotions/promotions.service.ts index adde5b4b..472ddd9f 100644 --- a/src/modules/promotions/promotions.service.ts +++ b/src/modules/promotions/promotions.service.ts @@ -2,9 +2,9 @@ import { join } from 'path'; import { MikroORM, CreateRequestContext } from '@mikro-orm/core'; import { Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Cron } from '@nestjs/schedule'; +import { env } from '@env'; import { FilesService } from '@modules/files/files.service'; import { TranslateService } from '@modules/translate/translate.service'; @@ -18,7 +18,6 @@ export class PromotionsService { constructor( private readonly t: TranslateService, private readonly orm: MikroORM, - private readonly configService: ConfigService, private readonly filesService: FilesService, ) {} @@ -113,7 +112,7 @@ export class PromotionsService { if (!promotion) throw new NotFoundException(this.t.Errors.Id.NotFound(Promotion, number)); const fileInfos = await this.filesService.writeOnDiskAsImage(file, { - directory: join(this.configService.get('files.promotions'), 'logo'), + directory: join(env.PROMOTION_BASE_PATH, 'logo'), filename: `promotion_${promotion.number}`, aspect_ratio: '1:1', }); diff --git a/src/modules/translate/translate.service.ts b/src/modules/translate/translate.service.ts index bf474fb6..5a442d07 100644 --- a/src/modules/translate/translate.service.ts +++ b/src/modules/translate/translate.service.ts @@ -1,5 +1,4 @@ /* istanbul ignore file */ -// TODO: (KEY: 7) Add crowdin support import type { aspect_ratio, email } from '#types'; import type { I18nTranslations, PERMISSION_NAMES } from '#types/api'; diff --git a/src/modules/users/services/users-data.service.ts b/src/modules/users/services/users-data.service.ts index 55adc1c0..c43722d6 100644 --- a/src/modules/users/services/users-data.service.ts +++ b/src/modules/users/services/users-data.service.ts @@ -3,12 +3,12 @@ import type { I18nTranslations, PERMISSION_NAMES } from '#types/api'; import { MikroORM, CreateRequestContext } from '@mikro-orm/core'; import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Cron } from '@nestjs/schedule'; import { compareSync, hashSync } from 'bcrypt'; import { I18nContext, I18nService } from 'nestjs-i18n'; import { z } from 'zod'; +import { env } from '@env'; import { MessageResponseDTO } from '@modules/_mixin/dto/message-response.dto'; import { UserPostByAdminDTO, UserPostDTO } from '@modules/auth/dto/register.dto'; import { EmailsService } from '@modules/emails/emails.service'; @@ -32,7 +32,6 @@ export class UsersDataService { private readonly orm: MikroORM, private readonly i18n: I18nService, private readonly emailsService: EmailsService, - private readonly configService: ConfigService, ) {} /** @@ -45,7 +44,7 @@ export class UsersDataService { async deleteUnverifiedUsers() { const users = await this.orm.em.find(User, { verified: null, - created: { $lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + created: { $lt: new Date(Date.now() - env.USERS_VERIFICATION_DELAY) }, }); for (const user of users) { @@ -227,8 +226,8 @@ export class UsersDataService { subject: this.i18n.t('templates.register_common.subject', { lang: I18nContext.current().lang }), html: getTemplate('emails/register_user', this.i18n, { username: user.full_name, - link: `${this.configService.get('api_url')}/auth/confirm/${user.id}/${encodeURI(email_token)}`, - days: this.configService.get('users.verification_token_validity'), + link: `${env.API_URL}/auth/confirm/${user.id}/${encodeURI(email_token)}`, + days: env.USERS_VERIFICATION_DELAY / (1000 * 60 * 60 * 24), }), }); // Email change -> email changed template @@ -238,7 +237,7 @@ export class UsersDataService { subject: this.i18n.t('templates.email_changed.subject', { lang: I18nContext.current().lang }), html: getTemplate('emails/email_changed', this.i18n, { username: user.full_name, - link: `${this.configService.get('api_url')}/auth/confirm/${user.id}/${encodeURI(email_token)}`, + link: `${env.API_URL}/auth/confirm/${user.id}/${encodeURI(email_token)}`, }), }); } diff --git a/src/modules/users/services/users-files.service.ts b/src/modules/users/services/users-files.service.ts index bdda4f0e..80055d44 100644 --- a/src/modules/users/services/users-files.service.ts +++ b/src/modules/users/services/users-files.service.ts @@ -2,8 +2,8 @@ import { join } from 'path'; import { MikroORM, CreateRequestContext } from '@mikro-orm/core'; import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { env } from '@env'; import { FilesService } from '@modules/files/files.service'; import { TranslateService } from '@modules/translate/translate.service'; @@ -18,7 +18,6 @@ export class UsersFilesService { private readonly t: TranslateService, private readonly orm: MikroORM, private readonly filesService: FilesService, - private readonly configService: ConfigService, private readonly dataService: UsersDataService, ) {} @@ -39,7 +38,7 @@ export class UsersFilesService { if (!user) throw new NotFoundException(this.t.Errors.Id.NotFound(User, owner_id)); if (req_user.id === user.id && user.picture !== null) { - const cooldown = this.configService.get('users.picture_cooldown'); + const cooldown = env.USERS_PICTURES_DELAY; const now = Date.now(); if ( @@ -54,7 +53,7 @@ export class UsersFilesService { } const fileInfos = await this.filesService.writeOnDiskAsImage(file, { - directory: join(this.configService.get('files.users'), 'pictures'), + directory: join(env.USERS_BASE_PATH, 'pictures'), filename: user.full_name.toLowerCase().replaceAll(' ', '_'), aspect_ratio: '1:1', }); @@ -115,7 +114,7 @@ export class UsersFilesService { if (!user) throw new NotFoundException(this.t.Errors.Id.NotFound(User, id)); const fileInfos = await this.filesService.writeOnDiskAsImage(file, { - directory: join(this.configService.get('files.users'), 'banners'), + directory: join(env.USERS_BASE_PATH, 'banners'), filename: user.full_name.replaceAll(' ', '_'), aspect_ratio: '16:9', }); diff --git a/tests/.env.test b/tests/.env.test new file mode 100644 index 00000000..5b050e13 --- /dev/null +++ b/tests/.env.test @@ -0,0 +1,70 @@ +####################### +# TESTS config file # +####################### + +#* If a parameter is noted as a LIST, it means that you can separate +#* multiple values with a ';', DO NOT add a ';' at the end of the line, +#* only between values + +#! ALL time value are expressed in milliseconds + +API_PORT = '4000' +API_URL = 'http://localhost:4000' +DEBUG = 'true' + +# Allowed URLs for CORS 'Access-Control-Allow-Origin' header +# -> LIST +# -> Accepted values: URLs or '*' +CORS_ORIGIN_WHITELIST = 'https://ae.utbm.fr' + +# Postgres configuration +# You can avoid user password when developing on local machine +POSTGRES_DB = 'ae_test' +POSTGRES_HOST = '127.0.0.1' +POSTGRES_PORT = '5432' +POSTGRES_USER = 'postgres' +POSTGRES_PASSWORD = 'postgres' # optional + +# JWT configuration +# Note: changing the key will invalidate all existing tokens +JWT_KEY = 'secret_jwt_key' +# Expiration time in milliseconds +JWT_EXPIRATION_TIME = '604800000' # 1 week + +# Base directory for generic uploaded files +FILES_BASE_DIR = './public' + +# Users files root directory +USERS_BASE_PATH = './public/users' +# Delay before allowing the user to update its profile picture +USERS_PICTURES_DELAY = '604800000' # 1 week +# Delay before deleting unverified users +USERS_VERIFICATION_DELAY = '604800000' # 1 week + +# Promotion files root directory +PROMOTION_BASE_PATH = './public/promotions' + +# Emailing configuration +# Note: use your own UTBM email account when developing on local machine +EMAIL_HOST = 'smtp.domain.com' +EMAIL_PORT = '465' +EMAIL_SECURE = 'true' +EMAIL_AUTH_USER = 'example@domain.com' +EMAIL_AUTH_PASS = 'azertyuiop' +EMAIL_ENABLED = 'false' + +# Whitelisted hosts can be used to register +# -> Bypass email validation +# -> LIST +WHITELISTED_HOSTS = '@gmail.com' +# Whitelisted emails can be used to register, despite their host +# being blacklisted +# -> Bypass email validation +# -> LIST +WHITELISTED_EMAILS = 'ae.info@utbm.fr;ae@utbm.fr' + +# Blacklisted hosts/emails cannot be used to register, +# unless they are whitelisted in WHITELISTED_HOSTS or WHITELISTED_EMAILS +# -> LISTs +BLACKLISTED_HOSTS = '@utbm.fr' +BLACKLISTED_EMAILS = 'example@pirate.ru' diff --git a/tests/database.setup.ts b/tests/database.setup.ts index f959f4a7..1055084b 100644 --- a/tests/database.setup.ts +++ b/tests/database.setup.ts @@ -1,9 +1,8 @@ -/* eslint-disable no-console */ - import 'tsconfig-paths/register'; import { join } from 'path'; import { MikroORM } from '@mikro-orm/core'; +import { Logger } from '@nestjs/common'; import { TestSeeder } from '@database/seeders/tests.seeder'; import config from '@mikro-orm.config'; @@ -12,6 +11,8 @@ import config from '@mikro-orm.config'; * This function is used to setup the database before running the tests. */ async function setup() { + Logger.log('Setting up the database...', 'database.setup.ts'); + const orm = await MikroORM.init({ ...config, debug: false, // Hide debug logs for the database setup @@ -32,5 +33,5 @@ async function setup() { } setup() - .then(() => console.log('Database setup done.')) - .catch(console.error); + .then(() => Logger.log('The database has been setup', 'database.setup.ts')) + .catch((err) => Logger.error(err)); diff --git a/tests/e2e/promotions.e2e-spec.ts b/tests/e2e/promotions.e2e-spec.ts index 8d544271..2ff3c3a1 100644 --- a/tests/e2e/promotions.e2e-spec.ts +++ b/tests/e2e/promotions.e2e-spec.ts @@ -3,11 +3,12 @@ import { join } from 'path'; import request from 'supertest'; +import { env } from '@env'; import { TokenDTO } from '@modules/auth/dto/token.dto'; import { PromotionPicture } from '@modules/promotions/entities/promotion-picture.entity'; import { Promotion } from '@modules/promotions/entities/promotion.entity'; -import { server, config, t, orm } from '..'; +import { server, t, orm } from '..'; describe('Promotions (e2e)', () => { let tokenUnauthorized: string; @@ -544,11 +545,9 @@ describe('Promotions (e2e)', () => { }); // expect the file to be created on disk - expect( - existsSync( - join(config.get('files.promotions'), 'logo', (response.body as Promotion).picture.filename), - ), - ).toBe(true); + expect(existsSync(join(env.PROMOTION_BASE_PATH, 'logo', (response.body as Promotion).picture.filename))).toBe( + true, + ); }); it('when the promotion has a logo and update the logo', async () => { @@ -579,14 +578,12 @@ describe('Promotions (e2e)', () => { }); // expect the old file to be deleted from disk - expect(existsSync(join(config.get('files.promotions'), 'logo', oldLogo.filename))).toBe(false); + expect(existsSync(join(env.PROMOTION_BASE_PATH, 'logo', oldLogo.filename))).toBe(false); // expect the new file to be created on disk - expect( - existsSync( - join(config.get('files.promotions'), 'logo', (response.body as Promotion).picture.filename), - ), - ).toBe(true); + expect(existsSync(join(env.PROMOTION_BASE_PATH, 'logo', (response.body as Promotion).picture.filename))).toBe( + true, + ); }); }); }); @@ -684,7 +681,7 @@ describe('Promotions (e2e)', () => { }); // expect the file to be deleted from disk - expect(existsSync(join(config.get('files.promotions'), 'logo', logo.filename))).toBe(false); + expect(existsSync(join(env.PROMOTION_BASE_PATH, 'logo', logo.filename))).toBe(false); expect(await em.findOne(PromotionPicture, { picture_promotion: 21 })).toBeNull(); }); }); diff --git a/tests/env.setup.ts b/tests/env.setup.ts new file mode 100644 index 00000000..9e74f756 --- /dev/null +++ b/tests/env.setup.ts @@ -0,0 +1,18 @@ +import 'tsconfig-paths/register'; + +import { copyFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +import { Logger } from '@nestjs/common'; + +(() => { + Logger.log('Setting up the environment variables...', 'env.setup.ts'); + + if (existsSync(join(process.cwd(), '.env'))) { + Logger.warn('The .env file already exists, skipping...', 'env.setup.ts'); + return; + } + + copyFileSync(join(process.cwd(), './tests/.env.test'), join(process.cwd(), '.env')); + Logger.log('The .env file has been created from the .env.test file', 'env.setup.ts'); +})(); diff --git a/tests/index.ts b/tests/index.ts index 7447317f..f7afce87 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -4,17 +4,14 @@ import 'tsconfig-paths/register'; import '@exported/global/utils'; import { MikroORM } from '@mikro-orm/core'; -import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { NestExpressApplication } from '@nestjs/platform-express'; import { TestingModule, Test } from '@nestjs/testing'; import { AppModule } from '@app.module'; -import env from '@env'; import { TranslateService } from '@modules/translate/translate.service'; let module_fixture: TestingModule; -let config: ConfigService; let jwt: JwtService; let app: NestExpressApplication; let server: Awaited>; @@ -35,12 +32,10 @@ beforeAll(async () => { }).compile(); app = module_fixture.createNestApplication(); - app.enableCors({ origin: env().cors }); - app.useStaticAssets(env().files.baseDir, { index: false, prefix: '/public' }); + app.enableCors({ origin: '*' }); orm = module_fixture.get(MikroORM); t = module_fixture.get(TranslateService); - config = module_fixture.get(ConfigService); jwt = module_fixture.get(JwtService); server = await app.listen(5325); @@ -54,4 +49,4 @@ afterAll(async () => { server.close(); }); -export { module_fixture, config, server, orm, t, jwt }; +export { module_fixture, server, orm, t, jwt }; diff --git a/tests/units/services/auth.test.ts b/tests/units/services/auth.test.ts index 32f50108..23fec9fd 100644 --- a/tests/units/services/auth.test.ts +++ b/tests/units/services/auth.test.ts @@ -1,8 +1,9 @@ import { UnauthorizedException } from '@nestjs/common'; +import { env } from '@env'; import { AuthService } from '@modules/auth/auth.service'; -import { module_fixture, jwt, config, t } from '../..'; +import { module_fixture, jwt, t } from '../..'; describe('AuthService (unit)', () => { let authService: AuthService; @@ -20,9 +21,7 @@ describe('AuthService (unit)', () => { it('should return an error if the token is expired', () => { expect(() => - authService.verifyJWT( - jwt.sign({ id: 1, email: 'test@example.fr' }, { expiresIn: '0s', secret: config.get('auth.jwtKey') }), - ), + authService.verifyJWT(jwt.sign({ id: 1, email: 'test@example.fr' }, { expiresIn: '0s', secret: env.JWT_KEY })), ).toThrowError(new UnauthorizedException(t.Errors.JWT.Expired())); }); });