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
This commit is contained in:
parent
f12221c84a
commit
7465af1cb9
2 changed files with 461 additions and 0 deletions
411
Backend/.windsurf/workflows/k6-load-test.md
Normal file
411
Backend/.windsurf/workflows/k6-load-test.md
Normal file
|
|
@ -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/<test-name>.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/<test-name>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Environment Variables
|
||||||
|
```bash
|
||||||
|
# ระบุ BASE_URL
|
||||||
|
k6 run -e BASE_URL=http://localhost:3000 tests/k6/<test-name>.js
|
||||||
|
|
||||||
|
# ระบุ VUs และ Duration แบบ simple
|
||||||
|
k6 run --vus 10 --duration 30s tests/k6/<test-name>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output to JSON
|
||||||
|
```bash
|
||||||
|
k6 run --out json=results.json tests/k6/<test-name>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output to InfluxDB (for Grafana)
|
||||||
|
```bash
|
||||||
|
k6 run --out influxdb=http://localhost:8086/k6 tests/k6/<test-name>.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
|
||||||
|
|
@ -64,6 +64,7 @@ model User {
|
||||||
updated_withdrawals WithdrawalRequest[] @relation("WithdrawalUpdater")
|
updated_withdrawals WithdrawalRequest[] @relation("WithdrawalUpdater")
|
||||||
submitted_approvals CourseApproval[] @relation("ApprovalSubmitter")
|
submitted_approvals CourseApproval[] @relation("ApprovalSubmitter")
|
||||||
reviewed_approvals CourseApproval[] @relation("ApprovalReviewer")
|
reviewed_approvals CourseApproval[] @relation("ApprovalReviewer")
|
||||||
|
audit_logs AuditLog[]
|
||||||
|
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
|
|
@ -608,3 +609,52 @@ model WithdrawalRequest {
|
||||||
@@index([instructor_id, status])
|
@@index([instructor_id, status])
|
||||||
@@map("withdrawal_requests")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue