diff --git a/src/components/admin/IntegrationTestRunner.tsx b/src/components/admin/IntegrationTestRunner.tsx new file mode 100644 index 00000000..ebd7df66 --- /dev/null +++ b/src/components/admin/IntegrationTestRunner.tsx @@ -0,0 +1,280 @@ +/** + * Integration Test Runner Component + * + * Superuser-only UI for running comprehensive integration tests. + * Requires AAL2 if MFA is enrolled. + */ + +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Progress } from '@/components/ui/progress'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useSuperuserGuard } from '@/hooks/useSuperuserGuard'; +import { IntegrationTestRunner as TestRunner, allTestSuites, type TestResult } from '@/lib/integrationTests'; +import { Play, Square, Download, ChevronDown, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react'; +import { toast } from 'sonner'; + +export function IntegrationTestRunner() { + const superuserGuard = useSuperuserGuard(); + const [selectedSuites, setSelectedSuites] = useState(allTestSuites.map(s => s.id)); + const [isRunning, setIsRunning] = useState(false); + const [results, setResults] = useState([]); + const [runner] = useState(() => new TestRunner((result) => { + setResults(prev => { + const existing = prev.findIndex(r => r.id === result.id); + if (existing >= 0) { + const updated = [...prev]; + updated[existing] = result; + return updated; + } + return [...prev, result]; + }); + })); + + const toggleSuite = useCallback((suiteId: string) => { + setSelectedSuites(prev => + prev.includes(suiteId) + ? prev.filter(id => id !== suiteId) + : [...prev, suiteId] + ); + }, []); + + const runTests = useCallback(async () => { + const suitesToRun = allTestSuites.filter(s => selectedSuites.includes(s.id)); + + if (suitesToRun.length === 0) { + toast.error('Please select at least one test suite'); + return; + } + + setIsRunning(true); + setResults([]); + runner.reset(); + + toast.info(`Running ${suitesToRun.length} test suite(s)...`); + + try { + await runner.runAllSuites(suitesToRun); + const summary = runner.getSummary(); + + if (summary.failed > 0) { + toast.error(`Tests completed with ${summary.failed} failure(s)`); + } else { + toast.success(`All ${summary.passed} tests passed!`); + } + } catch (error) { + console.error('Test run error:', error); + toast.error('Test run failed'); + } finally { + setIsRunning(false); + } + }, [selectedSuites, runner]); + + const stopTests = useCallback(() => { + runner.stop(); + setIsRunning(false); + toast.info('Test run stopped'); + }, [runner]); + + const exportResults = useCallback(() => { + const summary = runner.getSummary(); + const exportData = { + timestamp: new Date().toISOString(), + summary, + results: runner.getResults() + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `integration-tests-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Test results exported'); + }, [runner]); + + // Guard is handled by the route/page, no loading state needed here + + const summary = runner.getSummary(); + const totalTests = allTestSuites + .filter(s => selectedSuites.includes(s.id)) + .reduce((sum, s) => sum + s.tests.length, 0); + const progress = totalTests > 0 ? (results.length / totalTests) * 100 : 0; + + return ( +
+ + + + 🧪 Integration Test Runner + + + Superuser-only comprehensive testing system. Tests run against real database functions and edge functions. + + + + {/* Suite Selection */} +
+

Select Test Suites:

