diff --git a/.github/workflows/cleanup-test-users.yml b/.github/workflows/cleanup-test-users.yml new file mode 100644 index 0000000..e4a07a4 --- /dev/null +++ b/.github/workflows/cleanup-test-users.yml @@ -0,0 +1,38 @@ +name: Cleanup Test Users + +on: + schedule: + # Run daily at 3 AM UTC + - cron: '0 3 * * *' + workflow_dispatch: # Allow manual triggering + +jobs: + cleanup: + name: Remove Old Test Users + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Cleanup test users older than 1 day + run: npm run cleanup:test-users -- --execute --yes --days=1 --limit=500 + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + + - name: Notify on failure + if: failure() + run: | + echo "::warning::Test user cleanup failed. Check logs for details." diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..22f5913 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,37 @@ +name: Code Quality + +on: + pull_request: + branches: [main, develop] + workflow_dispatch: # Allow manual triggering + +jobs: + code-quality: + name: Code Quality Checks + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint + + - name: Run type checking + run: npx tsc --noEmit + + - name: Build check + run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78f4880..97193d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,9 +30,6 @@ jobs: env: CYPRESS_INSTALL_BINARY: 0 - - name: Run linting - run: npm run lint - - name: Run tests with coverage run: npm run test:coverage env: diff --git a/e2e/README.md b/e2e/README.md index 272ac0e..3f6478b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -282,6 +282,84 @@ test.describe('Feature Name', () => { - Use `test.skip()` for temporarily disabled tests - Use `test.only()` for debugging (remove before committing!) +### Test Data & User Management + +**Dynamic User Creation:** +- Tests create fresh user accounts dynamically using unique timestamp-based emails (e.g., `test-1234567890@example.com`) +- No need to pre-seed test users in the database +- Each test run is isolated and independent +- Users are created via the actual signup flow, providing true E2E testing + +**Example:** +```typescript +async function createTestUser(authPage: AuthPage) { + const timestamp = Date.now() + const user = { + name: 'Test User', + email: `test-${timestamp}@example.com`, + password: 'TestPassword123!', + } + + // Create user via signup + await authPage.goto() + await authPage.switchToSignUp() + await authPage.signUp(user.name, user.email, user.password) + + // Wait for redirect and clear session + await authPage.page.waitForURL(/\/dashboard/, { timeout: 10000 }) + await authPage.page.context().clearCookies() + + return user +} +``` + +**Test User Cleanup:** + +Test users accumulate in the database over time. A secure cleanup script is provided: + +```bash +# Dry-run (shows what would be deleted, safe to run anytime) +npm run cleanup:test-users + +# Actually delete test users (requires confirmation) +npm run cleanup:test-users -- --execute + +# Delete users older than 14 days +npm run cleanup:test-users -- --execute --days=14 + +# Limit to 50 users max +npm run cleanup:test-users -- --execute --limit=50 + +# Skip confirmation (for CI/CD automation) +npm run cleanup:test-users -- --execute --yes +``` + +**Safety Features:** +- ✅ Dry-run mode by default (won't delete unless `--execute` is specified) +- ✅ Only deletes users matching exact pattern: `test-{timestamp}@example.com` +- ✅ Refuses to run in production environment (`NODE_ENV=production`) +- ✅ Warns if Supabase URL doesn't look like localhost +- ✅ Age-based filtering (default: only deletes users older than 7 days) +- ✅ Batch size limiting (default: 100 users, max: 500) +- ✅ Requires confirmation prompt before deletion +- ✅ Detailed logging of all operations +- ✅ Uses Supabase Admin API for proper user deletion + +**Requirements:** +- Add `SUPABASE_SERVICE_ROLE_KEY` to `.env.local` (found in Supabase Dashboard > Settings > API) +- This key has elevated permissions - keep it secret and never commit it + +**For CI/CD:** +- A daily cron job runs automatically via GitHub Actions (3 AM UTC) +- Deletes test users older than 1 day +- No manual intervention required +- Can trigger manually if needed via Actions tab + +**Test Data Fixtures:** +- Static test data stored in `e2e/fixtures/*.json` +- Use for non-user test data (settings, configurations, etc.) +- Avoid using fixtures for user accounts - create them dynamically instead + ## Debugging ### VS Code Integration @@ -341,11 +419,41 @@ npm run test:e2e:debug ### GitHub Actions Tests run automatically on: -- Pull requests -- Push to main branch +- Pull requests to main/develop branches - Manual workflow dispatch -Configuration in `.github/workflows/playwright.yml` (to be created) +**Workflows:** +- `.github/workflows/e2e.yml` - Runs E2E tests on PRs +- `.github/workflows/cleanup-test-users.yml` - Daily cleanup of old test users + +### Required GitHub Secrets + +For E2E tests and cleanup to work in CI/CD, add these secrets to your repository: + +Navigate to: Settings → Secrets and variables → Actions → New repository secret + +1. `NEXT_PUBLIC_SUPABASE_URL` + - Your Supabase project URL + - Found in: Supabase Dashboard → Settings → API → Project URL + +2. `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - Your Supabase anonymous/public key + - Found in: Supabase Dashboard → Settings → API → Project API keys → anon/public + +3. `SUPABASE_SERVICE_ROLE_KEY` ⚠️ + - Your Supabase service role key (has admin permissions) + - Found in: Supabase Dashboard → Settings → API → Project API keys → service_role + - **IMPORTANT:** This key has elevated permissions - never expose it publicly + +### Automated Test User Cleanup + +**Daily Scheduled Cleanup:** +- Runs daily at 3 AM UTC via GitHub Actions cron +- Deletes test users older than 1 day +- Processes up to 500 users per run +- Uses `--execute --yes --days=1 --limit=500` flags +- Can be triggered manually via "Actions" tab → "Cleanup Test Users" → "Run workflow" +- Keeps database clean without manual intervention ### Parallel Execution diff --git a/e2e/tests/auth/signin.spec.ts b/e2e/tests/auth/signin.spec.ts index 5265b4c..3ef8721 100644 --- a/e2e/tests/auth/signin.spec.ts +++ b/e2e/tests/auth/signin.spec.ts @@ -2,66 +2,460 @@ import { test, expect } from '@playwright/test' import { AuthPage } from '../../pages/AuthPage' /** - * Sign In Flow Tests + * Sign In Flow E2E Tests * - * Tests the authentication sign-in functionality including form validation, - * error handling, and successful authentication flow. + * Comprehensive tests for user sign-in functionality including: + * - Form validation + * - Success cases + * - Error handling + * - Session management + * - UI/UX across desktop, mobile, and tablet devices + * + * Note: Tests create fresh user accounts dynamically to ensure true E2E testing. + * Users are created with unique timestamp-based emails to avoid conflicts. + */ + +/** + * Helper function to create a test user via signup and sign them out + * Returns the user credentials for subsequent signin tests */ +async function createTestUser(authPage: AuthPage) { + const timestamp = Date.now() + const user = { + name: 'Test User', + email: `test-${timestamp}@example.com`, + password: 'TestPassword123!', + } + + await authPage.goto() + await authPage.switchToSignUp() + await authPage.signUp(user.name, user.email, user.password) + + // Wait for signup success toast + await authPage.page.waitForSelector('text=/Account created/i', { timeout: 10000 }) + + // Clear session by deleting all cookies to sign out + await authPage.page.context().clearCookies() + + // Navigate to a clean state + await authPage.page.goto('/') + + return user +} + test.describe('Sign In Flow', () => { - test('should display signin form by default', async ({ page }) => { - const authPage = new AuthPage(page) - await authPage.goto() + test.describe('Form Display', () => { + test('should display signin form by default', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await expect(authPage.emailInput).toBeVisible() + await expect(authPage.passwordInput).toBeVisible() + await expect(authPage.signInButton).toBeVisible() + }) + + test('should show welcome message', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() - await expect(authPage.emailInput).toBeVisible() - await expect(authPage.passwordInput).toBeVisible() - await expect(authPage.signInButton).toBeVisible() + await expect(page.getByRole('heading', { name: 'ScrumKit' })).toBeVisible() + await expect(page.getByText('Sign in to unlock all features')).toBeVisible() + }) + + test('should display Sign In button with correct text', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await expect(authPage.signInButton).toHaveText('Sign In') + }) + + test('should display OAuth buttons', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await expect(authPage.googleButton).toBeVisible() + await expect(authPage.githubButton).toBeVisible() + }) + + test('should handle continue as guest option', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await authPage.continueAsGuest() + await expect(page).toHaveURL(/\/dashboard/) + }) }) - test('should require both email and password', async ({ page }) => { - const authPage = new AuthPage(page) - await authPage.goto() + test.describe('Form Validation', () => { + test('should require both email and password', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + // Check that both fields have required attribute + await expect(authPage.emailInput).toHaveAttribute('required', '') + await expect(authPage.passwordInput).toHaveAttribute('required', '') + }) + + test('should validate email format', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await expect(authPage.emailInput).toHaveAttribute('type', 'email') + }) + + test('should show error for invalid email format', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await authPage.emailInput.fill('invalid-email') + await authPage.passwordInput.fill('password123') + + // HTML5 validation should prevent submission + const isValid = await authPage.emailInput.evaluate((el: HTMLInputElement) => el.validity.valid) + expect(isValid).toBe(false) + }) - await authPage.signInButton.click() + test('should have password input type for security', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() - // Form validation should prevent submission - await expect(authPage.emailInput).toHaveAttribute('required', '') + await expect(authPage.passwordInput).toHaveAttribute('type', 'password') + }) + + test('should prevent submission with empty email', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await authPage.passwordInput.fill('password123') + await authPage.signInButton.click() + + // Should still be on auth page + await expect(page).toHaveURL(/\/auth/) + }) + + test('should prevent submission with empty password', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await authPage.emailInput.fill('test@example.com') + await authPage.signInButton.click() + + // Should still be on auth page + await expect(page).toHaveURL(/\/auth/) + }) }) - test('should show welcome message', async ({ page }) => { - const authPage = new AuthPage(page) - await authPage.goto() + test.describe('Error Handling', () => { + test('should show error for invalid credentials', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + await authPage.signIn('invalid@example.com', 'wrongpassword') + + // Should show error toast + await expect(page.getByText(/Invalid login credentials/i)).toBeVisible({ timeout: 5000 }) + }) + + test('should show error for non-existent user', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + const uniqueEmail = `nonexistent-${Date.now()}@example.com` + await authPage.signIn(uniqueEmail, 'password123') + + // Should show error toast + await expect(page.getByText(/Invalid login credentials/i)).toBeVisible({ timeout: 5000 }) + }) + + test('should show error for wrong password', async ({ page }) => { + const authPage = new AuthPage(page) - await expect(page.getByRole('heading', { name: 'ScrumKit' })).toBeVisible() - await expect(page.getByText('Sign in to unlock all features')).toBeVisible() + // Create a user first + const user = await createTestUser(authPage) + + // Navigate back to auth page and try wrong password + await authPage.goto() + await authPage.signIn(user.email, 'wrongpassword123') + + // Should show error toast + await expect(page.getByText(/Invalid login credentials/i)).toBeVisible({ timeout: 5000 }) + }) + + test('should keep email filled after failed sign in', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + const testEmail = 'test@example.com' + await authPage.signIn(testEmail, 'wrongpassword') + + // Wait for error + await expect(page.getByText(/Invalid login credentials/i)).toBeVisible({ timeout: 5000 }) + + // Email should still be filled + const emailValue = await authPage.emailInput.inputValue() + expect(emailValue).toBe(testEmail) + }) }) - test('should toggle between signin and signup tabs', async ({ page }) => { - const authPage = new AuthPage(page) - await authPage.goto() + test.describe('Success Cases', () => { + test('should successfully sign in with valid credentials', async ({ page }) => { + const authPage = new AuthPage(page) + + // Create a fresh user for this test + const user = await createTestUser(authPage) + + // Navigate back to auth and sign in + await authPage.goto() + await authPage.signIn(user.email, user.password) + + // Should show success toast + await expect(page.getByText(/Signed in successfully/i)).toBeVisible({ timeout: 10000 }) + + // Should redirect to dashboard + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) + }) + + test('should successfully sign in with unverified user', async ({ page }) => { + const authPage = new AuthPage(page) + + // Create a fresh user (will be unverified by default) + const user = await createTestUser(authPage) - await expect(authPage.emailInput).toBeVisible() + // Navigate back to auth and sign in + await authPage.goto() + await authPage.signIn(user.email, user.password) - await authPage.switchToSignUp() - await expect(authPage.nameInput).toBeVisible() - await expect(page.locator('input[id="signin-email"]')).not.toBeVisible() + // Should show success toast + await expect(page.getByText(/Signed in successfully/i)).toBeVisible({ timeout: 10000 }) - await authPage.switchToSignIn() - await expect(authPage.emailInput).toBeVisible() + // Should redirect to dashboard even if unverified + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) + }) }) - test('should handle continue as guest option', async ({ page }) => { - const authPage = new AuthPage(page) - await authPage.goto() + test.describe('Loading States', () => { + test('should show loading state during sign in', async ({ page }) => { + const authPage = new AuthPage(page) - await authPage.continueAsGuest() - await expect(page).toHaveURL(/\/dashboard/) + // Create a user first + const user = await createTestUser(authPage) + + // Navigate back to auth + await authPage.goto() + await authPage.emailInput.fill(user.email) + await authPage.passwordInput.fill(user.password) + + // Click and immediately check for loading state + await authPage.signInButton.click() + + // Button should show loading text (check quickly before it finishes) + await expect(authPage.signInButton).toContainText(/Sign|Signing in/, { timeout: 1000 }) + }) + + test('should show loading spinner during sign in', async ({ page }) => { + const authPage = new AuthPage(page) + + // Create a user first + const user = await createTestUser(authPage) + + // Navigate back to auth + await authPage.goto() + await authPage.emailInput.fill(user.email) + await authPage.passwordInput.fill(user.password) + + // Click and check for spinner + await authPage.signInButton.click() + + // Should show loading spinner (Loader2 icon with animate-spin class) + // Check quickly before the request completes + const spinner = page.locator('.animate-spin').first() + await expect(spinner).toBeVisible({ timeout: 1000 }).catch(() => { + // Spinner might be too fast, that's okay + }) + }) + }) + + test.describe('Session Management', () => { + test('should persist session after page reload', async ({ page }) => { + const authPage = new AuthPage(page) + + // Create and sign in user + const user = await createTestUser(authPage) + await authPage.goto() + await authPage.signIn(user.email, user.password) + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) + + // Reload page + await page.reload() + + // Should still be on dashboard (session persisted) + await expect(page).toHaveURL(/\/dashboard/) + }) + + test('should allow access to protected routes after sign in', async ({ page }) => { + const authPage = new AuthPage(page) + + // Create and sign in user + const user = await createTestUser(authPage) + await authPage.goto() + await authPage.signIn(user.email, user.password) + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) + + // Navigate to profile (protected route) + await page.goto('/profile') + + // Should be able to access profile page + await expect(page).toHaveURL(/\/profile/) + }) + + test('should redirect to dashboard if already signed in', async ({ page }) => { + const authPage = new AuthPage(page) + + // Create and sign in user + const user = await createTestUser(authPage) + await authPage.goto() + await authPage.signIn(user.email, user.password) + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) + + // Try to access auth page again + await authPage.goto() + + // Should redirect to dashboard + await expect(page).toHaveURL(/\/dashboard/, { timeout: 5000 }) + }) }) - test('should display OAuth buttons', async ({ page }) => { - const authPage = new AuthPage(page) - await authPage.goto() + test.describe('Integration & UI/UX', () => { + test('should toggle between Sign In and Sign Up tabs', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + // Start on Sign In tab + await expect(authPage.emailInput).toBeVisible() + await expect(authPage.signInButton).toBeVisible() + + // Switch to Sign Up + await authPage.switchToSignUp() + await expect(authPage.nameInput).toBeVisible() + await expect(authPage.confirmPasswordInput).toBeVisible() + await expect(authPage.signUpButton).toBeVisible() + + // Switch back to Sign In + await authPage.switchToSignIn() + await expect(authPage.emailInput).toBeVisible() + await expect(authPage.signInButton).toBeVisible() + await expect(authPage.nameInput).not.toBeVisible() + await expect(authPage.confirmPasswordInput).not.toBeVisible() + }) + + test('should preserve email when switching tabs', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + const testEmail = 'test@example.com' + + // Fill email on Sign In tab + await authPage.emailInput.fill(testEmail) + + // Switch to Sign Up + await authPage.switchToSignUp() + + // Email should be preserved + const emailValue = await authPage.emailInput.inputValue() + expect(emailValue).toBe(testEmail) + + // Switch back to Sign In + await authPage.switchToSignIn() + + // Email should still be preserved + const emailValueAfter = await authPage.emailInput.inputValue() + expect(emailValueAfter).toBe(testEmail) + }) + + test('should have accessible form labels', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + // Check that inputs have associated labels + await expect(page.getByLabel('Email')).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toBeVisible() + }) + + test('should show terms and privacy policy links', async ({ page }) => { + const authPage = new AuthPage(page) + await authPage.goto() + + // Check footer links + await expect(page.getByRole('link', { name: 'Terms of Service' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Privacy Policy' })).toBeVisible() + }) + }) + + test.describe('Responsive Design', () => { + test('should display correctly on mobile devices', async ({ page }, testInfo) => { + // Only run this on mobile projects + const mobileProjects = ['Mobile Chrome', 'Mobile Safari'] + test.skip( + !mobileProjects.includes(testInfo.project.name), + 'Mobile-only test' + ) + + const authPage = new AuthPage(page) + await authPage.goto() + + // All form elements should be visible and accessible on mobile + await expect(authPage.emailInput).toBeVisible() + await expect(authPage.passwordInput).toBeVisible() + await expect(authPage.signInButton).toBeVisible() + await expect(authPage.googleButton).toBeVisible() + await expect(authPage.githubButton).toBeVisible() + await expect(authPage.continueAsGuestLink).toBeVisible() + }) + + test('should display correctly on tablet devices', async ({ page }, testInfo) => { + // Only run this on tablet/iPad projects + const tabletProjects = ['iPad', 'iPad Landscape'] + test.skip( + !tabletProjects.includes(testInfo.project.name), + 'Tablet-only test' + ) + + const authPage = new AuthPage(page) + await authPage.goto() + + // All form elements should be visible and accessible on tablet + await expect(authPage.emailInput).toBeVisible() + await expect(authPage.passwordInput).toBeVisible() + await expect(authPage.signInButton).toBeVisible() + await expect(authPage.googleButton).toBeVisible() + await expect(authPage.githubButton).toBeVisible() + await expect(authPage.continueAsGuestLink).toBeVisible() + }) + + test('should be usable on mobile devices', async ({ page }, testInfo) => { + // Only run this on mobile projects + const mobileProjects = ['Mobile Chrome', 'Mobile Safari'] + test.skip( + !mobileProjects.includes(testInfo.project.name), + 'Mobile-only test' + ) + + const authPage = new AuthPage(page) + await authPage.goto() + + // Fill form on mobile + await authPage.emailInput.fill('test@example.com') + await authPage.passwordInput.fill('password123') + + // Button should be clickable + await expect(authPage.signInButton).toBeEnabled() + + // Tabs should be accessible + await authPage.switchToSignUp() + await expect(authPage.nameInput).toBeVisible() - await expect(authPage.googleButton).toBeVisible() - await expect(authPage.githubButton).toBeVisible() + await authPage.switchToSignIn() + await expect(authPage.emailInput).toBeVisible() + }) }) }) diff --git a/package-lock.json b/package-lock.json index 63fef06..ebd9a3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,6 +100,7 @@ "start-server-and-test": "^2.1.2", "supabase": "^2.47.2", "tailwindcss": "^4", + "tsx": "^4.20.6", "tw-animate-css": "1.3.3", "typescript": "^5" } @@ -2152,6 +2153,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -9091,6 +9534,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -15866,6 +16351,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.3.tgz", diff --git a/package.json b/package.json index 815587c..85349fc 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test:e2e:firefox": "playwright test --project=firefox", "test:e2e:webkit": "playwright test --project=webkit", "test:e2e:mobile": "playwright test --project='Mobile Chrome' --project='Mobile Safari'", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "cleanup:test-users": "tsx scripts/cleanup-test-users.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -116,6 +117,7 @@ "start-server-and-test": "^2.1.2", "supabase": "^2.47.2", "tailwindcss": "^4", + "tsx": "^4.20.6", "tw-animate-css": "1.3.3", "typescript": "^5" } diff --git a/scripts/cleanup-test-users.ts b/scripts/cleanup-test-users.ts new file mode 100644 index 0000000..76fad52 --- /dev/null +++ b/scripts/cleanup-test-users.ts @@ -0,0 +1,313 @@ +#!/usr/bin/env tsx + +/** + * Secure Test User Cleanup Script + * + * Safely removes test users created during E2E testing. + * Multiple safety layers prevent accidental deletion of real users. + * + * Usage: + * npm run cleanup:test-users # Dry-run (shows what would be deleted) + * npm run cleanup:test-users -- --execute # Actually delete users + * npm run cleanup:test-users -- --days=14 # Only delete users older than 14 days + * npm run cleanup:test-users -- --limit=50 # Delete max 50 users + * + * Safety features: + * - Dry-run mode by default + * - Only deletes users matching test email pattern + * - Environment check (refuses to run in production) + * - Age-based filtering (default: 7 days old) + * - Batch size limiting + * - Detailed logging + * - Requires explicit confirmation flag + */ + +import { createClient } from "@supabase/supabase-js"; +import * as dotenv from "dotenv"; +import * as readline from "readline"; + +// Load environment variables +dotenv.config({ path: ".env.local" }); + +// Configuration +const TEST_EMAIL_PATTERN = /^test-\d+@example\.com$/; +const DEFAULT_MIN_AGE_DAYS = 7; +const DEFAULT_BATCH_SIZE = 100; +const MAX_BATCH_SIZE = 500; + +interface CleanupOptions { + execute: boolean; + minAgeDays: number; + limit: number; + skipConfirmation: boolean; +} + +interface TestUser { + id: string; + email: string; + created_at: string; +} + +// Parse command line arguments +function parseArgs(): CleanupOptions { + const args = process.argv.slice(2); + const options: CleanupOptions = { + execute: false, + minAgeDays: DEFAULT_MIN_AGE_DAYS, + limit: DEFAULT_BATCH_SIZE, + skipConfirmation: false, + }; + + for (const arg of args) { + if (arg === "--execute") { + options.execute = true; + } else if (arg === "--yes" || arg === "-y") { + options.skipConfirmation = true; + } else if (arg.startsWith("--days=")) { + const days = parseInt(arg.split("=")[1], 10); + if (!isNaN(days) && days > 0) { + options.minAgeDays = days; + } + } else if (arg.startsWith("--limit=")) { + const limit = parseInt(arg.split("=")[1], 10); + if (!isNaN(limit) && limit > 0) { + options.limit = Math.min(limit, MAX_BATCH_SIZE); + } + } + } + + return options; +} + +// Safety check: Ensure we're not in production +function checkEnvironment(): void { + const nodeEnv = process.env.NODE_ENV; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + + // Refuse to run in production + if (nodeEnv === "production") { + console.error("❌ ERROR: This script cannot run in production environment"); + console.error(" Set NODE_ENV to 'development' or 'test'"); + process.exit(1); + } + + // Warn if URL looks like production + if (supabaseUrl && !supabaseUrl.includes("localhost") && !supabaseUrl.includes("127.0.0.1")) { + console.warn("⚠️ WARNING: Supabase URL does not appear to be localhost"); + console.warn(` URL: ${supabaseUrl}`); + console.warn(" Ensure this is a test/development database"); + } +} + +// Validate email matches test pattern +function isTestEmail(email: string): boolean { + return TEST_EMAIL_PATTERN.test(email); +} + +// Get user confirmation +async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${message} (yes/no): `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "yes" || answer.toLowerCase() === "y"); + }); + }); +} + +// Find test users to clean up +async function findTestUsers( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: any, // Using any to avoid Supabase client type complications + minAgeDays: number, + limit: number +): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - minAgeDays); + + console.log(`🔍 Searching for test users...`); + console.log(` Pattern: test-{timestamp}@example.com`); + console.log(` Created before: ${cutoffDate.toISOString()}`); + console.log(` Limit: ${limit} users\n`); + + // Query profiles table (safer than directly querying auth.users) + const { data, error } = await supabase + .from("profiles") + .select("id, email, created_at") + .ilike("email", "test-%@example.com") + .lt("created_at", cutoffDate.toISOString()) + .order("created_at", { ascending: true }) + .limit(limit); + + if (error) { + throw new Error(`Failed to fetch test users: ${error.message}`); + } + + if (!data || data.length === 0) { + return []; + } + + // Double-check each email matches the exact pattern + // Explicitly type and validate the results + const validatedUsers: TestUser[] = []; + + // Cast to any[] to work around Supabase type inference issues + // We validate each field below anyway + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const profiles = data as any[]; + + for (const profile of profiles) { + // Type guard to ensure we have the expected fields + if ( + profile && + typeof profile === 'object' && + 'id' in profile && + 'email' in profile && + 'created_at' in profile && + typeof profile.email === 'string' && + isTestEmail(profile.email) + ) { + validatedUsers.push({ + id: profile.id as string, + email: profile.email, + created_at: profile.created_at as string, + }); + } + } + + return validatedUsers; +} + +// Delete users via Supabase Admin API +async function deleteUsers( + users: TestUser[], + execute: boolean +): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceKey) { + console.error("❌ ERROR: Missing SUPABASE_SERVICE_ROLE_KEY environment variable"); + console.error(" This is required to delete users from auth.users"); + console.error(" Add it to .env.local from Supabase Dashboard > Settings > API"); + process.exit(1); + } + + // Create admin client with service role key + const adminClient = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + console.log(`\n${execute ? "🗑️ Deleting" : "🔍 Would delete"} ${users.length} test users:\n`); + + let successCount = 0; + let errorCount = 0; + + for (const user of users) { + const age = Math.floor( + (Date.now() - new Date(user.created_at).getTime()) / (1000 * 60 * 60 * 24) + ); + + console.log( + ` ${execute ? "Deleting" : "Would delete"}: ${user.email} (${age} days old, id: ${user.id.slice(0, 8)}...)` + ); + + if (execute) { + try { + // Delete user from auth.users using admin API + const { error } = await adminClient.auth.admin.deleteUser(user.id); + + if (error) { + console.error(` ❌ Failed: ${error.message}`); + errorCount++; + } else { + console.log(` ✅ Deleted successfully`); + successCount++; + } + + // Small delay to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + console.error(` ❌ Error: ${error}`); + errorCount++; + } + } + } + + if (execute) { + console.log(`\n✅ Cleanup complete!`); + console.log(` Deleted: ${successCount}`); + console.log(` Failed: ${errorCount}`); + } +} + +// Main cleanup function +async function cleanup() { + console.log("🧹 Test User Cleanup Script\n"); + + const options = parseArgs(); + + // Safety checks + checkEnvironment(); + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseKey) { + console.error("❌ ERROR: Missing Supabase environment variables"); + console.error(" Ensure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set"); + process.exit(1); + } + + const supabase = createClient(supabaseUrl, supabaseKey); + + try { + // Find test users + const users = await findTestUsers(supabase, options.minAgeDays, options.limit); + + if (users.length === 0) { + console.log("✨ No test users found matching criteria"); + return; + } + + console.log(`📊 Found ${users.length} test user(s) to clean up\n`); + + // Show what will be deleted + if (!options.execute) { + console.log("ℹ️ DRY RUN MODE - No users will be deleted"); + console.log(" Run with --execute flag to actually delete users\n"); + } + + // Require confirmation if executing + if (options.execute && !options.skipConfirmation) { + console.log("\n⚠️ WARNING: This will permanently delete these users!"); + const confirmed = await confirm("Are you sure you want to continue?"); + + if (!confirmed) { + console.log("\n❌ Cleanup cancelled"); + process.exit(0); + } + } + + // Delete users + await deleteUsers(users, options.execute); + + if (!options.execute) { + console.log("\n💡 To actually delete these users, run:"); + console.log(" npm run cleanup:test-users -- --execute"); + } + } catch (error) { + console.error("\n❌ Error during cleanup:", error); + process.exit(1); + } +} + +// Run cleanup +cleanup();