diff --git a/.env.example b/.env.example index f3d8615..7f5eed1 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ PORT=3000 NODE_ENV=development COOKIE_SECRET=same-serious-secret CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +THROTTLE_LIMIT=100 +THROTTLE_TTL=60000 # --- POSTGRES --- DB_USERNAME=admin diff --git a/.gitignore b/.gitignore index 5866f4e..b0b2412 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ pids *.pid *.seed *.pid.lock -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json \ No newline at end of file +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +infra/k6/data/*.json \ No newline at end of file diff --git a/infra/k6/scenarios/auth.js b/infra/k6/scenarios/auth.js new file mode 100644 index 0000000..f5e8052 --- /dev/null +++ b/infra/k6/scenarios/auth.js @@ -0,0 +1,92 @@ +import { SharedArray } from 'k6/data'; +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000/api/v1'; +const VUS = parseInt(__ENV.VUS) || 10; +const DURATION = __ENV.DURATION || '1m'; + +export const options = { + thresholds: { + 'http_req_duration{name:sign-in}': ['p(95)<800'], + 'http_req_duration{name:refresh}': ['p(95)<200'], + 'http_req_duration{name:sign-out}': ['p(95)<200'], + http_req_failed: ['rate<0.1'], + }, + scenarios: { + auth_load_test: { + executor: 'constant-vus', + vus: VUS, + duration: DURATION, + }, + }, +}; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const params = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + // --- SIGN-IN --- + const signInRes = http.post( + `${BASE_URL}/auth/sign-in`, + JSON.stringify({ email: user.email, password: user.password }), + Object.assign({}, params, { tags: { name: 'sign-in' } }), + ); + + const signInToken = signInRes.json().token; + const signInCookie = signInRes.cookies.refresh ? signInRes.cookies.refresh[0].value : 'MISSING'; + + check(signInRes, { + 'login: status is 201': (r) => r.status === 201, + 'login: has access token': (r) => r.json().token !== undefined, + }); + + sleep(1); + + // --- REFRESH --- + const refreshRes = http.post(`${BASE_URL}/auth/refresh`, null, { + tags: { name: 'refresh' }, + cookies: { refresh: signInCookie }, + }); + + const newAccessToken = refreshRes.json().token; + const newRefreshCookie = refreshRes.cookies.refresh + ? refreshRes.cookies.refresh[0].value + : 'NOT_ROTATED'; + + check(refreshRes, { + 'refresh: status is 200': (r) => r.status === 200, + }); + + sleep(1); + + // --- SIGN OUT --- + const refreshToken = newAccessToken || signInToken; + const refreshCookie = newRefreshCookie !== 'NOT_ROTATED' ? newRefreshCookie : signInCookie; + + const signOutRes = http.post( + `${BASE_URL}/auth/sign-out`, + {}, + { + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + tags: { name: 'sign-out' }, + cookies: { refresh: refreshCookie }, + }, + ); + + check(signOutRes, { + 'sign-out: status is 200': (r) => r.status === 200, + }); + + sleep(1); +} diff --git a/infra/k6/scripts/seed-users.ts b/infra/k6/scripts/seed-users.ts new file mode 100644 index 0000000..2017391 --- /dev/null +++ b/infra/k6/scripts/seed-users.ts @@ -0,0 +1,78 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { createId } from '@paralleldrive/cuid2'; +import * as argon from 'argon2'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import * as sc from '../../../src/modules/user/entities'; +import { sql } from 'drizzle-orm'; + +async function seed() { + const DB_URL = process.env.DATABASE_URL; + if (!DB_URL) throw new Error('DATABASE_URL is not defined in .env'); + + const COUNT = 500; + const OUT_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); + + console.log(`Start seeding ${COUNT} users using pg driver...`); + + const pool = new Pool({ connectionString: DB_URL }); + const db = drizzle(pool, { schema: sc }); + + const password = 'TestPassword123!'; + const passwordHash = await argon.hash(password); + + const usersToInsert = []; + const securityToInsert = []; + const notificationsToInsert = []; + const k6Data = []; + + for (let i = 0; i < COUNT; i++) { + const userId = createId(); + const email = `k6_user_${i}@tasktracker.com`; + + usersToInsert.push({ + id: userId, + email, + firstName: 'K6', + lastName: `User ${i}`, + timezone: 'UTC', + language: 'ru', + }); + + securityToInsert.push({ userId, passwordHash }); + + notificationsToInsert.push({ userId }); + + k6Data.push({ email, password }); + } + + console.log('Cleaning up ONLY k6 test users...'); + await db.transaction(async (tx) => { + await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); + }); + + console.log('Inserting new test users'); + try { + await db.transaction(async (tx) => { + await tx.insert(sc.users).values(usersToInsert); + await tx.insert(sc.userSecurity).values(securityToInsert); + await tx.insert(sc.userNotifications).values(notificationsToInsert); + }); + + mkdirSync(dirname(OUT_FILE), { recursive: true }); + writeFileSync(OUT_FILE, JSON.stringify(k6Data, null, 2)); + + console.log(`Success! ${COUNT} users created.`); + console.log(`Credentials saved to: ${OUT_FILE}`); + } catch (e) { + console.error('Seed failed:', e); + } finally { + await pool.end(); + } +} + +seed(); diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts index 08f8cbe..64d1d19 100644 --- a/libs/bootstrap/src/configs/throttler.ts +++ b/libs/bootstrap/src/configs/throttler.ts @@ -1,9 +1,11 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); import type { ThrottlerModuleOptions } from '@nestjs/throttler'; export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ { - ttl: 60000, - limit: 100, + ttl: process.env.THROTTLE_TTL ? parseInt(process.env.THROTTLE_LIMIT) : 60000, + limit: process.env.THROTTLE_LIMIT ? parseInt(process.env.THROTTLE_LIMIT) : 100, skipIf: (context) => context.getType() !== 'http', }, ]; diff --git a/package.json b/package.json index 532bee3..6be9d11 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "k6:user": "pnpm --filter @project/performance-tests test:user", "k6:board": "pnpm --filter @project/performance-tests test:board", "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", - "k6:smoke": "pnpm --filter @project/performance-tests smoke" + "k6:smoke": "pnpm --filter @project/performance-tests smoke", + "k6:seed-users": "npx tsx infra/k6/scripts/seed-users.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1029.0", @@ -55,6 +56,7 @@ "@willsoto/nestjs-prometheus": "^6.1.0", "argon2": "^0.44.0", "bullmq": "^5.73.4", + "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "fastify": "^5.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa52c76..64bc8af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: bullmq: specifier: ^5.73.4 version: 5.73.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) @@ -2636,6 +2639,10 @@ packages: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -7028,6 +7035,8 @@ snapshots: dotenv@17.4.1: {} + dotenv@17.4.2: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index 0e1ae9c..9280fd5 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -1,5 +1,4 @@ -import { ApiBaseController } from '../../../shared/decorators'; -import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { Body, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common'; import { AuthService } from '../services'; import { PostLoginSwagger, @@ -12,6 +11,7 @@ import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; +import { ApiBaseController } from '@shared/decorators'; @ApiBaseController('auth', 'Auth') export class AuthController { @@ -66,6 +66,7 @@ export class AuthController { } @Post('sign-out') + @HttpCode(HttpStatus.OK) @UseGuards(BearerAuthGuard) @PostLogoutSwagger() async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) {