diff --git a/e2e/auth/complete-flow.spec.ts b/e2e/auth/complete-flow.spec.ts new file mode 100644 index 000000000..a9af61419 --- /dev/null +++ b/e2e/auth/complete-flow.spec.ts @@ -0,0 +1,377 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * E2E tests for authentication flows + * + * Tests user authentication, session management, and permission-based access + */ + +// Helper to check if user is on login page +async function isOnLoginPage(page: Page): Promise { + const loginForm = page.locator('input[type="password"], input[name="password"]') + return (await loginForm.count()) > 0 +} + +test.describe('Authentication Flows', () => { + test.describe('Landing Page', () => { + test('should display landing page for unauthenticated users', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + + // Should see sign in or get started button + const signInButton = page.getByRole('button', { name: /sign in|get started|login/i }) + await expect(signInButton).toBeVisible({ timeout: 5000 }) + }) + + test('should have navigation to login', async ({ page }) => { + await page.goto('/') + + // Click sign in button + await page.getByRole('button', { name: /sign in|get started/i }).first().click() + await page.waitForLoadState('networkidle') + + // Should navigate to login page or show login form + await expect(page.getByLabel(/username|email/i)).toBeVisible({ timeout: 5000 }) + }) + }) + + test.describe('Login Flow', () => { + test('should show login form', async ({ page }) => { + await page.goto('/ui/login') + await page.waitForLoadState('networkidle') + + // Should have username/email and password fields + await expect(page.getByLabel(/username|email/i)).toBeVisible({ timeout: 5000 }) + await expect(page.getByLabel(/password/i)).toBeVisible() + await expect(page.getByRole('button', { name: /login|sign in/i })).toBeVisible() + }) + + test('should validate required fields', async ({ page }) => { + await page.goto('/ui/login') + + // Try to submit without filling fields + await page.getByRole('button', { name: /login|sign in/i }).click() + + // Should show validation errors or stay on page + await page.waitForTimeout(1000) + const stillOnLogin = await isOnLoginPage(page) + expect(stillOnLogin).toBeTruthy() + }) + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/ui/login') + + // Fill with invalid credentials + await page.getByLabel(/username|email/i).fill('invalid@example.com') + await page.getByLabel(/password/i).fill('wrongpassword') + await page.getByRole('button', { name: /login|sign in/i }).click() + + // Should show error message + await expect(page.getByText(/invalid|incorrect|error|failed/i)).toBeVisible({ timeout: 5000 }) + }) + + test('should have register/signup link', async ({ page }) => { + await page.goto('/ui/login') + + // Should have link to registration + const registerLink = page.getByText(/register|sign up|create account/i) + await expect(registerLink).toBeVisible({ timeout: 5000 }) + }) + + test('should have forgot password link', async ({ page }) => { + await page.goto('/ui/login') + + // Should have forgot password link + const forgotLink = page.getByText(/forgot password|reset password/i) + const linkExists = await forgotLink.count() > 0 + + if (linkExists) { + await expect(forgotLink).toBeVisible() + } + }) + }) + + test.describe('Session Management', () => { + test('should persist session across page reloads', async ({ page, context }) => { + // Skip if we can't log in + test.skip(true, 'Requires actual user credentials') + + // Login + await page.goto('/ui/login') + // ... login logic ... + + // Reload page + await page.reload() + + // Should still be logged in (not redirected to login) + const onLoginPage = await isOnLoginPage(page) + expect(onLoginPage).toBeFalsy() + }) + + test('should maintain session across navigation', async ({ page }) => { + // Skip if we can't log in + test.skip(true, 'Requires actual user credentials') + + // After login, navigate to different pages + await page.goto('/dashboard') + // Should be accessible + + await page.goto('/default/ui_home') + // Should still be logged in + }) + }) + + test.describe('Logout Flow', () => { + test('should have logout option when authenticated', async ({ page }) => { + // Skip if we can't log in + test.skip(true, 'Requires actual user credentials') + + // After login, should see logout button + const logoutButton = page.getByRole('button', { name: /logout|sign out/i }) + await expect(logoutButton).toBeVisible({ timeout: 5000 }) + }) + + test('should redirect to landing page after logout', async ({ page }) => { + // Skip if we can't log in + test.skip(true, 'Requires actual user credentials') + + // Click logout + await page.getByRole('button', { name: /logout|sign out/i }).click() + + // Should redirect to landing page + await page.waitForURL('/', { timeout: 5000 }) + }) + + test('should clear session after logout', async ({ page }) => { + // Skip if we can't log in + test.skip(true, 'Requires actual user credentials') + + // After logout, try to access protected page + await page.goto('/dashboard') + + // Should redirect to login + await page.waitForURL(/login/, { timeout: 5000 }) + }) + }) + + test.describe('Permission-Based Access', () => { + test('should redirect to login for protected pages', async ({ page }) => { + // Try to access a protected page without authentication + await page.goto('/dashboard') + + // Should redirect to login or show access denied + const onLoginPage = await isOnLoginPage(page) + const accessDenied = await page.getByText(/access denied|forbidden|login required/i).count() > 0 + + expect(onLoginPage || accessDenied).toBeTruthy() + }) + + test('should show access denied for insufficient permissions', async ({ page }) => { + // Skip if we can't test with actual user + test.skip(true, 'Requires user with specific permission level') + + // Login as user with level 1 + // Try to access admin page (level 3) + await page.goto('/admin') + + // Should show access denied + await expect(page.getByText(/access denied|forbidden|insufficient/i)).toBeVisible({ timeout: 5000 }) + }) + + test('should allow access to public pages without authentication', async ({ page }) => { + // Public pages should be accessible + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Should load successfully + const title = await page.title() + expect(title).toBeTruthy() + }) + + test('should allow access to user pages with authentication', async ({ page }) => { + // Skip if we can't log in + test.skip(true, 'Requires actual user credentials') + + // After login, should access user-level pages + await page.goto('/dashboard') + await page.waitForLoadState('networkidle') + + // Should not be on login page + const onLoginPage = await isOnLoginPage(page) + expect(onLoginPage).toBeFalsy() + }) + }) + + test.describe('Access Denied UI', () => { + test('should show user-friendly access denied message', async ({ page }) => { + // Try to access a page above user's permission level + await page.goto('/supergod') + + // Should show access denied with details + const accessDeniedHeading = page.getByRole('heading', { name: /access denied|forbidden/i }) + const accessDeniedText = page.getByText(/access denied|permission|insufficient/i) + + const hasAccessDenied = (await accessDeniedHeading.count() > 0) || (await accessDeniedText.count() > 0) + expect(hasAccessDenied).toBeTruthy() + }) + + test('should show required permission level', async ({ page }) => { + // Skip if access denied page doesn't exist + test.skip(true, 'Requires actual access denied scenario') + + // Should show what level is required + await expect(page.getByText(/required level|minimum level|level.*required/i)).toBeVisible({ timeout: 5000 }) + }) + + test('should have link back to home', async ({ page }) => { + // Skip if access denied page doesn't exist + test.skip(true, 'Requires actual access denied scenario') + + // Should have way to go back + const backLink = page.getByRole('link', { name: /home|back|return/i }) + await expect(backLink).toBeVisible({ timeout: 5000 }) + }) + }) + + test.describe('Registration Flow', () => { + test('should show registration form', async ({ page }) => { + await page.goto('/ui/register') + await page.waitForLoadState('networkidle') + + // Should have registration fields + const emailInput = page.getByLabel(/email/i) + const usernameInput = page.getByLabel(/username/i) + const passwordInput = page.getByLabel(/password/i) + + const hasRegistrationFields = (await emailInput.count() > 0) || (await usernameInput.count() > 0) + expect(hasRegistrationFields).toBeTruthy() + }) + + test('should validate email format', async ({ page }) => { + await page.goto('/ui/register') + + const emailInput = page.getByLabel(/email/i) + if (await emailInput.isVisible()) { + // Enter invalid email + await emailInput.fill('invalid-email') + await page.getByRole('button', { name: /register|sign up|create/i }).click() + + // Should show validation error + await expect(page.getByText(/invalid|valid.*email/i)).toBeVisible({ timeout: 5000 }) + } + }) + + test('should validate password requirements', async ({ page }) => { + await page.goto('/ui/register') + + const passwordInput = page.getByLabel(/^password$/i).first() + if (await passwordInput.isVisible()) { + // Enter weak password + await passwordInput.fill('123') + await page.getByRole('button', { name: /register|sign up|create/i }).click() + + // Should show password requirement error + const errorMessage = page.getByText(/password.*short|password.*weak|characters/i) + const hasError = await errorMessage.count() > 0 + expect(hasError).toBeTruthy() + } + }) + + test('should have link back to login', async ({ page }) => { + await page.goto('/ui/register') + + // Should have link to login + const loginLink = page.getByText(/already.*account|login|sign in/i) + const linkExists = await loginLink.count() > 0 + + if (linkExists) { + await expect(loginLink).toBeVisible() + } + }) + }) + + test.describe('Password Reset Flow', () => { + test('should show password reset form', async ({ page }) => { + await page.goto('/ui/forgot-password') + await page.waitForLoadState('networkidle') + + // Should have email input + const emailInput = page.getByLabel(/email/i) + const exists = await emailInput.count() > 0 + + if (exists) { + await expect(emailInput).toBeVisible() + } + }) + + test('should validate email for password reset', async ({ page }) => { + await page.goto('/ui/forgot-password') + + const emailInput = page.getByLabel(/email/i) + if (await emailInput.isVisible()) { + // Enter invalid email + await emailInput.fill('invalid') + await page.getByRole('button', { name: /reset|send/i }).click() + + // Should show validation error + const errorText = page.getByText(/invalid|valid.*email/i) + const hasError = await errorText.count() > 0 + expect(hasError).toBeTruthy() + } + }) + }) + + test.describe('Session Security', () => { + test('should use secure cookies', async ({ page, context }) => { + await page.goto('/') + + // Check cookies after navigation + const cookies = await context.cookies() + const sessionCookie = cookies.find(c => c.name.includes('session') || c.name.includes('token')) + + if (sessionCookie) { + // Should have httpOnly flag (can't check this in browser context) + // Should have sameSite flag + expect(sessionCookie.sameSite).toBeTruthy() + } + }) + + test('should expire session after timeout', async ({ page }) => { + // Skip - requires waiting for actual timeout + test.skip(true, 'Requires long wait time for session expiry') + }) + + test('should prevent CSRF attacks', async ({ page }) => { + // Skip - requires complex setup + test.skip(true, 'Requires CSRF token testing') + }) + }) + + test.describe('Error Handling', () => { + test('should handle network errors gracefully', async ({ page, context }) => { + // Go offline + await context.setOffline(true) + + await page.goto('/ui/login') + + // Fill form + const usernameInput = page.getByLabel(/username|email/i) + if (await usernameInput.isVisible()) { + await usernameInput.fill('test@example.com') + await page.getByLabel(/password/i).fill('password') + await page.getByRole('button', { name: /login|sign in/i }).click() + + // Should show network error + await expect(page.getByText(/network|connection|offline|error/i)).toBeVisible({ timeout: 10000 }) + } + + // Go back online + await context.setOffline(false) + }) + + test('should handle server errors', async ({ page }) => { + // Skip - requires mock server + test.skip(true, 'Requires server error simulation') + }) + }) +}) diff --git a/e2e/package-loading.spec.ts b/e2e/package-loading.spec.ts new file mode 100644 index 000000000..96e9610dd --- /dev/null +++ b/e2e/package-loading.spec.ts @@ -0,0 +1,436 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * E2E tests for package loading and rendering + * + * Tests the dynamic package system, JSON component rendering, and routing + */ + +test.describe('Package Rendering', () => { + test.describe('Package Home Pages', () => { + test('should render ui_home package', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Should load and render home page + const bodyText = await page.textContent('body') + expect(bodyText).toBeTruthy() + expect(bodyText!.length).toBeGreaterThan(0) + }) + + test('should render dashboard package', async ({ page }) => { + await page.goto('/default/dashboard') + await page.waitForLoadState('networkidle') + + // Should load dashboard (may require auth) + const title = await page.title() + expect(title).toBeTruthy() + }) + + test('should handle package not found', async ({ page }) => { + await page.goto('/default/non_existent_package') + + // Should show 404 or not found message + const notFound = page.getByText(/not found|404|doesn't exist/i) + await expect(notFound).toBeVisible({ timeout: 5000 }) + }) + + test('should respect package min level permissions', async ({ page }) => { + // Try to access admin package without authentication + await page.goto('/default/admin_dialog') + + // Should be denied or redirected + const isAccessDenied = (await page.getByText(/access denied|login required|forbidden/i).count()) > 0 + const onLoginPage = (await page.locator('input[type="password"]').count()) > 0 + + expect(isAccessDenied || onLoginPage).toBeTruthy() + }) + }) + + test.describe('Package Route Priority', () => { + test('should load root route from InstalledPackage config', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Root should load ui_home package (from InstalledPackage defaultRoute config) + const isHomePage = (await page.getByRole('heading', { name: /metabuilder|home|welcome/i }).count()) > 0 + expect(isHomePage).toBeTruthy() + }) + + test('should override package routes with PageConfig', async ({ page }) => { + // Skip - requires database setup with PageConfig overrides + test.skip(true, 'Requires PageConfig route overrides in database') + + // If PageConfig has entry for "/", it should take priority over InstalledPackage + }) + }) + + test.describe('Package Component Discovery', () => { + test('should find home component in package', async ({ page }) => { + await page.goto('/default/ui_home') + await page.waitForLoadState('networkidle') + + // Should successfully load home component + const hasContent = (await page.textContent('body'))!.length > 0 + expect(hasContent).toBeTruthy() + }) + + test('should prioritize home_page over HomePage', async ({ page }) => { + // Skip - requires package with both components + test.skip(true, 'Requires package with multiple home component candidates') + + // Package loader should prioritize: 'home_page' > 'HomePage' > 'Home' > first component + }) + + test('should use first component if no home component', async ({ page }) => { + // Skip - requires package without explicit home component + test.skip(true, 'Requires package without home component') + + // Should fall back to first component in components array + }) + }) + + test.describe('JSON Component Rendering', () => { + test('should render JSON components from package', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Components should be rendered from JSON definitions + // Check for common component types + const hasBoxes = (await page.locator('[class*="Box"], [class*="box"]').count()) > 0 + const hasContainers = (await page.locator('main, section, div').count()) > 0 + + expect(hasBoxes || hasContainers).toBeTruthy() + }) + + test('should render nested components', async ({ page }) => { + await page.goto('/') + + // Should have nested structure + const mainElement = page.locator('main').first() + const hasChildren = (await mainElement.locator('> *').count()) > 0 + + if (await mainElement.count() > 0) { + expect(hasChildren).toBeTruthy() + } + }) + + test('should apply component props', async ({ page }) => { + await page.goto('/') + + // Components should have their props applied + // Check for common props like className + const elementsWithClass = await page.locator('[class]').count() + expect(elementsWithClass).toBeGreaterThan(0) + }) + + test('should render text content', async ({ page }) => { + await page.goto('/') + + // Should have text content from JSON components + const bodyText = await page.textContent('body') + expect(bodyText!.trim().length).toBeGreaterThan(0) + }) + }) + + test.describe('Package Metadata', () => { + test('should set page title from package', async ({ page }) => { + await page.goto('/') + + // Should have page title + const title = await page.title() + expect(title).toBeTruthy() + expect(title.length).toBeGreaterThan(0) + }) + + test('should set meta description from package', async ({ page }) => { + await page.goto('/') + + // Check for meta description + const description = await page.locator('meta[name="description"]').getAttribute('content') + // May or may not exist + if (description) { + expect(description.length).toBeGreaterThan(0) + } + }) + }) + + test.describe('Package Static Assets', () => { + test('should load package styles', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Styles should be applied + const bodyStyles = await page.locator('body').evaluate((el) => { + const computed = window.getComputedStyle(el) + return { + backgroundColor: computed.backgroundColor, + color: computed.color, + margin: computed.margin, + } + }) + + // Should have some styling + expect(bodyStyles.backgroundColor).toBeTruthy() + }) + + test('should load package images', async ({ page }) => { + await page.goto('/') + + // Check for images + const images = page.locator('img') + const imageCount = await images.count() + + // May or may not have images + if (imageCount > 0) { + // First image should load + await expect(images.first()).toBeVisible({ timeout: 5000 }) + } + }) + }) + + test.describe('Package Sub-Routes', () => { + test('should handle package entity routes', async ({ page }) => { + await page.goto('/default/dashboard/users') + + // Should load entity list or show access denied + const hasContent = (await page.textContent('body'))!.length > 0 + expect(hasContent).toBeTruthy() + }) + + test('should handle entity detail routes', async ({ page }) => { + await page.goto('/default/dashboard/users/123') + + // Should attempt to load entity detail + const hasContent = (await page.textContent('body'))!.length > 0 + expect(hasContent).toBeTruthy() + }) + + test('should handle entity action routes', async ({ page }) => { + await page.goto('/default/dashboard/users/create') + + // Should show create form or access denied + const hasContent = (await page.textContent('body'))!.length > 0 + expect(hasContent).toBeTruthy() + }) + }) + + test.describe('Multi-Tenant Package Isolation', () => { + test('should load packages for specific tenant', async ({ page }) => { + await page.goto('/default/dashboard') + + // Should load for default tenant + const title = await page.title() + expect(title).toBeTruthy() + }) + + test('should isolate tenant data', async ({ page }) => { + // Skip - requires multiple tenants in database + test.skip(true, 'Requires multi-tenant database setup') + + // Navigate to tenant A + await page.goto('/tenant-a/dashboard') + const tenantAContent = await page.textContent('body') + + // Navigate to tenant B + await page.goto('/tenant-b/dashboard') + const tenantBContent = await page.textContent('body') + + // Should have different content + expect(tenantAContent).not.toBe(tenantBContent) + }) + + test('should prevent cross-tenant access', async ({ page }) => { + // Skip - requires authentication and multiple tenants + test.skip(true, 'Requires authenticated user and tenant isolation') + + // Login to tenant A + // Try to access tenant B data + // Should be denied + }) + }) + + test.describe('Package Dependencies', () => { + test('should load package dependencies', async ({ page }) => { + // Skip - requires package with dependencies + test.skip(true, 'Requires package with explicit dependencies') + + // Package should load its dependencies + // Dependencies should be available + }) + + test('should fail gracefully if dependency missing', async ({ page }) => { + // Skip - requires package with missing dependency + test.skip(true, 'Requires package with missing dependency') + + // Should show error or warning about missing dependency + }) + }) + + test.describe('Package Error Handling', () => { + test('should handle invalid JSON components', async ({ page }) => { + // Skip - requires package with invalid JSON + test.skip(true, 'Requires package with invalid JSON') + + // Should show error message or fallback UI + }) + + test('should handle missing package files', async ({ page }) => { + await page.goto('/default/missing_package_123') + + // Should show 404 or package not found + const notFound = page.getByText(/not found|404|doesn't exist|package.*not/i) + await expect(notFound).toBeVisible({ timeout: 5000 }) + }) + + test('should handle component rendering errors', async ({ page }) => { + // Skip - requires component with rendering error + test.skip(true, 'Requires component that fails to render') + + // Should show error boundary or fallback + }) + }) + + test.describe('Package Versioning', () => { + test('should display package version', async ({ page }) => { + // Skip - requires version display in UI + test.skip(true, 'Requires package version display') + + // Check footer or settings for version info + }) + + test('should handle package updates', async ({ page }) => { + // Skip - requires package update mechanism + test.skip(true, 'Requires package update system') + + // Should be able to update to new version + }) + }) + + test.describe('Package Configuration', () => { + test('should respect package enabled/disabled state', async ({ page }) => { + // Skip - requires disabled package in database + test.skip(true, 'Requires disabled package') + + // Disabled packages should not be accessible + await page.goto('/default/disabled_package') + + // Should show not found or access denied + }) + + test('should apply package configuration', async ({ page }) => { + // Skip - requires package with custom config + test.skip(true, 'Requires package with custom configuration') + + // Package should use its config values + }) + + test('should allow God users to configure packages', async ({ page }) => { + // Skip - requires God-level authentication + test.skip(true, 'Requires God-level user authentication') + + // God panel should show package configuration + }) + }) + + test.describe('Package System Integration', () => { + test('should list available packages', async ({ page }) => { + // Skip - requires package list UI + test.skip(true, 'Requires package listing UI') + + // Should show list of installed packages + }) + + test('should search packages', async ({ page }) => { + // Skip - requires package search UI + test.skip(true, 'Requires package search functionality') + + // Should be able to search for packages + }) + + test('should install new packages', async ({ page }) => { + // Skip - requires God-level and package installation UI + test.skip(true, 'Requires package installation system') + + // Should be able to install packages from UI + }) + + test('should uninstall packages', async ({ page }) => { + // Skip - requires God-level and package management UI + test.skip(true, 'Requires package uninstall system') + + // Should be able to uninstall packages + }) + }) + + test.describe('Performance', () => { + test('should load packages quickly', async ({ page }) => { + const startTime = Date.now() + await page.goto('/') + await page.waitForLoadState('networkidle') + const loadTime = Date.now() - startTime + + // Should load in reasonable time (< 5 seconds) + expect(loadTime).toBeLessThan(5000) + }) + + test('should cache package data', async ({ page }) => { + // First load + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Second load should be faster (cached) + const startTime = Date.now() + await page.goto('/') + await page.waitForLoadState('networkidle') + const loadTime = Date.now() - startTime + + // Should load quickly from cache + expect(loadTime).toBeLessThan(3000) + }) + + test('should lazy load package components', async ({ page }) => { + // Skip - requires lazy loading implementation + test.skip(true, 'Requires lazy loading of components') + + // Components should load on demand + }) + }) + + test.describe('Accessibility', () => { + test('should have semantic HTML from package components', async ({ page }) => { + await page.goto('/') + + // Check for semantic elements + const main = await page.locator('main').count() + const header = await page.locator('header').count() + const nav = await page.locator('nav').count() + + const hasSemanticHTML = main > 0 || header > 0 || nav > 0 + expect(hasSemanticHTML).toBeTruthy() + }) + + test('should have proper heading hierarchy', async ({ page }) => { + await page.goto('/') + + // Should have h1 + const h1Count = await page.locator('h1').count() + expect(h1Count).toBeGreaterThan(0) + }) + + test('should have alt text for images', async ({ page }) => { + await page.goto('/') + + const images = page.locator('img') + const imageCount = await images.count() + + if (imageCount > 0) { + // Images should have alt attribute + const firstImage = images.first() + const alt = await firstImage.getAttribute('alt') + // Alt can be empty string but should exist + expect(alt).not.toBeNull() + } + }) + }) +})