Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
195
apps/backend/tests/k6/lib/config.js
Normal file
195
apps/backend/tests/k6/lib/config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* K6 Test Configuration
|
||||
* Centralized configuration for all load tests
|
||||
*/
|
||||
|
||||
// Get environment variables with defaults
|
||||
export const config = {
|
||||
// Base URL for the target server
|
||||
get baseUrl() {
|
||||
return __ENV.BASE_URL || 'http://localhost:3000';
|
||||
},
|
||||
|
||||
// Admin credentials (from environment)
|
||||
get adminEmail() {
|
||||
return __ENV.ADMIN_EMAIL || 'admin@enchun.tw';
|
||||
},
|
||||
|
||||
get adminPassword() {
|
||||
return __ENV.ADMIN_PASSWORD || 'admin123';
|
||||
},
|
||||
|
||||
// Load testing thresholds (NFR4 requirements)
|
||||
thresholds: {
|
||||
// Response time thresholds
|
||||
httpReqDuration: ['p(95) < 500'], // 95th percentile < 500ms
|
||||
httpReqDurationApi: ['p(95) < 300'], // API endpoints < 300ms
|
||||
httpReqDurationAdmin: ['p(95) < 700'], // Admin operations < 700ms
|
||||
|
||||
// Error rate thresholds
|
||||
httpReqFailed: ['rate < 0.01'], // < 1% error rate
|
||||
httpReqFailedApi: ['rate < 0.005'], // API < 0.5% error rate
|
||||
|
||||
// Throughput thresholds
|
||||
httpReqs: ['rate > 50'], // > 50 requests/second for pages
|
||||
httpReqsApi: ['rate > 100'], // > 100 requests/second for API
|
||||
|
||||
// Check success rate
|
||||
checks: ['rate > 0.99'], // 99% of checks should pass
|
||||
},
|
||||
|
||||
// Stage configuration for gradual ramp-up
|
||||
stages: {
|
||||
// Public browsing: 100 users
|
||||
publicBrowsing: [
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '30s', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '1m', target: 100 }, // Ramp up to 100 users
|
||||
{ duration: '2m', target: 100 }, // Stay at 100 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
|
||||
// Admin operations: 20 users
|
||||
adminOperations: [
|
||||
{ duration: '30s', target: 5 }, // Ramp up to 5 users
|
||||
{ duration: '30s', target: 10 }, // Ramp up to 10 users
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '3m', target: 20 }, // Stay at 20 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
|
||||
// API performance: 50 users
|
||||
apiPerformance: [
|
||||
{ duration: '1m', target: 10 }, // Ramp up to 10 users
|
||||
{ duration: '1m', target: 25 }, // Ramp up to 25 users
|
||||
{ duration: '1m', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '5m', target: 50 }, // Stay at 50 users
|
||||
{ duration: '1m', target: 0 }, // Ramp down
|
||||
],
|
||||
},
|
||||
|
||||
// Common request options
|
||||
requestOptions: {
|
||||
timeout: '30s', // Request timeout
|
||||
maxRedirects: 10, // Maximum redirects to follow
|
||||
discardResponseBodies: true, // Discard response bodies to save memory
|
||||
},
|
||||
|
||||
// Common headers
|
||||
headers: {
|
||||
'User-Agent': 'k6-load-test',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9,zh-TW;q=0.8',
|
||||
},
|
||||
};
|
||||
|
||||
// URL helpers
|
||||
export const urls = {
|
||||
get baseUrl() {
|
||||
return config.baseUrl;
|
||||
},
|
||||
|
||||
// Public pages
|
||||
get home() {
|
||||
return `${config.baseUrl}/`;
|
||||
},
|
||||
get about() {
|
||||
return `${config.baseUrl}/about`;
|
||||
},
|
||||
get solutions() {
|
||||
return `${config.baseUrl}/solutions`;
|
||||
},
|
||||
get portfolio() {
|
||||
return `${config.baseUrl}/portfolio`;
|
||||
},
|
||||
get blog() {
|
||||
return `${config.baseUrl}/blog`;
|
||||
},
|
||||
get contact() {
|
||||
return `${config.baseUrl}/contact`;
|
||||
},
|
||||
|
||||
// API endpoints
|
||||
get api() {
|
||||
return `${config.baseUrl}/api`;
|
||||
},
|
||||
get graphql() {
|
||||
return `${config.baseUrl}/api/graphql`;
|
||||
},
|
||||
get global() {
|
||||
return `${config.baseUrl}/api/global`;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
get admin() {
|
||||
return `${config.baseUrl}/admin`;
|
||||
},
|
||||
get collections() {
|
||||
return `${config.baseUrl}/api/collections`;
|
||||
},
|
||||
get pages() {
|
||||
return `${config.baseUrl}/api/pages`;
|
||||
},
|
||||
get posts() {
|
||||
return `${config.baseUrl}/api/posts`;
|
||||
},
|
||||
get portfolioItems() {
|
||||
return `${config.baseUrl}/api/portfolio`;
|
||||
},
|
||||
get categories() {
|
||||
return `${config.baseUrl}/api/categories`;
|
||||
},
|
||||
|
||||
// Auth endpoints
|
||||
get login() {
|
||||
return `${config.baseUrl}/api/users/login`;
|
||||
},
|
||||
get logout() {
|
||||
return `${config.baseUrl}/api/users/logout`;
|
||||
},
|
||||
get me() {
|
||||
return `${config.baseUrl}/api/users/me`;
|
||||
},
|
||||
};
|
||||
|
||||
// Common checks
|
||||
export const checks = {
|
||||
// HTTP status checks
|
||||
status200: (res) => res.status === 200,
|
||||
status201: (res) => res.status === 201,
|
||||
status204: (res) => res.status === 204,
|
||||
|
||||
// Response time checks
|
||||
responseTimeFast: (res) => res.timings.duration < 200,
|
||||
responseTimeOk: (res) => res.timings.duration < 500,
|
||||
responseTimeSlow: (res) => res.timings.duration < 1000,
|
||||
|
||||
// Content checks
|
||||
hasContent: (res) => res.body.length > 0,
|
||||
hasJson: (res) => res.headers['Content-Type'].includes('application/json'),
|
||||
|
||||
// Performance checks (NFR4)
|
||||
nfr4ResponseTime: (res) => res.timings.duration < 500, // p95 < 500ms
|
||||
nfr4ApiResponseTime: (res) => res.timings.duration < 300, // API < 300ms
|
||||
};
|
||||
|
||||
// Threshold groups for different test types
|
||||
export const thresholdGroups = {
|
||||
public: {
|
||||
http_req_duration: ['p(95) < 500', 'p(99) < 1000'],
|
||||
http_req_failed: ['rate < 0.01'],
|
||||
checks: ['rate > 0.99'],
|
||||
},
|
||||
|
||||
admin: {
|
||||
http_req_duration: ['p(95) < 700', 'p(99) < 1500'],
|
||||
http_req_failed: ['rate < 0.01'],
|
||||
checks: ['rate > 0.99'],
|
||||
},
|
||||
|
||||
api: {
|
||||
http_req_duration: ['p(95) < 300', 'p(99) < 500'],
|
||||
http_req_failed: ['rate < 0.005'],
|
||||
checks: ['rate > 0.995'],
|
||||
},
|
||||
};
|
||||
405
apps/backend/tests/k6/lib/helpers.js
Normal file
405
apps/backend/tests/k6/lib/helpers.js
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* K6 Test Helpers
|
||||
* Reusable helper functions for load tests
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { urls, checks } from './config.js';
|
||||
|
||||
/**
|
||||
* Authentication helper
|
||||
*/
|
||||
export class AuthHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and store token
|
||||
*/
|
||||
login(email, password) {
|
||||
const res = http.post(`${this.baseUrl}/api/users/login`, JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const success = check(res, {
|
||||
'login successful': checks.status200,
|
||||
'received token': (r) => r.json('token') !== undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.token = res.json('token');
|
||||
}
|
||||
|
||||
return { res, success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers
|
||||
*/
|
||||
getAuthHeaders() {
|
||||
if (!this.token) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear token
|
||||
*/
|
||||
logout() {
|
||||
const res = http.post(`${this.baseUrl}/api/users/logout`, null, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
this.token = null;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page load helper
|
||||
*/
|
||||
export class PageHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page and check response
|
||||
*/
|
||||
loadPage(path) {
|
||||
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
||||
const res = http.get(url);
|
||||
|
||||
check(res, {
|
||||
[`page loaded: ${path}`]: checks.status200,
|
||||
'response time < 500ms': checks.nfr4ResponseTime,
|
||||
'has content': checks.hasContent,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple pages randomly
|
||||
*/
|
||||
loadRandomPages(pageList, count = 5) {
|
||||
const pages = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const randomPage = pageList[Math.floor(Math.random() * pageList.length)];
|
||||
pages.push(this.loadPage(randomPage));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API helper
|
||||
*/
|
||||
export class ApiHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth token
|
||||
*/
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers
|
||||
*/
|
||||
getHeaders(additionalHeaders = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request
|
||||
*/
|
||||
get(endpoint, params = {}) {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||
|
||||
const res = http.get(url.toString(), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`GET ${endpoint} successful`]: checks.status200,
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*/
|
||||
post(endpoint, data) {
|
||||
const res = http.post(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`POST ${endpoint} successful`]: (r) => [200, 201].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*/
|
||||
put(endpoint, data) {
|
||||
const res = http.put(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`PUT ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*/
|
||||
delete(endpoint) {
|
||||
const res = http.del(`${this.baseUrl}${endpoint}`, null, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`DELETE ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL query helper
|
||||
*/
|
||||
graphql(query, variables = {}) {
|
||||
const res = http.post(`${this.baseUrl}/api/graphql`, JSON.stringify({
|
||||
query,
|
||||
variables,
|
||||
}), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
'GraphQL successful': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
const body = r.json();
|
||||
return !body.errors;
|
||||
},
|
||||
'GraphQL response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Think time helper
|
||||
* Simulates real user think time between actions
|
||||
*/
|
||||
export function thinkTime(min = 1, max = 3) {
|
||||
sleep(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random item picker
|
||||
*/
|
||||
export function pickRandom(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted random picker
|
||||
*/
|
||||
export function pickWeighted(items) {
|
||||
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const item of items) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
return item.value;
|
||||
}
|
||||
}
|
||||
|
||||
return items[0].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random test data
|
||||
*/
|
||||
export const testData = {
|
||||
// Random string
|
||||
string(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// Random email
|
||||
email() {
|
||||
return `test_${this.string(8)}@example.com`;
|
||||
},
|
||||
|
||||
// Random number
|
||||
number(min = 0, max = 100) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
},
|
||||
|
||||
// Random phone
|
||||
phone() {
|
||||
return `09${this.number(10000000, 99999999)}`;
|
||||
},
|
||||
|
||||
// Random title
|
||||
title() {
|
||||
const adjectives = ['Amazing', 'Awesome', 'Brilliant', 'Creative', 'Dynamic'];
|
||||
const nouns = ['Project', 'Solution', 'Product', 'Service', 'Innovation'];
|
||||
return `${pickRandom(adjectives)} ${pickRandom(nouns)}`;
|
||||
},
|
||||
|
||||
// Random content
|
||||
content(paragraphs = 3) {
|
||||
const sentences = [
|
||||
'This is a test sentence for load testing purposes.',
|
||||
'Load testing helps identify performance bottlenecks.',
|
||||
'We ensure the system can handle expected traffic.',
|
||||
'Performance is critical for user experience.',
|
||||
'Testing under load reveals system behavior.',
|
||||
];
|
||||
|
||||
let content = '';
|
||||
for (let i = 0; i < paragraphs; i++) {
|
||||
content += '\n\n';
|
||||
for (let j = 0; j < 3; j++) {
|
||||
content += pickRandom(sentences) + ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Metrics helper
|
||||
*/
|
||||
export class MetricsHelper {
|
||||
constructor() {
|
||||
this.metrics = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric
|
||||
*/
|
||||
record(name, value) {
|
||||
if (!this.metrics[name]) {
|
||||
this.metrics[name] = {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Infinity,
|
||||
max: -Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const metric = this.metrics[name];
|
||||
metric.count++;
|
||||
metric.sum += value;
|
||||
metric.min = Math.min(metric.min, value);
|
||||
metric.max = Math.max(metric.max, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric statistics
|
||||
*/
|
||||
getStats(name) {
|
||||
const metric = this.metrics[name];
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
count: metric.count,
|
||||
sum: metric.sum,
|
||||
avg: metric.sum / metric.count,
|
||||
min: metric.min,
|
||||
max: metric.max,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print all metrics
|
||||
*/
|
||||
printAll() {
|
||||
console.log('=== Custom Metrics ===');
|
||||
Object.keys(this.metrics).forEach(name => {
|
||||
const stats = this.getStats(name);
|
||||
console.log(`${name}:`, stats);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario helper
|
||||
* Define test scenarios with weights
|
||||
*/
|
||||
export class ScenarioHelper {
|
||||
constructor() {
|
||||
this.scenarios = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scenario
|
||||
*/
|
||||
register(name, fn, weight = 1) {
|
||||
this.scenarios[name] = { fn, weight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a random scenario based on weights
|
||||
*/
|
||||
execute() {
|
||||
const scenarios = Object.entries(this.scenarios).map(([name, { fn, weight }]) => ({
|
||||
value: fn,
|
||||
weight,
|
||||
}));
|
||||
|
||||
const scenario = pickWeighted(scenarios);
|
||||
scenario();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user