Enforce RLS when user opted in to MFA. (#188)
* Allow Super Admin to view tables using RLS * Replace previous usages of the Admin client using the authed client using the new RLS * Enforce MFA for Super Admin users * Enforce RLS when user opted in to MFA. * Add Super Admin Access Policies and Update Database Types * Consolidate super admin logic into a single function that uses the RPC is_super_admin * Added Super Admin E2E tests * Fixes and improvements * Bump version to 2.5.0
This commit is contained in:
committed by
GitHub
parent
9cf7bf0aac
commit
131b1061e6
@@ -14,6 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"node-html-parser": "^7.0.1"
|
||||
"node-html-parser": "^7.0.1",
|
||||
"totp-generator": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +54,12 @@ export class AccountPageObject {
|
||||
'[data-test="account-password-form-password-input"]',
|
||||
password,
|
||||
);
|
||||
|
||||
await this.page.fill(
|
||||
'[data-test="account-password-form-repeat-password-input"]',
|
||||
password,
|
||||
);
|
||||
|
||||
await this.page.click('[data-test="account-password-form"] button');
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ test.describe('Account Settings', () => {
|
||||
|
||||
await Promise.all([request, response]);
|
||||
|
||||
await account.auth.signOut();
|
||||
await page.context().clearCookies();
|
||||
|
||||
await page.reload();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
362
apps/e2e/tests/admin/admin.spec.ts
Normal file
362
apps/e2e/tests/admin/admin.spec.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { Page, expect, selectors, test } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
|
||||
|
||||
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
||||
|
||||
test.describe('Admin Auth flow without MFA', () => {
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin', () => {
|
||||
// must be serial because OTP verification is not working in parallel
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Admin Dashboard', () => {
|
||||
test('displays all stat cards', async ({ page }) => {
|
||||
await goToAdmin(page);
|
||||
|
||||
// Check all stat cards are present
|
||||
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Team Accounts' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Paying Customers' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Trials' })).toBeVisible();
|
||||
|
||||
// Verify stat values are numbers
|
||||
const stats = await page.$$('.text-3xl.font-bold');
|
||||
|
||||
for (const stat of stats) {
|
||||
const value = await stat.textContent();
|
||||
expect(Number.isInteger(Number(value))).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Personal Account Management', () => {
|
||||
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`);
|
||||
|
||||
const filterText = testUserEmail.split('@')[0]!;
|
||||
|
||||
await filterAccounts(page, filterText);
|
||||
await selectAccount(page, filterText);
|
||||
});
|
||||
|
||||
test('displays personal account details', async ({ page }) => {
|
||||
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();
|
||||
});
|
||||
|
||||
test('ban user flow', async ({ page }) => {
|
||||
await page.getByTestId('admin-ban-account-button').click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Ban User' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Try with invalid confirmation
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
|
||||
await page.getByRole('button', { name: 'Ban User' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Ban User' }),
|
||||
).toBeVisible(); // Dialog should still be open
|
||||
|
||||
// Confirm with correct text
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Ban User' }).click();
|
||||
await expect(page.getByText('Banned')).toBeVisible();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Verify user can't log in
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.signIn({
|
||||
email: testUserEmail,
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
// Should show an error message
|
||||
await expect(
|
||||
page.locator('[data-test="auth-error-message"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('reactivate user flow', async ({ page }) => {
|
||||
// First ban the user
|
||||
await page.getByTestId('admin-ban-account-button').click();
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Ban User' }).click();
|
||||
await expect(page.getByText('Banned')).toBeVisible();
|
||||
|
||||
// Now reactivate
|
||||
await page.getByTestId('admin-reactivate-account-button').click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Reactivate User' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||
await page.getByRole('button', { name: 'Reactivate User' }).click();
|
||||
|
||||
// Verify ban badge is removed
|
||||
await expect(page.getByText('Banned')).not.toBeVisible();
|
||||
|
||||
// Log out
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Verify user can log in again
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.signIn({
|
||||
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 }) => {
|
||||
await page.getByTestId('admin-delete-account-button').click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete 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 User' }),
|
||||
).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
|
||||
await expect(page).toHaveURL('/admin/accounts');
|
||||
|
||||
// Log out
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Verify user can't log in
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.signIn({
|
||||
email: testUserEmail,
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
// Should show an error message
|
||||
await expect(
|
||||
page.locator('[data-test="auth-error-message"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Team Account Management', () => {
|
||||
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 page.goto(`/admin/accounts`);
|
||||
|
||||
await filterAccounts(page, teamName);
|
||||
await selectAccount(page, teamName);
|
||||
});
|
||||
|
||||
test('displays team account details', async ({ page }) => {
|
||||
await expect(page.getByText('Team Account')).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('admin-delete-account-button'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('delete team account flow', async ({ page }) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function goToAdmin(page: Page) {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await auth.signIn({
|
||||
email: 'super-admin@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/auth/verify');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
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.goto('/admin');
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
page: Page,
|
||||
params: {
|
||||
afterSignIn?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.goto('/auth/sign-up');
|
||||
|
||||
const email = `${(Math.random() * 1000000).toFixed(0)}@makerkit.dev`;
|
||||
|
||||
await auth.signUp({
|
||||
email,
|
||||
password: 'testingpassword',
|
||||
repeatPassword: 'testingpassword',
|
||||
});
|
||||
|
||||
await auth.visitConfirmEmailLink(email);
|
||||
|
||||
await page.goto('/home');
|
||||
|
||||
if (params.afterSignIn) {
|
||||
await params.afterSignIn();
|
||||
}
|
||||
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
async function filterAccounts(page: Page, email: string) {
|
||||
await page
|
||||
.locator('[data-test="admin-accounts-table-filter-input"]')
|
||||
.fill(email);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function selectAccount(page: Page, email: string) {
|
||||
await page.getByRole('link', { name: email.split('@')[0] }).click();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { TOTP } from 'totp-generator';
|
||||
|
||||
import { Mailbox } from '../utils/mailbox';
|
||||
|
||||
@@ -46,6 +47,21 @@ export class AuthPageObject {
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async submitMFAVerification(key: string) {
|
||||
const period = 30;
|
||||
|
||||
const { otp } = TOTP.generate(key, {
|
||||
period,
|
||||
});
|
||||
|
||||
console.log(`OTP ${otp} code`, {
|
||||
period,
|
||||
});
|
||||
|
||||
await this.page.fill('[data-input-otp]', otp);
|
||||
await this.page.click('[data-test="submit-mfa-button"]');
|
||||
}
|
||||
|
||||
async visitConfirmEmailLink(
|
||||
email: string,
|
||||
params: {
|
||||
|
||||
@@ -71,6 +71,23 @@ test.describe('Auth flow', () => {
|
||||
});
|
||||
|
||||
test.describe('Protected routes', () => {
|
||||
test('when logged out, redirects to the correct page after sign in', async ({
|
||||
page,
|
||||
}) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.goto('/home/settings');
|
||||
|
||||
await auth.signIn({
|
||||
email: 'test@makerkit.dev',
|
||||
password: 'testingpassword',
|
||||
});
|
||||
|
||||
await page.waitForURL('/home/settings');
|
||||
|
||||
expect(page.url()).toContain('/home/settings');
|
||||
});
|
||||
|
||||
test('will redirect to the sign-in page if not authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -78,10 +95,4 @@ test.describe('Protected routes', () => {
|
||||
|
||||
expect(page.url()).toContain('/auth/sign-in?next=/home/settings');
|
||||
});
|
||||
|
||||
test('will return a 404 for the admin page', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
|
||||
expect(page.url()).toContain('/auth/sign-in');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,14 +54,10 @@ test.describe('Password Reset Flow', () => {
|
||||
await page.waitForURL('/home');
|
||||
}).toPass();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
await auth.signOut();
|
||||
|
||||
await page
|
||||
.locator('a', {
|
||||
hasText: 'Sign in',
|
||||
})
|
||||
.click();
|
||||
await page.waitForURL('/');
|
||||
await page.goto('/auth/sign-in');
|
||||
|
||||
await auth.signIn({
|
||||
email,
|
||||
|
||||
@@ -120,7 +120,8 @@ test.describe('Full Invitation Flow', () => {
|
||||
await expect(invitations.getInvitations()).toHaveCount(2);
|
||||
|
||||
// sign out and sign in with the first email
|
||||
await invitations.auth.signOut();
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
console.log(`Finding email to ${firstEmail} ...`);
|
||||
|
||||
|
||||
@@ -173,3 +173,46 @@ test.describe('Team Ownership Transfer', () => {
|
||||
await expect(ownerRow.locator('text=Primary Owner')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Team Account Security', () => {
|
||||
test('unauthorized user cannot access team account', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
// 1. Create a team account with User A
|
||||
const teamAccounts = new TeamAccountsPageObject(page);
|
||||
const params = teamAccounts.createTeamName();
|
||||
|
||||
// Setup User A and create team
|
||||
await teamAccounts.setup(params);
|
||||
|
||||
// Store team slug for later use
|
||||
const teamSlug = params.slug;
|
||||
|
||||
// 2. Sign out User A
|
||||
await page.context().clearCookies();
|
||||
|
||||
// 3. Create a new context for User B (to have clean cookies/session)
|
||||
const userBContext = await browser.newContext();
|
||||
const userBPage = await userBContext.newPage();
|
||||
const userBTeamAccounts = new TeamAccountsPageObject(userBPage);
|
||||
|
||||
// Sign up with User B
|
||||
await userBPage.goto('/auth/sign-up');
|
||||
const emailB = userBTeamAccounts.auth.createRandomEmail();
|
||||
|
||||
await userBTeamAccounts.auth.signUp({
|
||||
email: emailB,
|
||||
password: 'password',
|
||||
repeatPassword: 'password',
|
||||
});
|
||||
|
||||
await userBTeamAccounts.auth.visitConfirmEmailLink(emailB);
|
||||
|
||||
// 4. Attempt to access the team page with User B
|
||||
await userBPage.goto(`/home/${teamSlug}`);
|
||||
|
||||
// Check that we're not on the team page anymore (should redirect)
|
||||
await expect(userBPage).toHaveURL(`/home`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user