mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 02:31:12 -05:00
Approve tool use
This commit is contained in:
@@ -2,24 +2,17 @@
|
||||
* E2E Tests for Moderation Lock Management
|
||||
*
|
||||
* Browser-based tests for lock UI and interactions
|
||||
* Uses authenticated state from global setup
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Helper function to login as moderator (adjust based on your auth setup)
|
||||
async function loginAsModerator(page: any) {
|
||||
await page.goto('/login');
|
||||
// TODO: Add your actual login steps here
|
||||
// For example:
|
||||
// await page.fill('[name="email"]', 'moderator@example.com');
|
||||
// await page.fill('[name="password"]', 'password123');
|
||||
// await page.click('button[type="submit"]');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
// Configure test to use moderator auth state
|
||||
test.use({ storageState: '.auth/moderator.json' });
|
||||
|
||||
test.describe('Moderation Lock Management UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsModerator(page);
|
||||
// Navigate to moderation queue (already authenticated via storageState)
|
||||
await page.goto('/moderation/queue');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
3
tests/fixtures/database.ts
vendored
3
tests/fixtures/database.ts
vendored
@@ -85,6 +85,7 @@ export async function cleanupTestData(): Promise<void> {
|
||||
|
||||
// Delete in dependency order (child tables first)
|
||||
const tables = [
|
||||
'moderation_audit_log',
|
||||
'ride_photos',
|
||||
'park_photos',
|
||||
'submission_items',
|
||||
@@ -190,7 +191,7 @@ export async function getTestDataStats(): Promise<Record<string, number>> {
|
||||
throw new Error('Service role key not configured');
|
||||
}
|
||||
|
||||
const tables = ['parks', 'rides', 'companies', 'ride_models', 'content_submissions'];
|
||||
const tables = ['parks', 'rides', 'companies', 'ride_models', 'content_submissions', 'moderation_audit_log'];
|
||||
const stats: Record<string, number> = {};
|
||||
|
||||
for (const table of tables) {
|
||||
|
||||
249
tests/integration/moderation-security.test.ts
Normal file
249
tests/integration/moderation-security.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Integration Tests for Moderation Security
|
||||
*
|
||||
* Tests backend validation, lock enforcement, and audit logging
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { setupTestUser, supabaseAdmin, cleanupTestData } from '../fixtures/database';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
|
||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4';
|
||||
|
||||
test.describe('Moderation Security', () => {
|
||||
test.beforeAll(async () => {
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
test('should validate moderator role before allowing actions', async () => {
|
||||
// Create a regular user (not moderator)
|
||||
const { userId, email } = await setupTestUser(
|
||||
'regular-user@test.com',
|
||||
'TestPassword123!',
|
||||
'user'
|
||||
);
|
||||
|
||||
// Create authenticated client for regular user
|
||||
const userClient = createClient(supabaseUrl, supabaseAnonKey);
|
||||
await userClient.auth.signInWithPassword({
|
||||
email,
|
||||
password: 'TestPassword123!',
|
||||
});
|
||||
|
||||
// Create a test submission
|
||||
if (!supabaseAdmin) {
|
||||
throw new Error('Admin client not available');
|
||||
}
|
||||
|
||||
const { data: submission } = await supabaseAdmin
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
submission_type: 'review',
|
||||
status: 'pending',
|
||||
submitted_by: userId,
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
expect(submission).toBeTruthy();
|
||||
|
||||
// Try to call validation function as regular user (should fail)
|
||||
const { data, error } = await userClient.rpc('validate_moderation_action', {
|
||||
_submission_id: submission!.id,
|
||||
_user_id: userId,
|
||||
_action: 'approve',
|
||||
});
|
||||
|
||||
// Should fail with authorization error
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toContain('Unauthorized');
|
||||
|
||||
await userClient.auth.signOut();
|
||||
});
|
||||
|
||||
test('should enforce lock when another moderator has claimed submission', async () => {
|
||||
// Create two moderators
|
||||
const { userId: mod1Id, email: mod1Email } = await setupTestUser(
|
||||
'moderator1@test.com',
|
||||
'TestPassword123!',
|
||||
'moderator'
|
||||
);
|
||||
|
||||
const { userId: mod2Id, email: mod2Email } = await setupTestUser(
|
||||
'moderator2@test.com',
|
||||
'TestPassword123!',
|
||||
'moderator'
|
||||
);
|
||||
|
||||
// Create submission
|
||||
if (!supabaseAdmin) {
|
||||
throw new Error('Admin client not available');
|
||||
}
|
||||
|
||||
const { data: submission } = await supabaseAdmin
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
submission_type: 'review',
|
||||
status: 'pending',
|
||||
submitted_by: mod1Id,
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
// Moderator 1 claims the submission
|
||||
const mod1Client = createClient(supabaseUrl, supabaseAnonKey);
|
||||
await mod1Client.auth.signInWithPassword({
|
||||
email: mod1Email,
|
||||
password: 'TestPassword123!',
|
||||
});
|
||||
|
||||
await mod1Client
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
assigned_to: mod1Id,
|
||||
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
})
|
||||
.eq('id', submission!.id);
|
||||
|
||||
// Moderator 2 tries to validate action (should fail due to lock)
|
||||
const mod2Client = createClient(supabaseUrl, supabaseAnonKey);
|
||||
await mod2Client.auth.signInWithPassword({
|
||||
email: mod2Email,
|
||||
password: 'TestPassword123!',
|
||||
});
|
||||
|
||||
const { data, error } = await mod2Client.rpc('validate_moderation_action', {
|
||||
_submission_id: submission!.id,
|
||||
_user_id: mod2Id,
|
||||
_action: 'approve',
|
||||
});
|
||||
|
||||
// Should fail with lock error
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toContain('locked by another moderator');
|
||||
|
||||
await mod1Client.auth.signOut();
|
||||
await mod2Client.auth.signOut();
|
||||
});
|
||||
|
||||
test('should create audit log entries for moderation actions', async () => {
|
||||
const { userId, email } = await setupTestUser(
|
||||
'audit-moderator@test.com',
|
||||
'TestPassword123!',
|
||||
'moderator'
|
||||
);
|
||||
|
||||
if (!supabaseAdmin) {
|
||||
throw new Error('Admin client not available');
|
||||
}
|
||||
|
||||
// Create submission
|
||||
const { data: submission } = await supabaseAdmin
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
submission_type: 'review',
|
||||
status: 'pending',
|
||||
submitted_by: userId,
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
const modClient = createClient(supabaseUrl, supabaseAnonKey);
|
||||
await modClient.auth.signInWithPassword({
|
||||
email,
|
||||
password: 'TestPassword123!',
|
||||
});
|
||||
|
||||
// Claim submission (should trigger audit log)
|
||||
await modClient
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
assigned_to: userId,
|
||||
locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
})
|
||||
.eq('id', submission!.id);
|
||||
|
||||
// Wait a moment for trigger to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Check audit log
|
||||
const { data: auditLogs } = await supabaseAdmin
|
||||
.from('moderation_audit_log')
|
||||
.select('*')
|
||||
.eq('submission_id', submission!.id)
|
||||
.eq('action', 'claim');
|
||||
|
||||
expect(auditLogs).toBeTruthy();
|
||||
expect(auditLogs!.length).toBeGreaterThan(0);
|
||||
expect(auditLogs![0].moderator_id).toBe(userId);
|
||||
|
||||
await modClient.auth.signOut();
|
||||
});
|
||||
|
||||
test('should enforce rate limiting (10 actions per minute)', async () => {
|
||||
const { userId, email } = await setupTestUser(
|
||||
'rate-limit-mod@test.com',
|
||||
'TestPassword123!',
|
||||
'moderator'
|
||||
);
|
||||
|
||||
if (!supabaseAdmin) {
|
||||
throw new Error('Admin client not available');
|
||||
}
|
||||
|
||||
const modClient = createClient(supabaseUrl, supabaseAnonKey);
|
||||
await modClient.auth.signInWithPassword({
|
||||
email,
|
||||
password: 'TestPassword123!',
|
||||
});
|
||||
|
||||
// Create 11 submissions
|
||||
const submissions = [];
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const { data } = await supabaseAdmin
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
submission_type: 'review',
|
||||
status: 'pending',
|
||||
submitted_by: userId,
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
submissions.push(data);
|
||||
}
|
||||
|
||||
// Try to validate 11 actions (should fail on 11th)
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const submission of submissions) {
|
||||
const { error } = await modClient.rpc('validate_moderation_action', {
|
||||
_submission_id: submission!.id,
|
||||
_user_id: userId,
|
||||
_action: 'approve',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
failCount++;
|
||||
expect(error.message).toContain('Rate limit exceeded');
|
||||
} else {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Should have at least one failure due to rate limiting
|
||||
expect(failCount).toBeGreaterThan(0);
|
||||
expect(successCount).toBeLessThanOrEqual(10);
|
||||
|
||||
await modClient.auth.signOut();
|
||||
});
|
||||
});
|
||||
117
tests/unit/sanitize.test.ts
Normal file
117
tests/unit/sanitize.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Unit Tests for Sanitization Utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@playwright/test';
|
||||
import { sanitizeHTML, sanitizeURL, sanitizePlainText, containsSuspiciousContent } from '@/lib/sanitize';
|
||||
|
||||
describe('sanitizeURL', () => {
|
||||
it('should allow valid http URLs', () => {
|
||||
expect(sanitizeURL('http://example.com')).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('should allow valid https URLs', () => {
|
||||
expect(sanitizeURL('https://example.com/path?query=value')).toBe('https://example.com/path?query=value');
|
||||
});
|
||||
|
||||
it('should allow valid mailto URLs', () => {
|
||||
expect(sanitizeURL('mailto:user@example.com')).toBe('mailto:user@example.com');
|
||||
});
|
||||
|
||||
it('should block javascript: protocol', () => {
|
||||
expect(sanitizeURL('javascript:alert("XSS")')).toBe('#');
|
||||
});
|
||||
|
||||
it('should block data: protocol', () => {
|
||||
expect(sanitizeURL('data:text/html,<script>alert("XSS")</script>')).toBe('#');
|
||||
});
|
||||
|
||||
it('should handle invalid URLs', () => {
|
||||
expect(sanitizeURL('not a url')).toBe('#');
|
||||
expect(sanitizeURL('')).toBe('#');
|
||||
});
|
||||
|
||||
it('should handle null/undefined gracefully', () => {
|
||||
expect(sanitizeURL(null as any)).toBe('#');
|
||||
expect(sanitizeURL(undefined as any)).toBe('#');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizePlainText', () => {
|
||||
it('should escape HTML entities', () => {
|
||||
expect(sanitizePlainText('<script>alert("XSS")</script>'))
|
||||
.toBe('<script>alert("XSS")</script>');
|
||||
});
|
||||
|
||||
it('should escape ampersands', () => {
|
||||
expect(sanitizePlainText('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
});
|
||||
|
||||
it('should escape quotes', () => {
|
||||
expect(sanitizePlainText('"Hello" \'World\'')).toContain('"');
|
||||
expect(sanitizePlainText('"Hello" \'World\'')).toContain(''');
|
||||
});
|
||||
|
||||
it('should handle plain text without changes', () => {
|
||||
expect(sanitizePlainText('Hello World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(sanitizePlainText('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsSuspiciousContent', () => {
|
||||
it('should detect script tags', () => {
|
||||
expect(containsSuspiciousContent('<script>alert(1)</script>')).toBe(true);
|
||||
expect(containsSuspiciousContent('<SCRIPT>alert(1)</SCRIPT>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect javascript: protocol', () => {
|
||||
expect(containsSuspiciousContent('javascript:alert(1)')).toBe(true);
|
||||
expect(containsSuspiciousContent('JAVASCRIPT:alert(1)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect event handlers', () => {
|
||||
expect(containsSuspiciousContent('<img onerror="alert(1)">')).toBe(true);
|
||||
expect(containsSuspiciousContent('<div onclick="alert(1)">')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect iframes', () => {
|
||||
expect(containsSuspiciousContent('<iframe src="evil.com"></iframe>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag safe content', () => {
|
||||
expect(containsSuspiciousContent('This is a safe message')).toBe(false);
|
||||
expect(containsSuspiciousContent('Email: user@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHTML', () => {
|
||||
it('should allow safe tags', () => {
|
||||
const html = '<p>Hello <strong>world</strong></p>';
|
||||
const result = sanitizeHTML(html);
|
||||
expect(result).toContain('<p>');
|
||||
expect(result).toContain('<strong>');
|
||||
});
|
||||
|
||||
it('should remove script tags', () => {
|
||||
const html = '<p>Hello</p><script>alert("XSS")</script>';
|
||||
const result = sanitizeHTML(html);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<p>');
|
||||
});
|
||||
|
||||
it('should remove event handlers', () => {
|
||||
const html = '<p onclick="alert(1)">Click me</p>';
|
||||
const result = sanitizeHTML(html);
|
||||
expect(result).not.toContain('onclick');
|
||||
});
|
||||
|
||||
it('should allow safe links', () => {
|
||||
const html = '<a href="https://example.com" target="_blank" rel="noopener">Link</a>';
|
||||
const result = sanitizeHTML(html);
|
||||
expect(result).toContain('href');
|
||||
expect(result).toContain('target');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user