diff --git a/.env.ci b/.env.ci index 394f7dc7..511ee0db 100644 --- a/.env.ci +++ b/.env.ci @@ -6,6 +6,7 @@ APP_DEBUG=true APP_URL=http://localhost APP_FORCE_HTTPS=false APP_ENABLE_REGISTRATION=true +SUPER_ADMINS=admin@example.com # Logging LOG_CHANNEL=stack diff --git a/e2e/admin-panel.spec.ts b/e2e/admin-panel.spec.ts new file mode 100644 index 00000000..83106fdf --- /dev/null +++ b/e2e/admin-panel.spec.ts @@ -0,0 +1,75 @@ +import { adminTest as test, expect } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; + +/** + * Index pages of all Filament resources that ship with the core application. + * Resources provided by extensions are intentionally left out, because + * extensions are not installed in CI. + */ +const resourcePages = [ + { path: '/admin/time-entries', heading: 'Time Entries' }, + { path: '/admin/projects', heading: 'Projects' }, + { path: '/admin/tasks', heading: 'Tasks' }, + { path: '/admin/clients', heading: 'Clients' }, + { path: '/admin/tags', heading: 'Tags' }, + { path: '/admin/reports', heading: 'Reports' }, + { path: '/admin/users', heading: 'Users' }, + { path: '/admin/organizations', heading: 'Organizations' }, + { path: '/admin/organization-invitations', heading: 'Invitations' }, + { path: '/admin/project-members', heading: 'Project Members' }, + { path: '/admin/tokens', heading: 'Tokens' }, + { path: '/admin/failed-jobs', heading: 'Failed Jobs' }, + { path: '/admin/audits', heading: 'Audits' }, +]; + +test.describe('Admin Panel Access', () => { + test.use({ storageState: undefined }); + + test('test that the admin panel redirects guests to the login page', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/admin'); + await expect(page).toHaveURL(PLAYWRIGHT_BASE_URL + '/login'); + }); + + test('test that the admin panel is forbidden for users that are no super admin', async ({ + page, + }) => { + const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`; + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill('John Doe'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD); + await page.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('I agree to the Terms of').click(); + await page.getByRole('button', { name: 'Register' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); + + const response = await page.goto(PLAYWRIGHT_BASE_URL + '/admin'); + expect(response?.status()).toBe(403); + }); +}); + +test.describe('Admin Panel', () => { + test('test that the admin dashboard loads with all widgets', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/admin'); + + await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible(); + + // The widgets are loaded lazily via Livewire, so they only show up once + // their content has been rendered. + await expect(page.getByText('Total real users')).toBeVisible(); + await expect(page.getByText('Placeholder users')).toBeVisible(); + await expect(page.getByText('Active users in the last seven days')).toBeVisible(); + await expect(page.getByText('User Registrations', { exact: true })).toBeVisible(); + await expect(page.getByText('Time Entries Created', { exact: true })).toBeVisible(); + await expect(page.getByText('Time Entries Imported', { exact: true })).toBeVisible(); + }); + + for (const { path, heading } of resourcePages) { + test(`test that the admin page ${path} loads`, async ({ page }) => { + const response = await page.goto(PLAYWRIGHT_BASE_URL + path); + + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { name: heading, level: 1 })).toBeVisible(); + }); + } +}); diff --git a/e2e/utils/admin.ts b/e2e/utils/admin.ts new file mode 100644 index 00000000..f160abdc --- /dev/null +++ b/e2e/utils/admin.ts @@ -0,0 +1,24 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../../playwright/config'; + +/** + * The seeded user that is allowed to access the Filament admin panel. + * + * Panel access is granted by email address via the `SUPER_ADMINS` config, so + * the admin panel tests cannot use the throwaway users created by the default + * fixture and rely on the seeded user instead. + */ +export const SUPER_ADMIN_EMAIL = 'admin@example.com'; +export const SEEDED_USER_PASSWORD = 'password'; + +/** + * Log in as the seeded super admin user via the regular application login. + */ +export async function loginAsSuperAdmin(page: Page): Promise { + await page.goto(PLAYWRIGHT_BASE_URL + '/login'); + await page.getByLabel('Email').fill(SUPER_ADMIN_EMAIL); + await page.getByLabel('Password').fill(SEEDED_USER_PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 }); +} diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts index 41695593..12882b6b 100644 --- a/playwright/fixtures.ts +++ b/playwright/fixtures.ts @@ -1,8 +1,11 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { test as baseTest } from '@playwright/test'; import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config'; import { type TestContext, setupTestContext } from '../e2e/utils/api'; import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members'; +import { loginAsSuperAdmin } from '../e2e/utils/admin'; export * from '@playwright/test'; export type { TestContext }; @@ -116,3 +119,33 @@ export const test = baseTest.extend< await closeAdmin(); }, }); + +/** + * Authentication fixture for the Filament admin panel. + * + * The admin panel can only be accessed by the seeded super admin user, so the + * throwaway users of the default fixture cannot be used. Logging in is rate + * limited (5 attempts per minute and email address), therefore the session is + * created once per worker and reused by all tests of that worker. + */ +export const adminTest = baseTest.extend({ + adminStorageState: [ + async ({ browser }, use, workerInfo) => { + const fileName = path.resolve( + workerInfo.project.outputDir, + `.auth/admin-${workerInfo.workerIndex}.json` + ); + + if (!fs.existsSync(fileName)) { + const context = await browser.newContext({ storageState: undefined }); + await loginAsSuperAdmin(await context.newPage()); + await context.storageState({ path: fileName }); + await context.close(); + } + + await use(fileName); + }, + { scope: 'worker' }, + ], + storageState: ({ adminStorageState }, use) => use(adminStorageState), +});