From 7465af1cb917c1b3fa1e0ea00fc93aed1324b1c4 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Thu, 5 Feb 2026 15:07:07 +0700 Subject: [PATCH] feat: add audit log system with comprehensive action tracking and user activity monitoring Introduce AuditLog model to track system-wide user actions including authentication, course management, file operations, and user account changes. Add AuditAction enum with 17 action types (CREATE, UPDATE, DELETE, LOGIN, LOGOUT, ENROLL, UNENROLL, SUBMIT_QUIZ, APPROVE_COURSE, REJECT_COURSE, UPLOAD_FILE, DELETE_FILE, CHANGE_PASSWORD, RESET_PASSWORD, VERIFY_EMAIL, DEACTIVATE_USER, ACTIVATE_USER). Include fields --- Backend/.windsurf/workflows/k6-load-test.md | 411 ++++++++++++++++++++ Backend/prisma/schema.prisma | 50 +++ 2 files changed, 461 insertions(+) create mode 100644 Backend/.windsurf/workflows/k6-load-test.md diff --git a/Backend/.windsurf/workflows/k6-load-test.md b/Backend/.windsurf/workflows/k6-load-test.md new file mode 100644 index 00000000..48feb4f6 --- /dev/null +++ b/Backend/.windsurf/workflows/k6-load-test.md @@ -0,0 +1,411 @@ +--- +description: How to create k6 load tests for API endpoints +--- + +# K6 Load Test Workflow + +วิธีสร้าง k6 load test สำหรับ API endpoints ใน E-Learning Backend + +## Prerequisites + +1. ติดตั้ง k6: +```bash +# macOS +brew install k6 + +# หรือ download จาก https://k6.io/docs/getting-started/installation/ +``` + +2. สร้าง folder สำหรับ tests: +```bash +mkdir -p Backend/tests/k6 +``` + +## Step 1: ระบุ API Endpoints ที่ต้องการ Test + +ดู API endpoints จาก: +- `Backend/src/controllers/` - ดู Route decorators (@Route, @Get, @Post, etc.) +- `Backend/public/swagger.json` - ดู OpenAPI spec (ถ้ามี) + +### Available Controllers & Endpoints: + +| Controller | Base Route | Key Endpoints | +|------------|------------|---------------| +| AuthController | `/api/auth` | `POST /login`, `POST /register-learner`, `POST /register-instructor`, `POST /refresh` | +| CoursesStudentController | `/api/students` | `GET /courses`, `POST /courses/{id}/enroll`, `GET /courses/{id}/learn` | +| CoursesInstructorController | `/api/instructors/courses` | `GET /`, `GET /{id}`, `POST /`, `PUT /{id}` | +| CategoriesController | `/api/categories` | `GET /`, `GET /{id}` | +| UserController | `/api/users` | `GET /me`, `PUT /me` | +| CertificateController | `/api/certificates` | `GET /{id}` | + +## Step 2: สร้าง K6 Test Script + +สร้างไฟล์ใน `Backend/tests/k6/` ตาม template นี้: + +### Template: Basic Load Test + +```javascript +// Backend/tests/k6/.js +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +// Custom metrics +const errorRate = new Rate('errors'); +const responseTime = new Trend('response_time'); + +// Test configuration +export const options = { + // Ramp-up pattern + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 users + { duration: '1m', target: 10 }, // Stay at 10 users + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 50 }, // Stay at 50 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests < 500ms + errors: ['rate<0.1'], // Error rate < 10% + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +// Setup: Get auth token (runs once per VU) +export function setup() { + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'test@example.com', + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const token = loginRes.json('data.token'); + return { token }; +} + +// Main test function +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${data.token}`, + }; + + // Test endpoint + const res = http.get(`${BASE_URL}/api/endpoint`, { headers }); + + // Record metrics + responseTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + + // Assertions + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + sleep(1); // Think time between requests +} +``` + +## Step 3: Test Scenarios ตามประเภท + +### 3.1 Authentication Load Test + +```javascript +// Backend/tests/k6/auth-load-test.js +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +const errorRate = new Rate('errors'); + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 20 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<1000'], + errors: ['rate<0.05'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export default function () { + // Test Login + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: `user${__VU}@test.com`, + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + errorRate.add(loginRes.status !== 200); + + check(loginRes, { + 'login successful': (r) => r.status === 200, + 'has token': (r) => r.json('data.token') !== undefined, + }); + + sleep(1); +} +``` + +### 3.2 Course Browsing Load Test (Student) + +```javascript +// Backend/tests/k6/student-courses-load-test.js +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const courseListTime = new Trend('course_list_time'); +const courseLearningTime = new Trend('course_learning_time'); + +export const options = { + stages: [ + { duration: '30s', target: 30 }, + { duration: '2m', target: 30 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<800'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export function setup() { + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'student@test.com', + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + return { token: loginRes.json('data.token') }; +} + +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${data.token}`, + }; + + group('List Enrolled Courses', () => { + const res = http.get(`${BASE_URL}/api/students/courses`, { headers }); + courseListTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + check(res, { 'courses listed': (r) => r.status === 200 }); + }); + + sleep(2); + + group('Get Course Learning Page', () => { + const courseId = 1; // Use a valid course ID + const res = http.get(`${BASE_URL}/api/students/courses/${courseId}/learn`, { headers }); + courseLearningTime.add(res.timings.duration); + errorRate.add(res.status !== 200 && res.status !== 403); + check(res, { 'learning page loaded': (r) => r.status === 200 || r.status === 403 }); + }); + + sleep(1); +} +``` + +### 3.3 Instructor Course Management Load Test + +```javascript +// Backend/tests/k6/instructor-courses-load-test.js +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate } from 'k6/metrics'; + +const errorRate = new Rate('errors'); + +export const options = { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<1000'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export function setup() { + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'instructor@test.com', + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + return { token: loginRes.json('data.token') }; +} + +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${data.token}`, + }; + + group('List My Courses', () => { + const res = http.get(`${BASE_URL}/api/instructors/courses`, { headers }); + errorRate.add(res.status !== 200); + check(res, { 'courses listed': (r) => r.status === 200 }); + }); + + sleep(2); + + group('Get Course Detail', () => { + const courseId = 1; + const res = http.get(`${BASE_URL}/api/instructors/courses/${courseId}`, { headers }); + errorRate.add(res.status !== 200 && res.status !== 404); + check(res, { 'course detail loaded': (r) => r.status === 200 || r.status === 404 }); + }); + + sleep(1); +} +``` + +### 3.4 Mixed Scenario (Realistic Load) + +```javascript +// Backend/tests/k6/mixed-load-test.js +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate } from 'k6/metrics'; +import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const errorRate = new Rate('errors'); + +export const options = { + scenarios: { + students: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 50 }, + { duration: '2m', target: 50 }, + { duration: '30s', target: 0 }, + ], + exec: 'studentScenario', + }, + instructors: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, + { duration: '2m', target: 10 }, + { duration: '30s', target: 0 }, + ], + exec: 'instructorScenario', + }, + }, + thresholds: { + http_req_duration: ['p(95)<1000'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +function getToken(email, password) { + const res = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ email, password }), { + headers: { 'Content-Type': 'application/json' }, + }); + return res.json('data.token'); +} + +export function studentScenario() { + const token = getToken('student@test.com', 'password123'); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // Browse courses + const coursesRes = http.get(`${BASE_URL}/api/students/courses`, { headers }); + check(coursesRes, { 'student courses ok': (r) => r.status === 200 }); + + sleep(randomIntBetween(1, 3)); +} + +export function instructorScenario() { + const token = getToken('instructor@test.com', 'password123'); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // List courses + const coursesRes = http.get(`${BASE_URL}/api/instructors/courses`, { headers }); + check(coursesRes, { 'instructor courses ok': (r) => r.status === 200 }); + + sleep(randomIntBetween(2, 5)); +} +``` + +## Step 4: รัน Load Test + +### Basic Run +```bash +# รันจาก Backend directory +k6 run tests/k6/.js +``` + +### With Environment Variables +```bash +# ระบุ BASE_URL +k6 run -e BASE_URL=http://localhost:3000 tests/k6/.js + +# ระบุ VUs และ Duration แบบ simple +k6 run --vus 10 --duration 30s tests/k6/.js +``` + +### Output to JSON +```bash +k6 run --out json=results.json tests/k6/.js +``` + +### Output to InfluxDB (for Grafana) +```bash +k6 run --out influxdb=http://localhost:8086/k6 tests/k6/.js +``` + +## Step 5: วิเคราะห์ผลลัพธ์ + +### Key Metrics to Watch: +- **http_req_duration**: Response time (p50, p90, p95, p99) +- **http_req_failed**: Failed request rate +- **http_reqs**: Requests per second (throughput) +- **vus**: Virtual users at any point +- **iterations**: Total completed iterations + +### Thresholds ที่แนะนำ: +```javascript +thresholds: { + http_req_duration: ['p(95)<500'], // 95% < 500ms + http_req_duration: ['p(99)<1000'], // 99% < 1s + http_req_failed: ['rate<0.01'], // < 1% errors + http_reqs: ['rate>100'], // > 100 req/s +} +``` + +## Tips & Best Practices + +1. **Test Data**: สร้าง test users ก่อนรัน load test +2. **Warm-up**: ใช้ ramp-up stages เพื่อไม่ให้ server shock +3. **Think Time**: ใส่ `sleep()` เพื่อจำลอง user behavior จริง +4. **Isolation**: รัน test บน environment แยก ไม่ใช่ production +5. **Baseline**: รัน test หลายรอบเพื่อหา baseline performance +6. **Monitor**: ดู server metrics (CPU, Memory, DB connections) ขณะรัน test diff --git a/Backend/prisma/schema.prisma b/Backend/prisma/schema.prisma index 168487eb..a4386c8a 100644 --- a/Backend/prisma/schema.prisma +++ b/Backend/prisma/schema.prisma @@ -64,6 +64,7 @@ model User { updated_withdrawals WithdrawalRequest[] @relation("WithdrawalUpdater") submitted_approvals CourseApproval[] @relation("ApprovalSubmitter") reviewed_approvals CourseApproval[] @relation("ApprovalReviewer") + audit_logs AuditLog[] @@index([username]) @@index([email]) @@ -608,3 +609,52 @@ model WithdrawalRequest { @@index([instructor_id, status]) @@map("withdrawal_requests") } + +// ============================================ +// Audit Log System +// ============================================ + +enum AuditAction { + CREATE + UPDATE + DELETE + LOGIN + LOGOUT + ENROLL + UNENROLL + SUBMIT_QUIZ + APPROVE_COURSE + REJECT_COURSE + UPLOAD_FILE + DELETE_FILE + CHANGE_PASSWORD + RESET_PASSWORD + VERIFY_EMAIL + DEACTIVATE_USER + ACTIVATE_USER +} + +model AuditLog { + id Int @id @default(autoincrement()) + user_id Int? + action AuditAction + entity_type String @db.VarChar(100) // Course, User, Lesson, Quiz, etc. + entity_id Int? + old_value Json? // ค่าเดิม (สำหรับ UPDATE/DELETE) + new_value Json? // ค่าใหม่ (สำหรับ CREATE/UPDATE) + ip_address String? @db.VarChar(45) // รองรับ IPv6 + user_agent String? @db.Text + metadata Json? // ข้อมูลเพิ่มเติม เช่น request_id, session_id + created_at DateTime @default(now()) + + user User? @relation(fields: [user_id], references: [id], onDelete: SetNull) + + @@index([user_id]) + @@index([action]) + @@index([entity_type]) + @@index([entity_type, entity_id]) + @@index([created_at]) + @@index([user_id, created_at]) + @@index([action, created_at]) + @@map("audit_logs") +}