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:
232
apps/backend/tests/k6/admin-operations.js
Normal file
232
apps/backend/tests/k6/admin-operations.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Admin Operations Load Test
|
||||
*
|
||||
* Simulates 20 concurrent admin users performing management operations
|
||||
* Tests:
|
||||
* - Login
|
||||
* - List collections (Pages, Posts, Portfolio)
|
||||
* - View/edit items
|
||||
* - Create new items
|
||||
* - Delete items
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 700ms (slightly more lenient for admin)
|
||||
* - Error rate < 1%
|
||||
* - 20 concurrent users sustained for 3 minutes
|
||||
*/
|
||||
|
||||
import { check, group } from 'k6';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { AuthHelper, ApiHelper, thinkTime, testData } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.adminOperations,
|
||||
thresholds: thresholdGroups.admin,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// Store auth token per VU
|
||||
let authToken = null;
|
||||
let apiHelper = null;
|
||||
|
||||
/**
|
||||
* Setup and login - runs once per VU
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== Admin Operations Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log(`Admin: ${config.adminEmail}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Login function
|
||||
*/
|
||||
function login() {
|
||||
const auth = new AuthHelper(config.baseUrl);
|
||||
const { success } = auth.login(config.adminEmail, config.adminPassword);
|
||||
|
||||
if (!success) {
|
||||
console.error('Login failed!');
|
||||
return null;
|
||||
}
|
||||
|
||||
apiHelper = new ApiHelper(config.baseUrl);
|
||||
apiHelper.setToken(auth.token);
|
||||
return auth.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Login if not authenticated
|
||||
if (!authToken) {
|
||||
group('Admin Login', () => {
|
||||
authToken = login();
|
||||
thinkTime(1, 2);
|
||||
});
|
||||
|
||||
if (!authToken) {
|
||||
// Cannot proceed without auth
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 1: Browse collections (read operations)
|
||||
group('List Collections', () => {
|
||||
// List pages
|
||||
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
|
||||
// List posts
|
||||
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
|
||||
// List portfolio
|
||||
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
});
|
||||
|
||||
// Scenario 2: View specific items (read operations)
|
||||
group('View Items', () => {
|
||||
// Try to view first item from each collection
|
||||
try {
|
||||
const pages = apiHelper.get('/pages', { limit: 1, depth: 0 });
|
||||
if (pages.status === 200 && pages.json('totalDocs') > 0) {
|
||||
const firstId = pages.json('docs')[0].id;
|
||||
apiHelper.get(`/pages/${firstId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
|
||||
const posts = apiHelper.get('/posts', { limit: 1, depth: 0 });
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const firstId = posts.json('docs')[0].id;
|
||||
apiHelper.get(`/posts/${firstId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
// Items might not exist
|
||||
console.log('No items to view:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 3: Create new content (write operations)
|
||||
group('Create Content', () => {
|
||||
// 20% chance to create a test post
|
||||
if (Math.random() < 0.2) {
|
||||
const newPost = {
|
||||
title: `Load Test Post ${testData.string(6)}`,
|
||||
content: testData.content(2),
|
||||
status: 'draft', // Save as draft to avoid publishing
|
||||
};
|
||||
|
||||
const res = apiHelper.post('/posts', newPost);
|
||||
|
||||
if (res.status === 201 || res.status === 200) {
|
||||
const postId = res.json('doc')?.id;
|
||||
|
||||
// Store for potential cleanup (in real scenario)
|
||||
if (postId) {
|
||||
console.log(`Created post: ${postId}`);
|
||||
}
|
||||
}
|
||||
|
||||
thinkTime(2, 3);
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 4: Update content (write operations)
|
||||
group('Update Content', () => {
|
||||
// 30% chance to update a post
|
||||
if (Math.random() < 0.3) {
|
||||
try {
|
||||
// Get a random post
|
||||
const posts = apiHelper.get('/posts', { limit: 20, depth: 0, where: { status: { equals: 'draft' } } });
|
||||
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const docs = posts.json('docs');
|
||||
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||
const postId = randomPost.id;
|
||||
|
||||
// Update the post
|
||||
const updateData = {
|
||||
title: `Updated ${randomPost.title}`,
|
||||
};
|
||||
|
||||
apiHelper.put(`/posts/${postId}`, updateData);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Update failed:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 5: Delete test content (cleanup operations)
|
||||
group('Delete Content', () => {
|
||||
// 10% chance to delete a draft post
|
||||
if (Math.random() < 0.1) {
|
||||
try {
|
||||
// Get draft posts (likely from load test)
|
||||
const posts = apiHelper.get('/posts', {
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
where: {
|
||||
status: { equals: 'draft' },
|
||||
title: { like: 'Load Test Post' },
|
||||
},
|
||||
});
|
||||
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const docs = posts.json('docs');
|
||||
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||
const postId = randomPost.id;
|
||||
|
||||
// Delete the post
|
||||
apiHelper.delete(`/posts/${postId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Delete failed:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 6: Use GraphQL API
|
||||
group('GraphQL Operations', () => {
|
||||
// 40% chance to use GraphQL
|
||||
if (Math.random() < 0.4) {
|
||||
const query = `
|
||||
query {
|
||||
Posts(limit: 5) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
apiHelper.graphql(query);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Think time before next iteration
|
||||
thinkTime(3, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function - runs once after test
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== Admin Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 700ms');
|
||||
console.log('- Error rate < 1%');
|
||||
console.log('- 20 concurrent admin users sustained');
|
||||
console.log('Note: Any draft posts created were left in the system for manual review');
|
||||
}
|
||||
Reference in New Issue
Block a user