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:
2026-01-31 17:20:35 +08:00
parent 0846318d6e
commit 7fd73e0e3d
48 changed files with 19497 additions and 5261 deletions

View 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'],
},
};

View 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();
}
}