+
+ {allTestSuites.map(suite => ( +
+ toggleSuite(suite.id)} + disabled={isRunning} + /> +
+ +

+ {suite.description} +

+
+
+ ))} +
+
+ + {/* Controls */} +
+ + {isRunning && ( + + )} + {results.length > 0 && !isRunning && ( + + )} +
+ + {/* Progress */} + {results.length > 0 && ( +
+
+ Progress: {results.length}/{totalTests} tests + {progress.toFixed(0)}% +
+ +
+ )} + + {/* Summary */} + {results.length > 0 && ( +
+
+ + {summary.passed} passed +
+
+ + {summary.failed} failed +
+
+ + {summary.skipped} skipped +
+
+ + {(summary.totalDuration / 1000).toFixed(2)}s +
+
+ )} +
+
+ + {/* Results */} + {results.length > 0 && ( + + + Test Results + + + +
+ {results.map(result => ( + +
+
+ {result.status === 'pass' && } + {result.status === 'fail' && } + {result.status === 'skip' && } + {result.status === 'running' && } +
+
+
+
+

{result.name}

+

{result.suite}

+
+
+ + {result.duration}ms + + {(result.error || result.details) && ( + + + + )} +
+
+ {result.error && ( +

{result.error}

+ )} +
+
+ {(result.error || result.details) && ( + +
+ {result.error && result.stack && ( +
+

Stack Trace:

+
+                                {result.stack}
+                              
+
+ )} + {result.details && ( +
+

Details:

+
+                                {JSON.stringify(result.details, null, 2)}
+                              
+
+ )} +
+
+ )} +
+ ))} +
+
+
+
+ )} +
+ ); +} diff --git a/src/lib/integrationTests/index.ts b/src/lib/integrationTests/index.ts new file mode 100644 index 00000000..a76c9225 --- /dev/null +++ b/src/lib/integrationTests/index.ts @@ -0,0 +1,10 @@ +/** + * Integration Testing System + * + * Main exports for the comprehensive integration testing framework. + */ + +export { IntegrationTestRunner } from './testRunner'; +export { allTestSuites } from './suites'; + +export type { TestResult, Test, TestSuite } from './testRunner'; diff --git a/src/lib/integrationTests/suites/authTests.ts b/src/lib/integrationTests/suites/authTests.ts new file mode 100644 index 00000000..51ec3d65 --- /dev/null +++ b/src/lib/integrationTests/suites/authTests.ts @@ -0,0 +1,259 @@ +/** + * Authentication & Authorization Test Suite + * + * Tests auth flows, MFA enforcement, role checks, and session management. + */ + +import { supabase } from '@/integrations/supabase/client'; +import type { TestSuite, TestResult } from '../testRunner'; + +export const authTestSuite: TestSuite = { + id: 'auth', + name: 'Authentication & Authorization', + description: 'Tests for auth flows, MFA, roles, and permissions', + tests: [ + { + id: 'auth-001', + name: 'User Session Validation', + description: 'Validates current user session is valid with proper JWT structure', + run: async (): Promise => { + const startTime = Date.now(); + + try { + // Get current session + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error) throw new Error(`Session fetch failed: ${error.message}`); + if (!session) throw new Error('No active session found'); + if (!session.access_token) throw new Error('No access token in session'); + if (!session.user) throw new Error('No user in session'); + if (!session.user.id) throw new Error('No user ID in session'); + + // Validate token structure (JWT has 3 parts separated by dots) + const tokenParts = session.access_token.split('.'); + if (tokenParts.length !== 3) { + throw new Error(`Invalid JWT structure: expected 3 parts, got ${tokenParts.length}`); + } + + // Check expiration + if (session.expires_at && session.expires_at < Date.now() / 1000) { + throw new Error('Session token is expired'); + } + + const duration = Date.now() - startTime; + + return { + id: 'auth-001', + name: 'User Session Validation', + suite: 'Authentication & Authorization', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + userId: session.user.id, + email: session.user.email, + expiresAt: session.expires_at, + aal: (session.user as any).aal || 'aal1' + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'auth-001', + name: 'User Session Validation', + suite: 'Authentication & Authorization', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + }, + { + id: 'auth-002', + name: 'Role-Based Access Control (RBAC)', + description: 'Tests role checks are consistent across hooks and database functions', + run: async (): Promise => { + const startTime = Date.now(); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('No authenticated user'); + + // Query user_roles table + const { data: roles, error: rolesError } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', user.id); + + if (rolesError) throw new Error(`Failed to fetch roles: ${rolesError.message}`); + + // Test is_moderator() database function + const { data: isMod, error: modError } = await supabase + .rpc('is_moderator', { _user_id: user.id }); + + if (modError) throw new Error(`is_moderator() failed: ${modError.message}`); + + // Test is_superuser() database function + const { data: isSuper, error: superError } = await supabase + .rpc('is_superuser', { _user_id: user.id }); + + if (superError) throw new Error(`is_superuser() failed: ${superError.message}`); + + // Validate consistency + const hasModRole = roles?.some(r => ['moderator', 'admin', 'superuser'].includes(r.role)); + if (hasModRole !== isMod) { + throw new Error(`Inconsistent moderator check: has role=${hasModRole}, is_moderator()=${isMod}`); + } + + const hasSuperRole = roles?.some(r => r.role === 'superuser'); + if (hasSuperRole !== isSuper) { + throw new Error(`Inconsistent superuser check: has role=${hasSuperRole}, is_superuser()=${isSuper}`); + } + + const duration = Date.now() - startTime; + + return { + id: 'auth-002', + name: 'Role-Based Access Control (RBAC)', + suite: 'Authentication & Authorization', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + roles: roles?.map(r => r.role) || [], + isModerator: isMod, + isSuperuser: isSuper, + consistent: true + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'auth-002', + name: 'Role-Based Access Control (RBAC)', + suite: 'Authentication & Authorization', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + }, + { + id: 'auth-003', + name: 'MFA Factor Detection', + description: 'Tests MFA enrollment detection and AAL level', + run: async (): Promise => { + const startTime = Date.now(); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('No authenticated user'); + + // Get MFA factors + const { data: factors, error: factorsError } = await supabase.auth.mfa.listFactors(); + + if (factorsError) throw new Error(`Failed to list MFA factors: ${factorsError.message}`); + + const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified') || false; + const currentAAL = (user as any).aal || 'aal1'; + + const duration = Date.now() - startTime; + + return { + id: 'auth-003', + name: 'MFA Factor Detection', + suite: 'Authentication & Authorization', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + hasVerifiedMFA: hasVerifiedFactor, + currentAAL: currentAAL, + totpFactorCount: factors?.totp?.length || 0, + verifiedFactorCount: factors?.totp?.filter(f => f.status === 'verified').length || 0 + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'auth-003', + name: 'MFA Factor Detection', + suite: 'Authentication & Authorization', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + }, + { + id: 'auth-004', + name: 'Banned User Detection', + description: 'Tests banned user detection in profiles table', + run: async (): Promise => { + const startTime = Date.now(); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('No authenticated user'); + + // Query profile banned status + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', user.id) + .single(); + + if (profileError) throw new Error(`Failed to fetch profile: ${profileError.message}`); + if (!profile) throw new Error('No profile found'); + + // Test is_user_banned() database function + const { data: isBanned, error: bannedError } = await supabase + .rpc('is_user_banned', { _user_id: user.id }); + + if (bannedError) throw new Error(`is_user_banned() failed: ${bannedError.message}`); + + // Validate consistency + if (profile.banned !== isBanned) { + throw new Error(`Inconsistent banned check: profile=${profile.banned}, is_user_banned()=${isBanned}`); + } + + const duration = Date.now() - startTime; + + return { + id: 'auth-004', + name: 'Banned User Detection', + suite: 'Authentication & Authorization', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + isBanned: profile.banned, + consistent: true + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'auth-004', + name: 'Banned User Detection', + suite: 'Authentication & Authorization', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + } + ] +}; diff --git a/src/lib/integrationTests/suites/dataIntegrityTests.ts b/src/lib/integrationTests/suites/dataIntegrityTests.ts new file mode 100644 index 00000000..b0444a93 --- /dev/null +++ b/src/lib/integrationTests/suites/dataIntegrityTests.ts @@ -0,0 +1,292 @@ +/** + * Data Integrity & Constraints Test Suite + * + * Tests database constraints, RLS policies, and data integrity rules. + */ + +import { supabase } from '@/integrations/supabase/client'; +import type { TestSuite, TestResult } from '../testRunner'; + +export const dataIntegrityTestSuite: TestSuite = { + id: 'data-integrity', + name: 'Data Integrity & Constraints', + description: 'Tests database constraints, RLS policies, and data integrity', + tests: [ + { + id: 'integrity-001', + name: 'RLS Policy Enforcement - Public Read', + description: 'Validates public read access to entity tables', + run: async (): Promise => { + const startTime = Date.now(); + + try { + // Test public read access to parks + const { data: parks, error: parksError } = await supabase + .from('parks') + .select('id, name, slug') + .limit(5); + + if (parksError) throw new Error(`Parks read failed: ${parksError.message}`); + + // Test public read access to rides + const { data: rides, error: ridesError } = await supabase + .from('rides') + .select('id, name, slug') + .limit(5); + + if (ridesError) throw new Error(`Rides read failed: ${ridesError.message}`); + + // Test public read access to companies + const { data: companies, error: companiesError } = await supabase + .from('companies') + .select('id, name, slug') + .limit(5); + + if (companiesError) throw new Error(`Companies read failed: ${companiesError.message}`); + + // Test public read access to ride_models + const { data: models, error: modelsError } = await supabase + .from('ride_models') + .select('id, name, slug') + .limit(5); + + if (modelsError) throw new Error(`Ride models read failed: ${modelsError.message}`); + + const duration = Date.now() - startTime; + + return { + id: 'integrity-001', + name: 'RLS Policy Enforcement - Public Read', + suite: 'Data Integrity & Constraints', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + parksReadable: Array.isArray(parks), + ridesReadable: Array.isArray(rides), + companiesReadable: Array.isArray(companies), + rideModelsReadable: Array.isArray(models) + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'integrity-001', + name: 'RLS Policy Enforcement - Public Read', + suite: 'Data Integrity & Constraints', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + }, + { + id: 'integrity-002', + name: 'Foreign Key Constraint Enforcement', + description: 'Tests foreign key constraints prevent invalid references', + run: async (): Promise => { + const startTime = Date.now(); + + try { + // Try to create a ride with non-existent park_id + const invalidParkId = '00000000-0000-0000-0000-000000000000'; + const slug = `test-ride-${Date.now()}`; + + const { error } = await supabase + .from('rides') + .insert({ + name: 'Invalid Ride', + slug, + park_id: invalidParkId, + category: 'roller_coaster', + status: 'operating' + }); + + // This SHOULD fail with foreign key violation + if (!error) { + throw new Error('Foreign key constraint not enforced - invalid park_id was accepted'); + } + + // Verify it's a foreign key violation + if (!error.message.includes('foreign key') && !error.message.includes('violates')) { + throw new Error(`Expected foreign key error, got: ${error.message}`); + } + + const duration = Date.now() - startTime; + + return { + id: 'integrity-002', + name: 'Foreign Key Constraint Enforcement', + suite: 'Data Integrity & Constraints', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + constraintEnforced: true, + errorMessage: error.message + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'integrity-002', + name: 'Foreign Key Constraint Enforcement', + suite: 'Data Integrity & Constraints', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + }, + { + id: 'integrity-003', + name: 'Unique Constraint Enforcement', + description: 'Tests unique constraints prevent duplicate slugs', + run: async (): Promise => { + const startTime = Date.now(); + let parkId: string | null = null; + + try { + // Create a park + const slug = `unique-test-${Date.now()}`; + const { data: park, error: createError } = await supabase + .from('parks') + .insert({ + name: 'Unique Test Park', + slug, + park_type: 'theme_park', + status: 'operating' + }) + .select('id') + .single(); + + if (createError) throw new Error(`Park creation failed: ${createError.message}`); + if (!park) throw new Error('No park returned'); + + parkId = park.id; + + // Try to create another park with same slug + const { error: duplicateError } = await supabase + .from('parks') + .insert({ + name: 'Duplicate Park', + slug, // Same slug + park_type: 'theme_park', + status: 'operating' + }); + + // This SHOULD fail with unique violation + if (!duplicateError) { + throw new Error('Unique constraint not enforced - duplicate slug was accepted'); + } + + // Verify it's a unique violation + if (!duplicateError.message.includes('unique') && !duplicateError.message.includes('duplicate')) { + throw new Error(`Expected unique constraint error, got: ${duplicateError.message}`); + } + + const duration = Date.now() - startTime; + + return { + id: 'integrity-003', + name: 'Unique Constraint Enforcement', + suite: 'Data Integrity & Constraints', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + constraintEnforced: true, + errorMessage: duplicateError.message + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'integrity-003', + name: 'Unique Constraint Enforcement', + suite: 'Data Integrity & Constraints', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } finally { + if (parkId) { + await supabase.from('parks').delete().eq('id', parkId); + } + } + } + }, + { + id: 'integrity-004', + name: 'No JSONB in Entity Tables', + description: 'Validates no JSONB columns exist in entity tables (per requirements)', + run: async (): Promise => { + const startTime = Date.now(); + + try { + // Sample actual data and check structure (information_schema not accessible via RLS) + const { data: parks } = await supabase.from('parks').select('*').limit(1); + const { data: rides } = await supabase.from('rides').select('*').limit(1); + const { data: companies } = await supabase.from('companies').select('*').limit(1); + const { data: models } = await supabase.from('ride_models').select('*').limit(1); + + // Check if any fields appear to be JSONB objects + const hasJsonbFields = [parks, rides, companies, models].some(dataset => { + if (!dataset || dataset.length === 0) return false; + const record = dataset[0] as any; + return Object.keys(record).some(key => { + const val = record[key]; + // Check if value is a plain object (not Date, not Array, not null) + if (val === null || val === undefined) return false; + if (typeof val !== 'object') return false; + if (Array.isArray(val)) return false; + // Check if it's a Date by checking if it has getTime method + if (val && typeof val.getTime === 'function') return false; + // If we get here, it's likely a JSONB object + return true; + }); + }); + + if (hasJsonbFields) { + throw new Error('Found JSONB-like fields in entity tables'); + } + + const duration = Date.now() - startTime; + + return { + id: 'integrity-004', + name: 'No JSONB in Entity Tables', + suite: 'Data Integrity & Constraints', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + noJsonbColumns: true, + validation: 'Entity tables use relational structure only' + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'integrity-004', + name: 'No JSONB in Entity Tables', + suite: 'Data Integrity & Constraints', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } + } + } + ] +}; diff --git a/src/lib/integrationTests/suites/index.ts b/src/lib/integrationTests/suites/index.ts new file mode 100644 index 00000000..6f4f6435 --- /dev/null +++ b/src/lib/integrationTests/suites/index.ts @@ -0,0 +1,22 @@ +/** + * Integration Test Suites Export + * + * Exports all test suites for the integration testing system. + */ + +import { authTestSuite } from './authTests'; +import { versioningTestSuite } from './versioningTests'; +import { dataIntegrityTestSuite } from './dataIntegrityTests'; +import type { TestSuite } from '../testRunner'; + +export const allTestSuites: TestSuite[] = [ + authTestSuite, + versioningTestSuite, + dataIntegrityTestSuite, +]; + +export { + authTestSuite, + versioningTestSuite, + dataIntegrityTestSuite, +}; diff --git a/src/lib/integrationTests/suites/versioningTests.ts b/src/lib/integrationTests/suites/versioningTests.ts new file mode 100644 index 00000000..404a42aa --- /dev/null +++ b/src/lib/integrationTests/suites/versioningTests.ts @@ -0,0 +1,456 @@ +/** + * Versioning & Rollback Test Suite + * + * Tests the complete versioning system end-to-end including automatic + * version creation, attribution, and rollback functionality. + */ + +import { supabase } from '@/integrations/supabase/client'; +import type { TestSuite, TestResult } from '../testRunner'; + +export const versioningTestSuite: TestSuite = { + id: 'versioning', + name: 'Versioning & Rollback', + description: 'Tests version creation, attribution, rollback, and cleanup', + tests: [ + { + id: 'version-001', + name: 'Automatic Version Creation on Insert', + description: 'Verifies version 1 is created automatically when entity is created', + run: async (): Promise => { + const startTime = Date.now(); + let parkId: string | null = null; + + try { + // Create a park + const slug = `test-park-${Date.now()}`; + const { data: park, error: createError } = await supabase + .from('parks') + .insert({ + name: 'Version Test Park', + slug, + park_type: 'theme_park', + status: 'operating' + }) + .select('id') + .single(); + + if (createError) throw new Error(`Park creation failed: ${createError.message}`); + if (!park) throw new Error('No park returned from insert'); + + parkId = park.id; + + // Wait a bit for trigger to execute + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check version was created + const { data: version, error: versionError } = await supabase + .from('park_versions') + .select('*') + .eq('park_id', park.id) + .eq('version_number', 1) + .single(); + + if (versionError) throw new Error(`Version query failed: ${versionError.message}`); + if (!version) throw new Error('Version 1 not created'); + if (version.name !== 'Version Test Park') throw new Error('Version has incorrect name'); + if (version.change_type !== 'created') throw new Error(`Expected change_type "created", got "${version.change_type}"`); + if (!version.is_current) throw new Error('Version is not marked as current'); + + const duration = Date.now() - startTime; + + return { + id: 'version-001', + name: 'Automatic Version Creation on Insert', + suite: 'Versioning & Rollback', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + parkId: park.id, + versionNumber: version.version_number, + changeType: version.change_type, + isCurrent: version.is_current + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'version-001', + name: 'Automatic Version Creation on Insert', + suite: 'Versioning & Rollback', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } finally { + // Cleanup + if (parkId) { + await supabase.from('parks').delete().eq('id', parkId); + } + } + } + }, + { + id: 'version-002', + name: 'Automatic Version Creation on Update', + description: 'Verifies version 2 is created when entity is updated', + run: async (): Promise => { + const startTime = Date.now(); + let parkId: string | null = null; + + try { + // Create a park + const slug = `test-park-${Date.now()}`; + const { data: park, error: createError } = await supabase + .from('parks') + .insert({ + name: 'Original Name', + slug, + park_type: 'theme_park', + status: 'operating' + }) + .select('id') + .single(); + + if (createError) throw new Error(`Park creation failed: ${createError.message}`); + if (!park) throw new Error('No park returned'); + + parkId = park.id; + + // Wait for version 1 + await new Promise(resolve => setTimeout(resolve, 100)); + + // Update the park + const { error: updateError } = await supabase + .from('parks') + .update({ name: 'Updated Name' }) + .eq('id', park.id); + + if (updateError) throw new Error(`Park update failed: ${updateError.message}`); + + // Wait for version 2 + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check version 2 exists + const { data: v2, error: v2Error } = await supabase + .from('park_versions') + .select('*') + .eq('park_id', park.id) + .eq('version_number', 2) + .single(); + + if (v2Error) throw new Error(`Version 2 query failed: ${v2Error.message}`); + if (!v2) throw new Error('Version 2 not created'); + if (v2.name !== 'Updated Name') throw new Error('Version 2 has incorrect name'); + if (v2.change_type !== 'updated') throw new Error(`Expected change_type "updated", got "${v2.change_type}"`); + if (!v2.is_current) throw new Error('Version 2 is not marked as current'); + + // Check version 1 is no longer current + const { data: v1, error: v1Error } = await supabase + .from('park_versions') + .select('is_current') + .eq('park_id', park.id) + .eq('version_number', 1) + .single(); + + if (v1Error) throw new Error(`Version 1 query failed: ${v1Error.message}`); + if (v1?.is_current) throw new Error('Version 1 is still marked as current'); + + const duration = Date.now() - startTime; + + return { + id: 'version-002', + name: 'Automatic Version Creation on Update', + suite: 'Versioning & Rollback', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + parkId: park.id, + v1IsCurrent: v1?.is_current, + v2IsCurrent: v2.is_current, + v2ChangeType: v2.change_type + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'version-002', + name: 'Automatic Version Creation on Update', + suite: 'Versioning & Rollback', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } finally { + if (parkId) { + await supabase.from('parks').delete().eq('id', parkId); + } + } + } + }, + { + id: 'version-003', + name: 'Rollback Authorization Check', + description: 'Tests that rollback_to_version requires moderator role', + run: async (): Promise => { + const startTime = Date.now(); + let parkId: string | null = null; + + try { + // Create a park + const slug = `test-park-${Date.now()}`; + const { data: park, error: createError } = await supabase + .from('parks') + .insert({ + name: 'Rollback Test Park', + slug, + park_type: 'theme_park', + status: 'operating' + }) + .select('id') + .single(); + + if (createError) throw new Error(`Park creation failed: ${createError.message}`); + if (!park) throw new Error('No park returned'); + + parkId = park.id; + + await new Promise(resolve => setTimeout(resolve, 100)); + + // Get version 1 ID + const { data: v1, error: v1Error } = await supabase + .from('park_versions') + .select('version_id') + .eq('park_id', park.id) + .eq('version_number', 1) + .single(); + + if (v1Error || !v1) throw new Error('Version 1 not found'); + + // Check current user is moderator + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('No authenticated user'); + + const { data: isMod } = await supabase.rpc('is_moderator', { _user_id: user.id }); + + // Try rollback + const { error: rollbackError } = await supabase.rpc('rollback_to_version', { + p_entity_type: 'park', + p_entity_id: park.id, + p_target_version_id: v1.version_id, + p_changed_by: user.id, + p_reason: 'Authorization test' + }); + + // If user is moderator, rollback should succeed + // If not, rollback should fail with permission error + if (isMod && rollbackError) { + throw new Error(`Rollback failed for moderator: ${rollbackError.message}`); + } + + if (!isMod && !rollbackError) { + throw new Error('Rollback succeeded for non-moderator (should have failed)'); + } + + const duration = Date.now() - startTime; + + return { + id: 'version-003', + name: 'Rollback Authorization Check', + suite: 'Versioning & Rollback', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + userIsModerator: isMod, + rollbackBlocked: !isMod && !!rollbackError, + authorizationEnforced: true + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'version-003', + name: 'Rollback Authorization Check', + suite: 'Versioning & Rollback', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } finally { + if (parkId) { + await supabase.from('parks').delete().eq('id', parkId); + } + } + } + }, + { + id: 'version-004', + name: 'Complete Rollback Flow', + description: 'Tests end-to-end rollback with version 3 creation', + run: async (): Promise => { + const startTime = Date.now(); + let parkId: string | null = null; + + try { + // Check if user is moderator + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('No authenticated user'); + + const { data: isMod } = await supabase.rpc('is_moderator', { _user_id: user.id }); + + if (!isMod) { + // Skip test if not moderator + const duration = Date.now() - startTime; + return { + id: 'version-004', + name: 'Complete Rollback Flow', + suite: 'Versioning & Rollback', + status: 'skip', + duration, + timestamp: new Date().toISOString(), + details: { reason: 'User is not a moderator, test requires moderator role' } + }; + } + + // Create park + const slug = `test-park-${Date.now()}`; + const { data: park, error: createError } = await supabase + .from('parks') + .insert({ + name: 'Original Name', + slug, + park_type: 'theme_park', + status: 'operating', + description: 'Original Description' + }) + .select('id') + .single(); + + if (createError) throw new Error(`Park creation failed: ${createError.message}`); + if (!park) throw new Error('No park returned'); + + parkId = park.id; + await new Promise(resolve => setTimeout(resolve, 100)); + + // Get version 1 + const { data: v1, error: v1Error } = await supabase + .from('park_versions') + .select('version_id, name, description') + .eq('park_id', park.id) + .eq('version_number', 1) + .single(); + + if (v1Error || !v1) throw new Error('Version 1 not found'); + + // Update park + const { error: updateError } = await supabase + .from('parks') + .update({ name: 'Modified Name', description: 'Modified Description' }) + .eq('id', park.id); + + if (updateError) throw new Error(`Park update failed: ${updateError.message}`); + + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify version 2 + const { data: v2 } = await supabase + .from('park_versions') + .select('version_number, name') + .eq('park_id', park.id) + .eq('version_number', 2) + .single(); + + if (!v2) throw new Error('Version 2 not created'); + if (v2.name !== 'Modified Name') throw new Error('Version 2 has incorrect data'); + + // Rollback to version 1 + const { error: rollbackError } = await supabase.rpc('rollback_to_version', { + p_entity_type: 'park', + p_entity_id: park.id, + p_target_version_id: v1.version_id, + p_changed_by: user.id, + p_reason: 'Integration test rollback' + }); + + if (rollbackError) throw new Error(`Rollback failed: ${rollbackError.message}`); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // Verify park data restored + const { data: restored, error: restoredError } = await supabase + .from('parks') + .select('name, description') + .eq('id', park.id) + .single(); + + if (restoredError) throw new Error(`Failed to fetch restored park: ${restoredError.message}`); + if (!restored) throw new Error('Restored park not found'); + if (restored.name !== 'Original Name') { + throw new Error(`Rollback failed: expected "Original Name", got "${restored.name}"`); + } + if (restored.description !== 'Original Description') { + throw new Error(`Description not restored: expected "Original Description", got "${restored.description}"`); + } + + // Verify version 3 created with change_type = 'restored' + const { data: v3, error: v3Error } = await supabase + .from('park_versions') + .select('*') + .eq('park_id', park.id) + .eq('version_number', 3) + .single(); + + if (v3Error || !v3) throw new Error('Version 3 (restored) not created'); + if (v3.change_type !== 'restored') { + throw new Error(`Expected change_type "restored", got "${v3.change_type}"`); + } + if (v3.name !== 'Original Name') throw new Error('Version 3 has incorrect data'); + if (!v3.is_current) throw new Error('Version 3 is not marked as current'); + + const duration = Date.now() - startTime; + + return { + id: 'version-004', + name: 'Complete Rollback Flow', + suite: 'Versioning & Rollback', + status: 'pass', + duration, + timestamp: new Date().toISOString(), + details: { + parkId: park.id, + versionsCreated: 3, + dataRestored: true, + v3ChangeType: v3.change_type, + v3IsCurrent: v3.is_current + } + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + id: 'version-004', + name: 'Complete Rollback Flow', + suite: 'Versioning & Rollback', + status: 'fail', + duration, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }; + } finally { + if (parkId) { + await supabase.from('parks').delete().eq('id', parkId); + } + } + } + } + ] +}; diff --git a/src/lib/integrationTests/testRunner.ts b/src/lib/integrationTests/testRunner.ts new file mode 100644 index 00000000..f51b1141 --- /dev/null +++ b/src/lib/integrationTests/testRunner.ts @@ -0,0 +1,193 @@ +/** + * Integration Test Runner + * + * Core infrastructure for running comprehensive integration tests. + * Tests run against real database functions, edge functions, and API endpoints. + */ + +export interface TestResult { + id: string; + name: string; + suite: string; + status: 'pass' | 'fail' | 'skip' | 'running'; + duration: number; // milliseconds + error?: string; + details?: any; + timestamp: string; + stack?: string; +} + +export interface Test { + id: string; + name: string; + description: string; + run: () => Promise; +} + +export interface TestSuite { + id: string; + name: string; + description: string; + tests: Test[]; +} + +export class IntegrationTestRunner { + private results: TestResult[] = []; + private isRunning = false; + private shouldStop = false; + private onProgress?: (result: TestResult) => void; + + constructor(onProgress?: (result: TestResult) => void) { + this.onProgress = onProgress; + } + + /** + * Run a single test with error handling + */ + async runTest(test: Test, suiteName: string): Promise { + if (this.shouldStop) { + return { + id: test.id, + name: test.name, + suite: suiteName, + status: 'skip', + duration: 0, + timestamp: new Date().toISOString(), + details: { reason: 'Test run stopped by user' } + }; + } + + // Mark as running + const runningResult: TestResult = { + id: test.id, + name: test.name, + suite: suiteName, + status: 'running', + duration: 0, + timestamp: new Date().toISOString(), + }; + + if (this.onProgress) { + this.onProgress(runningResult); + } + + try { + const result = await test.run(); + this.results.push(result); + + if (this.onProgress) { + this.onProgress(result); + } + + return result; + } catch (error) { + const failResult: TestResult = { + id: test.id, + name: test.name, + suite: suiteName, + status: 'fail', + duration: 0, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + + this.results.push(failResult); + + if (this.onProgress) { + this.onProgress(failResult); + } + + return failResult; + } + } + + /** + * Run all tests in a suite + */ + async runSuite(suite: TestSuite): Promise { + const suiteResults: TestResult[] = []; + + for (const test of suite.tests) { + const result = await this.runTest(test, suite.name); + suiteResults.push(result); + + if (this.shouldStop) { + break; + } + } + + return suiteResults; + } + + /** + * Run all suites sequentially + */ + async runAllSuites(suites: TestSuite[]): Promise { + this.results = []; + this.isRunning = true; + this.shouldStop = false; + + for (const suite of suites) { + await this.runSuite(suite); + + if (this.shouldStop) { + break; + } + } + + this.isRunning = false; + return this.results; + } + + /** + * Stop the test run + */ + stop(): void { + this.shouldStop = true; + } + + /** + * Get all results + */ + getResults(): TestResult[] { + return this.results; + } + + /** + * Get summary statistics + */ + getSummary(): { + total: number; + passed: number; + failed: number; + skipped: number; + running: number; + totalDuration: number; + } { + const total = this.results.length; + const passed = this.results.filter(r => r.status === 'pass').length; + const failed = this.results.filter(r => r.status === 'fail').length; + const skipped = this.results.filter(r => r.status === 'skip').length; + const running = this.results.filter(r => r.status === 'running').length; + const totalDuration = this.results.reduce((sum, r) => sum + r.duration, 0); + + return { total, passed, failed, skipped, running, totalDuration }; + } + + /** + * Check if runner is currently running + */ + getIsRunning(): boolean { + return this.isRunning; + } + + /** + * Reset runner state + */ + reset(): void { + this.results = []; + this.isRunning = false; + this.shouldStop = false; + } +} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index b6ed9727..33e37896 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -13,7 +13,8 @@ import { useUserRole } from '@/hooks/useUserRole'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility'; import { TestDataGenerator } from '@/components/admin/TestDataGenerator'; -import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock } from 'lucide-react'; +import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner'; +import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube } from 'lucide-react'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; export default function AdminSettings() { @@ -477,7 +478,7 @@ export default function AdminSettings() { - + Moderation @@ -502,6 +503,10 @@ export default function AdminSettings() { Testing + + + Integration Tests + @@ -639,6 +644,10 @@ export default function AdminSettings() { + + + +