Enhance E2E Tests and Configuration (#358)
* Enhance E2E Tests and Configuration - Updated `.gitignore` to exclude `.auth/` directory for cleaner test environment. - Added `test:fast` script in `package.json` for faster Playwright test execution. - Configured Playwright to include a setup project for initializing tests. - Introduced `AUTH_STATES` utility for managing authentication states in tests. - Created `auth.setup.ts` for user authentication tests, ensuring proper login flows. - Refactored various test files to utilize the new authentication state management, improving test reliability and maintainability. - Adjusted team and user billing tests to streamline setup and enhance clarity. - Refactored some dialogs
This commit is contained in:
committed by
GitHub
parent
f157cc7f3e
commit
02e2502dcc
1
apps/e2e/.gitignore
vendored
1
apps/e2e/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.auth
|
||||
|
||||
105
apps/e2e/AGENTS.md
Normal file
105
apps/e2e/AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
## End-to-End Testing with Playwright
|
||||
|
||||
### Page Object Pattern (Required)
|
||||
|
||||
Always use Page Objects for test organization and reusability:
|
||||
|
||||
```typescript
|
||||
// Example: auth.po.ts
|
||||
export class AuthPageObject {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async signIn(params: { email: string; password: string }) {
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reliability Patterns
|
||||
|
||||
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Reliable email/OTP operations
|
||||
await expect(async () => {
|
||||
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||
expect(otpCode).not.toBeNull();
|
||||
await this.enterOtpCode(otpCode);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Network requests with validation
|
||||
await expect(async () => {
|
||||
const response = await this.page.waitForResponse(resp =>
|
||||
resp.url().includes('auth/v1/user')
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Complex operations with custom intervals
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||
});
|
||||
```
|
||||
|
||||
### Test Data Management
|
||||
|
||||
**Email Testing**: Use `createRandomEmail()` for unique test emails:
|
||||
```typescript
|
||||
createRandomEmail() {
|
||||
const value = Math.random() * 10000000000000;
|
||||
return `${value.toFixed(0)}@makerkit.dev`;
|
||||
}
|
||||
```
|
||||
|
||||
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
|
||||
```typescript
|
||||
await auth.bootstrapUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
name: 'Test User'
|
||||
});
|
||||
```
|
||||
|
||||
This method creates a user with an API call.
|
||||
|
||||
To sign in:
|
||||
|
||||
```tsx
|
||||
await auth.loginAsUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
```
|
||||
|
||||
### Test Selectors
|
||||
|
||||
**Always use `data-test` attributes** for reliable element selection:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use data-test attributes
|
||||
await this.page.click('[data-test="submit-button"]');
|
||||
await this.page.fill('[data-test="email-input"]', email);
|
||||
|
||||
// ✅ OR
|
||||
await this.page.getByTestId('submit-button').click();
|
||||
|
||||
// ❌ AVOID - Fragile selectors
|
||||
await this.page.click('.btn-primary');
|
||||
await this.page.click('button:nth-child(2)');
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
|
||||
- **Page Objects**: `*.po.ts` files for reusable page interactions
|
||||
- **Setup files**: `auth.setup.ts` for global test setup
|
||||
- **Utility classes**: `/tests/utils/` for shared functionality
|
||||
105
apps/e2e/CLAUDE.md
Normal file
105
apps/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
## End-to-End Testing with Playwright
|
||||
|
||||
### Page Object Pattern (Required)
|
||||
|
||||
Always use Page Objects for test organization and reusability:
|
||||
|
||||
```typescript
|
||||
// Example: auth.po.ts
|
||||
export class AuthPageObject {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async signIn(params: { email: string; password: string }) {
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reliability Patterns
|
||||
|
||||
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Reliable email/OTP operations
|
||||
await expect(async () => {
|
||||
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||
expect(otpCode).not.toBeNull();
|
||||
await this.enterOtpCode(otpCode);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Network requests with validation
|
||||
await expect(async () => {
|
||||
const response = await this.page.waitForResponse(resp =>
|
||||
resp.url().includes('auth/v1/user')
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}).toPass();
|
||||
|
||||
// ✅ CORRECT - Complex operations with custom intervals
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||
});
|
||||
```
|
||||
|
||||
### Test Data Management
|
||||
|
||||
**Email Testing**: Use `createRandomEmail()` for unique test emails:
|
||||
```typescript
|
||||
createRandomEmail() {
|
||||
const value = Math.random() * 10000000000000;
|
||||
return `${value.toFixed(0)}@makerkit.dev`;
|
||||
}
|
||||
```
|
||||
|
||||
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
|
||||
```typescript
|
||||
await auth.bootstrapUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
name: 'Test User'
|
||||
});
|
||||
```
|
||||
|
||||
This method creates a user with an API call.
|
||||
|
||||
To sign in:
|
||||
|
||||
```tsx
|
||||
await auth.loginAsUser({
|
||||
email: 'test@example.com',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
```
|
||||
|
||||
### Test Selectors
|
||||
|
||||
**Always use `data-test` attributes** for reliable element selection:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use data-test attributes
|
||||
await this.page.click('[data-test="submit-button"]');
|
||||
await this.page.fill('[data-test="email-input"]', email);
|
||||
|
||||
// ✅ OR
|
||||
await this.page.getByTestId('submit-button').click();
|
||||
|
||||
// ❌ AVOID - Fragile selectors
|
||||
await this.page.click('.btn-primary');
|
||||
await this.page.click('button:nth-child(2)');
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
|
||||
- **Page Objects**: `*.po.ts` files for reusable page interactions
|
||||
- **Setup files**: `auth.setup.ts` for global test setup
|
||||
- **Utility classes**: `/tests/utils/` for shared functionality
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "web-e2e",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"report": "playwright show-report",
|
||||
"test": "playwright test --max-failures=1",
|
||||
"test:fast": "playwright test --max-failures=1 --workers=16",
|
||||
"test:setup": "playwright test tests/auth.setup.ts",
|
||||
"test:ui": "playwright test --ui"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"author": "Makerkit",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@types/node": "^24.5.2",
|
||||
"dotenv": "17.2.2",
|
||||
"node-html-parser": "^7.0.1",
|
||||
|
||||
@@ -7,9 +7,10 @@ dotenvConfig({ path: '.env.local' });
|
||||
/**
|
||||
* Number of workers to use in CI. Tweak based on your CI provider's resources.
|
||||
*/
|
||||
const CI_WORKERS = 3;
|
||||
const CI_WORKERS = 2;
|
||||
|
||||
const enableBillingTests = process.env.ENABLE_BILLING_TESTS === 'true';
|
||||
|
||||
const enableTeamAccountTests =
|
||||
(process.env.ENABLE_TEAM_ACCOUNT_TESTS ?? 'true') === 'true';
|
||||
|
||||
@@ -50,7 +51,7 @@ export default defineConfig({
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 3,
|
||||
retries: 2,
|
||||
/* Limit parallel tests on CI. */
|
||||
workers: process.env.CI ? CI_WORKERS : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
@@ -68,6 +69,7 @@ export default defineConfig({
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
navigationTimeout: 15 * 1000,
|
||||
testIdAttribute: 'data-test',
|
||||
},
|
||||
// test timeout set to 2 minutes
|
||||
timeout: 120 * 1000,
|
||||
@@ -77,9 +79,11 @@ export default defineConfig({
|
||||
},
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
|
||||
@@ -14,10 +14,6 @@ export class AccountPageObject {
|
||||
this.otp = new OtpPo(page);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
return this.auth.signUpFlow('/home/settings');
|
||||
}
|
||||
|
||||
async updateName(name: string) {
|
||||
await this.page.fill('[data-test="update-account-name-form"] input', name);
|
||||
await this.page.click('[data-test="update-account-name-form"] button');
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { AccountPageObject } from './account.po';
|
||||
import {AuthPageObject} from "../authentication/auth.po";
|
||||
|
||||
test.describe('Account Settings', () => {
|
||||
let page: Page;
|
||||
let account: AccountPageObject;
|
||||
let email: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
email = auth.createRandomEmail();
|
||||
|
||||
auth.bootstrapUser({
|
||||
email,
|
||||
password: 'testingpassword',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
account = new AccountPageObject(page);
|
||||
|
||||
await account.setup();
|
||||
await auth.loginAsUser({
|
||||
email,
|
||||
password: 'testingpassword',
|
||||
next: '/home/settings',
|
||||
});
|
||||
});
|
||||
|
||||
test('user can update their profile name', async () => {
|
||||
test('user can update their profile name', async ({ page }) => {
|
||||
const name = 'John Doe';
|
||||
|
||||
const request = account.updateName(name);
|
||||
@@ -28,13 +41,13 @@ test.describe('Account Settings', () => {
|
||||
await expect(account.getProfileName()).toHaveText(name);
|
||||
});
|
||||
|
||||
test('user can update their email', async () => {
|
||||
test('user can update their email', async ({ page }) => {
|
||||
const email = account.auth.createRandomEmail();
|
||||
|
||||
await account.updateEmail(email);
|
||||
});
|
||||
|
||||
test('user can update their password', async () => {
|
||||
test('user can update their password', async ({ page }) => {
|
||||
const password = (Math.random() * 100000).toString();
|
||||
|
||||
const request = account.updatePassword(password);
|
||||
@@ -53,10 +66,19 @@ test.describe('Account Settings', () => {
|
||||
|
||||
test.describe('Account Deletion', () => {
|
||||
test('user can delete their own account', async ({ page }) => {
|
||||
const account = new AccountPageObject(page);
|
||||
// Create a fresh user for this test since we'll be deleting it
|
||||
const auth = new AuthPageObject(page);
|
||||
const account = new AccountPageObject(page);
|
||||
|
||||
const { email } = await account.setup();
|
||||
const email = auth.createRandomEmail();
|
||||
|
||||
await auth.bootstrapUser({
|
||||
email,
|
||||
password: 'testingpassword',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
await auth.loginAsUser({ email, next: '/home/settings' });
|
||||
|
||||
await account.deleteAccount(email);
|
||||
|
||||
@@ -70,6 +92,8 @@ test.describe('Account Deletion', () => {
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-test="auth-error-message"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-test="auth-error-message"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
import { Page, expect, selectors, test } from '@playwright/test';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
|
||||
|
||||
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
||||
import { AUTH_STATES } from '../utils/auth-state';
|
||||
|
||||
test.describe('Admin Auth flow without MFA', () => {
|
||||
AuthPageObject.setupSession(AUTH_STATES.OWNER_USER);
|
||||
|
||||
test('will return a 404 for non-admin users', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await auth.signIn({
|
||||
email: 'owner@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home');
|
||||
|
||||
await page.goto('/admin');
|
||||
|
||||
expect(page.url()).toContain('/404');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Auth flow with Super Admin but without MFA', () => {
|
||||
AuthPageObject.setupSession(AUTH_STATES.TEST_USER);
|
||||
|
||||
test('will redirect to 404 for admin users without MFA', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await auth.signIn({
|
||||
email: 'test@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home');
|
||||
|
||||
await page.goto('/admin');
|
||||
|
||||
expect(page.url()).toContain('/404');
|
||||
@@ -42,12 +25,13 @@ test.describe('Admin Auth flow without MFA', () => {
|
||||
});
|
||||
|
||||
test.describe('Admin', () => {
|
||||
// must be serial because OTP verification is not working in parallel
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Admin Dashboard', () => {
|
||||
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
|
||||
|
||||
test('displays all stat cards', async ({ page }) => {
|
||||
await goToAdmin(page);
|
||||
await page.goto('/admin');
|
||||
|
||||
// Check all stat cards are present
|
||||
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
|
||||
@@ -73,19 +57,14 @@ test.describe('Admin', () => {
|
||||
});
|
||||
|
||||
test.describe('Personal Account Management', () => {
|
||||
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
|
||||
|
||||
let testUserEmail: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
selectors.setTestIdAttribute('data-test');
|
||||
|
||||
// Create a new test user before each test
|
||||
testUserEmail = await createUser(page);
|
||||
|
||||
await goToAdmin(page);
|
||||
|
||||
// Navigate to the newly created user's account page
|
||||
// Note: We need to get the user's ID from the email - this might need adjustment
|
||||
// based on your URL structure
|
||||
await page.goto(`/admin/accounts`);
|
||||
|
||||
// use the email as the filter text
|
||||
@@ -99,6 +78,7 @@ test.describe('Admin', () => {
|
||||
await expect(page.getByText('Personal Account')).toBeVisible();
|
||||
await expect(page.getByTestId('admin-ban-account-button')).toBeVisible();
|
||||
await expect(page.getByTestId('admin-impersonate-button')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('admin-delete-account-button'),
|
||||
).toBeVisible();
|
||||
@@ -106,6 +86,7 @@ test.describe('Admin', () => {
|
||||
|
||||
test('ban user flow', async ({ page }) => {
|
||||
await page.getByTestId('admin-ban-account-button').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Ban User' }),
|
||||
).toBeVisible();
|
||||
@@ -189,29 +170,14 @@ test.describe('Admin', () => {
|
||||
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.signIn({
|
||||
await auth.loginAsUser({
|
||||
email: testUserEmail,
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home');
|
||||
});
|
||||
|
||||
test('impersonate user flow', async ({ page }) => {
|
||||
await page.getByTestId('admin-impersonate-button').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Impersonate User' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Impersonate User' }).click();
|
||||
|
||||
// Should redirect to home and be logged in as the user
|
||||
await page.waitForURL('/home');
|
||||
});
|
||||
|
||||
test('delete user flow', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.getByTestId('admin-delete-account-button').click();
|
||||
|
||||
await expect(
|
||||
@@ -236,13 +202,10 @@ test.describe('Admin', () => {
|
||||
await page.waitForURL('/admin/accounts');
|
||||
|
||||
// Log out
|
||||
await page.context().clearCookies();
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Verify user can't log in
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
const auth = new AuthPageObject(page);
|
||||
await auth.goToSignIn();
|
||||
|
||||
await auth.signIn({
|
||||
email: testUserEmail,
|
||||
@@ -256,130 +219,116 @@ test.describe('Admin', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Team Account Management', () => {
|
||||
test.skip(
|
||||
process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true',
|
||||
'Team account tests are disabled',
|
||||
);
|
||||
test.describe('Impersonation', () => {
|
||||
test('can sign in as a user', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
let testUserEmail: string;
|
||||
let teamName: string;
|
||||
let slug: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
selectors.setTestIdAttribute('data-test');
|
||||
|
||||
// Create a new test user and team account
|
||||
testUserEmail = await createUser(page, {
|
||||
afterSignIn: async () => {
|
||||
teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
const teamAccountPo = new TeamAccountsPageObject(page);
|
||||
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
slug = teamSlug;
|
||||
|
||||
await teamAccountPo.createTeam({
|
||||
teamName,
|
||||
slug,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await goToAdmin(page);
|
||||
await auth.loginAsSuperAdmin({});
|
||||
const filterText = await createUser(page);
|
||||
|
||||
await page.goto(`/admin/accounts`);
|
||||
|
||||
await filterAccounts(page, teamName);
|
||||
await selectAccount(page, teamName);
|
||||
});
|
||||
await filterAccounts(page, filterText);
|
||||
await selectAccount(page, filterText);
|
||||
|
||||
test('displays team account details', async ({ page }) => {
|
||||
await expect(page.getByText('Team Account')).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('admin-delete-account-button'),
|
||||
).toBeVisible();
|
||||
});
|
||||
await page.getByTestId('admin-impersonate-button').click();
|
||||
|
||||
test('delete team account flow', async ({ page }) => {
|
||||
await page.getByTestId('admin-delete-account-button').click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete Account' }),
|
||||
page.getByRole('heading', { name: 'Impersonate User' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Try with invalid confirmation
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete Account' }),
|
||||
).toBeVisible(); // Dialog should still be open
|
||||
|
||||
// Confirm with correct text
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Impersonate User' }).click();
|
||||
|
||||
// Should redirect to admin dashboard after deletion
|
||||
await expect(page).toHaveURL('/admin/accounts');
|
||||
// Should redirect to home and be logged in as the user
|
||||
await page.waitForURL('/home');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function goToAdmin(page: Page) {
|
||||
const auth = new AuthPageObject(page);
|
||||
test.describe('Team Account Management', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
await page.goto('/auth/sign-in');
|
||||
test.skip(
|
||||
process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true',
|
||||
'Team account tests are disabled',
|
||||
);
|
||||
|
||||
await auth.signIn({
|
||||
email: 'super-admin@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
let testUserEmail: string;
|
||||
let teamName: string;
|
||||
let slug: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
// Create a new test user and team account
|
||||
testUserEmail = await createUser(page);
|
||||
|
||||
teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
await auth.loginAsUser({ email: testUserEmail });
|
||||
|
||||
const teamAccountPo = new TeamAccountsPageObject(page);
|
||||
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
slug = teamSlug;
|
||||
|
||||
await teamAccountPo.createTeam({
|
||||
teamName,
|
||||
slug,
|
||||
});
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
|
||||
await auth.loginAsSuperAdmin({});
|
||||
|
||||
await page.goto(`/admin/accounts`);
|
||||
|
||||
await filterAccounts(page, teamName);
|
||||
await selectAccount(page, teamName);
|
||||
});
|
||||
|
||||
await page.waitForURL('/auth/verify');
|
||||
await page.waitForTimeout(250);
|
||||
test('delete team account flow', async ({ page }) => {
|
||||
await expect(page.getByText('Team Account')).toBeVisible();
|
||||
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(MFA_KEY);
|
||||
await page.waitForURL('/home');
|
||||
}).toPass({
|
||||
intervals: [
|
||||
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
|
||||
40_000, 45_000, 50_000,
|
||||
],
|
||||
await page.getByTestId('admin-delete-account-button').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete Account' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Try with invalid confirmation
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete Account' }),
|
||||
).toBeVisible(); // Dialog should still be open
|
||||
|
||||
// Confirm with correct text
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Should redirect to admin dashboard after deletion
|
||||
await expect(page).toHaveURL('/admin/accounts');
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
page: Page,
|
||||
params: {
|
||||
afterSignIn?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
async function createUser(page: Page) {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
const password = 'testingpassword';
|
||||
const email = auth.createRandomEmail();
|
||||
|
||||
// sign up
|
||||
await page.goto('/auth/sign-up');
|
||||
|
||||
await auth.signUp({
|
||||
// create user using bootstrap method
|
||||
await auth.bootstrapUser({
|
||||
email,
|
||||
password,
|
||||
repeatPassword: password,
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
// confirm email
|
||||
await auth.visitConfirmEmailLink(email);
|
||||
|
||||
if (params.afterSignIn) {
|
||||
await params.afterSignIn();
|
||||
}
|
||||
|
||||
// sign out
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
|
||||
// return the email
|
||||
return email;
|
||||
}
|
||||
@@ -404,6 +353,6 @@ async function selectAccount(page: Page, email: string) {
|
||||
|
||||
await link.click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
37
apps/e2e/tests/auth.setup.ts
Normal file
37
apps/e2e/tests/auth.setup.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { join } from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
|
||||
import { AuthPageObject } from './authentication/auth.po';
|
||||
|
||||
const testAuthFile = join(cwd(), '.auth/test@makerkit.dev.json');
|
||||
const ownerAuthFile = join(cwd(), '.auth/owner@makerkit.dev.json');
|
||||
const superAdminAuthFile = join(cwd(), '.auth/super-admin@makerkit.dev.json');
|
||||
|
||||
test('authenticate as test user', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.loginAsUser({
|
||||
email: 'test@makerkit.dev',
|
||||
});
|
||||
|
||||
await page.context().storageState({ path: testAuthFile });
|
||||
});
|
||||
|
||||
test('authenticate as owner user', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.loginAsUser({
|
||||
email: 'owner@makerkit.dev',
|
||||
});
|
||||
|
||||
await page.context().storageState({ path: ownerAuthFile });
|
||||
});
|
||||
|
||||
test('authenticate as super-admin user', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.loginAsSuperAdmin({});
|
||||
|
||||
await page.context().storageState({ path: superAdminAuthFile });
|
||||
});
|
||||
@@ -1,22 +1,33 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import test, { Page, expect } from '@playwright/test';
|
||||
|
||||
import { AUTH_STATES } from '../utils/auth-state';
|
||||
import { Mailbox } from '../utils/mailbox';
|
||||
|
||||
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
||||
|
||||
export class AuthPageObject {
|
||||
private readonly page: Page;
|
||||
private readonly mailbox: Mailbox;
|
||||
|
||||
static MFA_KEY = MFA_KEY;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.mailbox = new Mailbox(page);
|
||||
}
|
||||
|
||||
goToSignIn() {
|
||||
return this.page.goto('/auth/sign-in');
|
||||
static setupSession(user: (typeof AUTH_STATES)[keyof typeof AUTH_STATES]) {
|
||||
test.use({ storageState: user });
|
||||
}
|
||||
|
||||
goToSignUp() {
|
||||
return this.page.goto('/auth/sign-up');
|
||||
goToSignIn(next?: string) {
|
||||
return this.page.goto(`/auth/sign-in${next ? `?next=${next}` : ''}`);
|
||||
}
|
||||
|
||||
goToSignUp(next?: string) {
|
||||
return this.page.goto(`/auth/sign-up${next ? `?next=${next}` : ''}`);
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
@@ -25,7 +36,7 @@ export class AuthPageObject {
|
||||
}
|
||||
|
||||
async signIn(params: { email: string; password: string }) {
|
||||
await this.page.waitForTimeout(500);
|
||||
await this.page.waitForTimeout(100);
|
||||
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
@@ -37,7 +48,7 @@ export class AuthPageObject {
|
||||
password: string;
|
||||
repeatPassword: string;
|
||||
}) {
|
||||
await this.page.waitForTimeout(500);
|
||||
await this.page.waitForTimeout(100);
|
||||
|
||||
await this.page.fill('input[name="email"]', params.email);
|
||||
await this.page.fill('input[name="password"]', params.password);
|
||||
@@ -108,4 +119,60 @@ export class AuthPageObject {
|
||||
await this.page.fill('[name="repeatPassword"]', password);
|
||||
await this.page.click('[type="submit"]');
|
||||
}
|
||||
|
||||
async loginAsSuperAdmin(params: { next?: string }) {
|
||||
await this.loginAsUser({
|
||||
email: 'super-admin@makerkit.dev',
|
||||
next: '/auth/verify',
|
||||
});
|
||||
|
||||
// Complete MFA verification
|
||||
await this.submitMFAVerification(MFA_KEY);
|
||||
await this.page.waitForURL(params.next ?? '/home');
|
||||
}
|
||||
|
||||
async bootstrapUser({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}: {
|
||||
email: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
}) {
|
||||
const client = createClient(
|
||||
'http://127.0.0.1:54321',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU',
|
||||
);
|
||||
|
||||
const { data, error } = await client.auth.admin.createUser({
|
||||
email,
|
||||
password: password || 'testingpassword',
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create user: ${error.message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async loginAsUser(params: {
|
||||
email: string;
|
||||
password?: string;
|
||||
next?: string;
|
||||
}) {
|
||||
await this.goToSignIn(params.next);
|
||||
|
||||
await this.signIn({
|
||||
email: params.email,
|
||||
password: params.password || 'testingpassword',
|
||||
});
|
||||
|
||||
await this.page.waitForURL(params.next ?? '**/home');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ test.describe('Auth flow', () => {
|
||||
|
||||
await auth.visitConfirmEmailLink(email);
|
||||
|
||||
await page.waitForURL('**/home');
|
||||
await page.waitForURL('**/home', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('will sign-in with the correct credentials', async ({ page }) => {
|
||||
@@ -43,7 +45,9 @@ test.describe('Auth flow', () => {
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
await page.waitForURL('**/home');
|
||||
await page.waitForURL('**/home', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
expect(page.url()).toContain('/home');
|
||||
|
||||
@@ -62,7 +66,9 @@ test.describe('Auth flow', () => {
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home/settings');
|
||||
await page.waitForURL('/home/settings', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await auth.signOut();
|
||||
|
||||
@@ -75,17 +81,14 @@ test.describe('Protected routes', () => {
|
||||
page,
|
||||
}) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
const path = '/home/settings';
|
||||
|
||||
await page.goto('/home/settings');
|
||||
await page.goto(path);
|
||||
|
||||
await auth.signIn({
|
||||
await auth.loginAsUser({
|
||||
email: 'test@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
next: path,
|
||||
});
|
||||
|
||||
await page.waitForURL('/home/settings');
|
||||
|
||||
expect(page.url()).toContain('/home/settings');
|
||||
});
|
||||
|
||||
test('will redirect to the sign-in page if not authenticated', async ({
|
||||
@@ -115,7 +118,9 @@ test.describe('Last auth method tracking', () => {
|
||||
});
|
||||
|
||||
await auth.visitConfirmEmailLink(testEmail);
|
||||
await page.waitForURL('**/home');
|
||||
await page.waitForURL('**/home', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Sign out
|
||||
await auth.signOut();
|
||||
@@ -169,7 +174,9 @@ test.describe('Last auth method tracking', () => {
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
await page.waitForURL('**/home');
|
||||
await page.waitForURL('**/home', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Sign out and check the method is still tracked
|
||||
await auth.signOut();
|
||||
|
||||
@@ -13,22 +13,12 @@ test.describe('Password Reset Flow', () => {
|
||||
await expect(async () => {
|
||||
email = auth.createRandomEmail();
|
||||
|
||||
await page.goto('/auth/sign-up');
|
||||
|
||||
await auth.signUp({
|
||||
auth.bootstrapUser({
|
||||
email,
|
||||
password: 'password',
|
||||
repeatPassword: 'password',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
await auth.visitConfirmEmailLink(email, {
|
||||
deleteAfter: true,
|
||||
subject: 'Confirm your email',
|
||||
});
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
await page.goto('/auth/password-reset');
|
||||
|
||||
await page.fill('[name="email"]', email);
|
||||
@@ -59,13 +49,10 @@ test.describe('Password Reset Flow', () => {
|
||||
await page.waitForURL('/');
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await auth.signIn({
|
||||
await auth.loginAsUser({
|
||||
email,
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
await page.waitForURL('/home', {
|
||||
timeout: 2000,
|
||||
next: '/home',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { InvitationsPageObject } from './invitations.po';
|
||||
|
||||
test.describe('Invitations', () => {
|
||||
let page: Page;
|
||||
let invitations: InvitationsPageObject;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ page }) => {
|
||||
invitations = new InvitationsPageObject(page);
|
||||
|
||||
await invitations.setup();
|
||||
@@ -88,17 +86,12 @@ test.describe('Invitations', () => {
|
||||
});
|
||||
|
||||
test.describe('Full Invitation Flow', () => {
|
||||
let page: Page;
|
||||
let invitations: InvitationsPageObject;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
invitations = new InvitationsPageObject(page);
|
||||
|
||||
test('should invite users and let users accept an invite', async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
await invitations.setup();
|
||||
});
|
||||
|
||||
test('should invite users and let users accept an invite', async () => {
|
||||
await invitations.navigateToMembers();
|
||||
|
||||
const invites = [
|
||||
|
||||
@@ -15,11 +15,24 @@ export class TeamAccountsPageObject {
|
||||
}
|
||||
|
||||
async setup(params = this.createTeamName()) {
|
||||
const { email } = await this.auth.signUpFlow('/home');
|
||||
const auth = new AuthPageObject(this.page);
|
||||
|
||||
const email = auth.createRandomEmail();
|
||||
|
||||
await auth.bootstrapUser({
|
||||
email,
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
await auth.loginAsUser({ email });
|
||||
|
||||
await this.createTeam(params);
|
||||
|
||||
return { email, teamName: params.teamName, slug: params.slug };
|
||||
return {
|
||||
email: email,
|
||||
teamName: params.teamName,
|
||||
slug: params.slug,
|
||||
};
|
||||
}
|
||||
|
||||
getTeamFromSelector(teamName: string) {
|
||||
@@ -81,11 +94,12 @@ export class TeamAccountsPageObject {
|
||||
async tryCreateTeam(teamName: string) {
|
||||
await this.page.locator('[data-test="create-team-form"] input').fill('');
|
||||
await this.page.waitForTimeout(200);
|
||||
await this.page.locator('[data-test="create-team-form"] input').fill(teamName);
|
||||
|
||||
return this.page.click(
|
||||
'[data-test="create-team-form"] button:last-child',
|
||||
);
|
||||
await this.page
|
||||
.locator('[data-test="create-team-form"] input')
|
||||
.fill(teamName);
|
||||
|
||||
return this.page.click('[data-test="create-team-form"] button:last-child');
|
||||
}
|
||||
|
||||
async createTeam({ teamName, slug } = this.createTeamName()) {
|
||||
@@ -150,13 +164,13 @@ export class TeamAccountsPageObject {
|
||||
await this.page.click('[data-test="role-selector-trigger"]');
|
||||
await this.page.click(`[data-test="role-option-${newRole}"]`);
|
||||
|
||||
// Click the confirm button
|
||||
const click = this.page.click('[data-test="confirm-update-member-role"]');
|
||||
|
||||
// Wait for the update to complete and page to reload
|
||||
const response = this.page.waitForURL('**/home/*/members');
|
||||
const response = this.page.waitForResponse('**/members');
|
||||
|
||||
return Promise.all([click, response]);
|
||||
return Promise.all([
|
||||
this.page.click('[data-test="confirm-update-member-role"]'),
|
||||
response,
|
||||
]);
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
@@ -172,15 +186,13 @@ export class TeamAccountsPageObject {
|
||||
// Complete OTP verification
|
||||
await this.otp.completeOtpVerification(ownerEmail);
|
||||
|
||||
// Click the confirm button
|
||||
const click = this.page.click(
|
||||
'[data-test="confirm-transfer-ownership-button"]',
|
||||
);
|
||||
|
||||
// Wait for the transfer to complete and page to reload
|
||||
const response = this.page.waitForURL('**/home/*/members');
|
||||
const response = this.page.waitForResponse('**/members');
|
||||
|
||||
return Promise.all([click, response]);
|
||||
return Promise.all([
|
||||
this.page.click('[data-test="confirm-transfer-ownership-button"]'),
|
||||
response,
|
||||
]);
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,11 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
|
||||
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await invitations.auth.signIn({
|
||||
await invitations.auth.loginAsUser({
|
||||
email: ownerEmail,
|
||||
password: 'password',
|
||||
next: '/home',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home');
|
||||
|
||||
// Navigate to the team members page
|
||||
await page.goto(`/home/${slug}/members`);
|
||||
|
||||
@@ -70,17 +68,13 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
|
||||
}
|
||||
|
||||
test.describe('Team Accounts', () => {
|
||||
let page: Page;
|
||||
let teamAccounts: TeamAccountsPageObject;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
teamAccounts = new TeamAccountsPageObject(page);
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const teamAccounts = new TeamAccountsPageObject(page);
|
||||
await teamAccounts.setup();
|
||||
});
|
||||
|
||||
test('user can update their team name (and slug)', async () => {
|
||||
await teamAccounts.setup();
|
||||
|
||||
test('user can update their team name (and slug)', async ({ page }) => {
|
||||
const teamAccounts = new TeamAccountsPageObject(page);
|
||||
const { teamName, slug } = teamAccounts.createTeamName();
|
||||
|
||||
await teamAccounts.goToSettings();
|
||||
@@ -101,7 +95,7 @@ test.describe('Team Accounts', () => {
|
||||
page,
|
||||
}) => {
|
||||
const teamAccounts = new TeamAccountsPageObject(page);
|
||||
await teamAccounts.setup();
|
||||
await teamAccounts.createTeam();
|
||||
|
||||
await teamAccounts.openAccountsSelector();
|
||||
await page.click('[data-test="create-team-account-trigger"]');
|
||||
@@ -132,16 +126,16 @@ test.describe('Team Accounts', () => {
|
||||
await teamAccounts.tryCreateTeam('Test,Name');
|
||||
await expectError();
|
||||
|
||||
await teamAccounts.tryCreateTeam('Test Name/')
|
||||
await teamAccounts.tryCreateTeam('Test Name/');
|
||||
await expectError();
|
||||
|
||||
await teamAccounts.tryCreateTeam('Test Name\\')
|
||||
await teamAccounts.tryCreateTeam('Test Name\\');
|
||||
await expectError();
|
||||
|
||||
await teamAccounts.tryCreateTeam('Test Name:')
|
||||
await teamAccounts.tryCreateTeam('Test Name:');
|
||||
await expectError();
|
||||
|
||||
await teamAccounts.tryCreateTeam('Test Name;')
|
||||
await teamAccounts.tryCreateTeam('Test Name;');
|
||||
await expectError();
|
||||
|
||||
await teamAccounts.tryCreateTeam('Test Name=');
|
||||
@@ -225,14 +219,11 @@ test.describe('Team Member Role Management', () => {
|
||||
// Update the member's role to admin
|
||||
await teamAccounts.updateMemberRole(memberEmail, 'owner');
|
||||
|
||||
// Wait for the page to fully load after the update
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the role was updated successfully
|
||||
const updatedRoleBadge = page
|
||||
.getByRole('row', { name: memberEmail })
|
||||
.locator('[data-test="member-role-badge"]');
|
||||
await expect(updatedRoleBadge).toHaveText('Owner');
|
||||
await expect(
|
||||
page
|
||||
.getByRole('row', { name: memberEmail })
|
||||
.locator('[data-test="member-role-badge"]'),
|
||||
).toHaveText('Owner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,7 +241,7 @@ test.describe('Team Ownership Transfer', () => {
|
||||
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
|
||||
|
||||
// Wait for the page to fully load after the transfer
|
||||
await page.waitForTimeout(1000);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the transfer was successful by checking if the primary owner badge
|
||||
// is now on the new owner's row
|
||||
|
||||
@@ -4,8 +4,6 @@ import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { InvitationsPageObject } from '../invitations/invitations.po';
|
||||
import { TeamAccountsPageObject } from './team-accounts.po';
|
||||
|
||||
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
||||
|
||||
test.describe('Team Invitation with MFA Flow', () => {
|
||||
test('complete flow: test@makerkit.dev creates team, invites super-admin@makerkit.dev who accepts after MFA', async ({
|
||||
page,
|
||||
@@ -14,18 +12,12 @@ test.describe('Team Invitation with MFA Flow', () => {
|
||||
const teamAccounts = new TeamAccountsPageObject(page);
|
||||
const invitations = new InvitationsPageObject(page);
|
||||
|
||||
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
||||
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
// Step 1: test@makerkit.dev creates a team and sends invitation
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await auth.signIn({
|
||||
await auth.loginAsUser({
|
||||
email: 'test@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home');
|
||||
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
||||
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
// Create a new team
|
||||
await teamAccounts.createTeam({
|
||||
@@ -46,19 +38,24 @@ test.describe('Team Invitation with MFA Flow', () => {
|
||||
|
||||
// Verify invitation was sent
|
||||
await expect(invitations.getInvitations()).toHaveCount(1);
|
||||
|
||||
const invitationRow = invitations.getInvitationRow(
|
||||
'super-admin@makerkit.dev',
|
||||
);
|
||||
|
||||
await expect(invitationRow).toBeVisible();
|
||||
|
||||
// Sign out test@makerkit.dev
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
await expect(async () => {
|
||||
// Sign out test@makerkit.dev
|
||||
await auth.signOut();
|
||||
|
||||
// Step 2: super-admin@makerkit.dev signs in with MFA
|
||||
await page.context().clearCookies();
|
||||
await page.waitForURL('/', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}).toPass();
|
||||
|
||||
await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
|
||||
|
||||
await page
|
||||
.locator('[data-test="existing-account-hint"]')
|
||||
.getByRole('link', { name: 'Already have an account?' })
|
||||
@@ -71,7 +68,7 @@ test.describe('Team Invitation with MFA Flow', () => {
|
||||
|
||||
// Complete MFA verification
|
||||
await expect(async () => {
|
||||
await auth.submitMFAVerification(MFA_KEY);
|
||||
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||
}).toPass({
|
||||
intervals: [
|
||||
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
|
||||
@@ -91,6 +88,7 @@ test.describe('Team Invitation with MFA Flow', () => {
|
||||
// Step 4: Verify membership was successful
|
||||
// Open account selector to verify team is available
|
||||
await teamAccounts.openAccountsSelector();
|
||||
|
||||
const team = teamAccounts.getTeamFromSelector(teamName);
|
||||
|
||||
await expect(team).toBeVisible();
|
||||
|
||||
@@ -13,6 +13,6 @@ export class TeamBillingPageObject {
|
||||
}
|
||||
|
||||
setup() {
|
||||
return this.teamAccounts.setup();
|
||||
return this.teamAccounts.createTeam();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { TeamBillingPageObject } from './team-billing.po';
|
||||
|
||||
test.describe('Team Billing', () => {
|
||||
let page: Page;
|
||||
let po: TeamBillingPageObject;
|
||||
test('a team can subscribe to a plan', async ({ page }) => {
|
||||
const po = new TeamBillingPageObject(page);
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
po = new TeamBillingPageObject(page);
|
||||
});
|
||||
|
||||
test('a team can subscribe to a plan', async () => {
|
||||
await po.setup();
|
||||
await po.teamAccounts.setup();
|
||||
await po.teamAccounts.goToBilling();
|
||||
|
||||
await po.billing.selectPlan(0);
|
||||
@@ -23,7 +18,7 @@ test.describe('Team Billing', () => {
|
||||
await po.billing.stripe.submitForm();
|
||||
|
||||
await expect(po.billing.successStatus()).toBeVisible({
|
||||
timeout: 25_000,
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await po.billing.returnToBilling();
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
|
||||
import { BillingPageObject } from '../utils/billing.po';
|
||||
|
||||
export class UserBillingPageObject {
|
||||
private readonly auth: AuthPageObject;
|
||||
public readonly billing: BillingPageObject;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.auth = new AuthPageObject(page);
|
||||
constructor(private readonly page: Page) {
|
||||
this.billing = new BillingPageObject(page);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await this.auth.signUpFlow('/home/billing');
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { UserBillingPageObject } from './user-billing.po';
|
||||
|
||||
test.describe('User Billing', () => {
|
||||
let page: Page;
|
||||
let po: UserBillingPageObject;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
po = new UserBillingPageObject(page);
|
||||
|
||||
await po.setup();
|
||||
});
|
||||
|
||||
test('user can subscribe to a plan', async ({ page }) => {
|
||||
const po = new UserBillingPageObject(page);
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
const email = auth.createRandomEmail();
|
||||
|
||||
await auth.bootstrapUser({
|
||||
email,
|
||||
name: 'Test Billing User',
|
||||
});
|
||||
|
||||
await auth.loginAsUser({
|
||||
email,
|
||||
next: '/home/billing',
|
||||
});
|
||||
|
||||
await po.billing.selectPlan(0);
|
||||
await po.billing.proceedToCheckout();
|
||||
|
||||
|
||||
10
apps/e2e/tests/utils/auth-state.ts
Normal file
10
apps/e2e/tests/utils/auth-state.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { join } from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
|
||||
export const AUTH_STATES = {
|
||||
TEST_USER: join(cwd(), '.auth/test@makerkit.dev.json'),
|
||||
OWNER_USER: join(cwd(), '.auth/owner@makerkit.dev.json'),
|
||||
SUPER_ADMIN: join(cwd(), '.auth/super-admin@makerkit.dev.json'),
|
||||
} as const;
|
||||
|
||||
export type AuthState = keyof typeof AUTH_STATES;
|
||||
@@ -17,13 +17,15 @@ export class StripePageObject {
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
async fillForm(params: {
|
||||
billingName?: string;
|
||||
cardNumber?: string;
|
||||
expiry?: string;
|
||||
cvc?: string;
|
||||
billingCountry?: string;
|
||||
} = {}) {
|
||||
async fillForm(
|
||||
params: {
|
||||
billingName?: string;
|
||||
cardNumber?: string;
|
||||
expiry?: string;
|
||||
cvc?: string;
|
||||
billingCountry?: string;
|
||||
} = {},
|
||||
) {
|
||||
const billingName = this.billingName();
|
||||
const cardNumber = this.cardNumber();
|
||||
const expiry = this.expiry();
|
||||
@@ -38,7 +40,9 @@ export class StripePageObject {
|
||||
}
|
||||
|
||||
submitForm() {
|
||||
return this.getStripeCheckoutIframe().getByTestId('hosted-payment-submit-button').click();
|
||||
return this.getStripeCheckoutIframe()
|
||||
.locator('[data-testid="hosted-payment-submit-button"]')
|
||||
.click();
|
||||
}
|
||||
|
||||
cardNumber() {
|
||||
|
||||
@@ -220,10 +220,6 @@ function ActionsDropdown({
|
||||
currentTeamAccountId: string;
|
||||
currentRoleHierarchy: number;
|
||||
}) {
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||
|
||||
const isCurrentUser = member.user_id === currentUserId;
|
||||
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
|
||||
|
||||
@@ -258,54 +254,42 @@ function ActionsDropdown({
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={canUpdateRole}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
<Trans i18nKey={'teams:updateRole'} />
|
||||
</DropdownMenuItem>
|
||||
<UpdateMemberRoleDialog
|
||||
userId={member.user_id}
|
||||
userRole={member.role}
|
||||
teamAccountId={currentTeamAccountId}
|
||||
userRoleHierarchy={currentRoleHierarchy}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trans i18nKey={'teams:updateRole'} />
|
||||
</DropdownMenuItem>
|
||||
</UpdateMemberRoleDialog>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canTransferOwnership}>
|
||||
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
|
||||
<Trans i18nKey={'teams:transferOwnership'} />
|
||||
</DropdownMenuItem>
|
||||
<TransferOwnershipDialog
|
||||
targetDisplayName={member.name ?? member.email}
|
||||
accountId={member.account_id}
|
||||
userId={member.user_id}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trans i18nKey={'teams:transferOwnership'} />
|
||||
</DropdownMenuItem>
|
||||
</TransferOwnershipDialog>
|
||||
</If>
|
||||
|
||||
<If condition={canRemoveFromAccount}>
|
||||
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
|
||||
<Trans i18nKey={'teams:removeMember'} />
|
||||
</DropdownMenuItem>
|
||||
<RemoveMemberDialog
|
||||
teamAccountId={currentTeamAccountId}
|
||||
userId={member.user_id}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trans i18nKey={'teams:removeMember'} />
|
||||
</DropdownMenuItem>
|
||||
</RemoveMemberDialog>
|
||||
</If>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<If condition={isRemoving}>
|
||||
<RemoveMemberDialog
|
||||
isOpen
|
||||
setIsOpen={setIsRemoving}
|
||||
teamAccountId={currentTeamAccountId}
|
||||
userId={member.user_id}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isUpdatingRole}>
|
||||
<UpdateMemberRoleDialog
|
||||
isOpen
|
||||
setIsOpen={setIsUpdatingRole}
|
||||
userId={member.user_id}
|
||||
userRole={member.role}
|
||||
teamAccountId={currentTeamAccountId}
|
||||
userRoleHierarchy={currentRoleHierarchy}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isTransferring}>
|
||||
<TransferOwnershipDialog
|
||||
isOpen
|
||||
setIsOpen={setIsTransferring}
|
||||
targetDisplayName={member.name ?? member.email}
|
||||
accountId={member.account_id}
|
||||
userId={member.user_id}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -17,18 +18,17 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
|
||||
|
||||
export function RemoveMemberDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
teamAccountId,
|
||||
userId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
teamAccountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
@@ -40,11 +40,7 @@ export function RemoveMemberDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<RemoveMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
accountId={teamAccountId}
|
||||
userId={userId}
|
||||
/>
|
||||
<RemoveMemberForm accountId={teamAccountId} userId={userId} />
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
@@ -53,11 +49,9 @@ export function RemoveMemberDialog({
|
||||
function RemoveMemberForm({
|
||||
accountId,
|
||||
userId,
|
||||
setIsOpen,
|
||||
}: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
@@ -66,8 +60,6 @@ function RemoveMemberForm({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await removeMemberFromAccountAction({ accountId, userId });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Form } from '@kit/ui/form';
|
||||
@@ -26,20 +27,20 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner
|
||||
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
|
||||
|
||||
export function TransferOwnershipDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
children,
|
||||
targetDisplayName,
|
||||
accountId,
|
||||
userId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
targetDisplayName: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
@@ -55,7 +56,6 @@ export function TransferOwnershipDialog({
|
||||
accountId={accountId}
|
||||
userId={userId}
|
||||
targetDisplayName={targetDisplayName}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -66,12 +66,10 @@ function TransferOrganizationOwnershipForm({
|
||||
accountId,
|
||||
userId,
|
||||
targetDisplayName,
|
||||
setIsOpen,
|
||||
}: {
|
||||
userId: string;
|
||||
accountId: string;
|
||||
targetDisplayName: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
@@ -121,8 +119,6 @@ function TransferOrganizationOwnershipForm({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await transferOwnershipAction(data);
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
@@ -33,22 +34,20 @@ import { RolesDataProvider } from './roles-data-provider';
|
||||
type Role = string;
|
||||
|
||||
export function UpdateMemberRoleDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
children,
|
||||
userId,
|
||||
teamAccountId,
|
||||
userRole,
|
||||
userRoleHierarchy,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
teamAccountId: string;
|
||||
userRole: Role;
|
||||
userRoleHierarchy: number;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -63,7 +62,6 @@ export function UpdateMemberRoleDialog({
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
{(data) => (
|
||||
<UpdateMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
userId={userId}
|
||||
teamAccountId={teamAccountId}
|
||||
userRole={userRole}
|
||||
@@ -80,13 +78,11 @@ function UpdateMemberForm({
|
||||
userId,
|
||||
userRole,
|
||||
teamAccountId,
|
||||
setIsOpen,
|
||||
roles,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
userRole: Role;
|
||||
teamAccountId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
roles: Role[];
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -101,8 +97,6 @@ function UpdateMemberForm({
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -126,6 +126,9 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: ^1.55.0
|
||||
version: 1.55.0
|
||||
'@supabase/supabase-js':
|
||||
specifier: 2.57.4
|
||||
version: 2.57.4
|
||||
'@types/node':
|
||||
specifier: ^24.5.2
|
||||
version: 24.5.2
|
||||
|
||||
Reference in New Issue
Block a user