diff --git a/e2e/pages/ManageUsersPanel.ts b/e2e/pages/ManageUsersPanel.ts new file mode 100644 index 0000000..40eafb0 --- /dev/null +++ b/e2e/pages/ManageUsersPanel.ts @@ -0,0 +1,109 @@ +import { type Page, type Locator } from '@playwright/test'; +import { BasePage, waitForVisible, waitForHidden } from '@levkin/playkit'; + +export interface NewUserInput { + email: string; + password: string; + name: string; + isAdmin?: boolean; + hasWriteAccess?: boolean; +} + +/** + * Page object for the "Manage Users" overlay content + * (`viewer-frontend/app/admin/users/ManageUsersContent.tsx`). + * + * Talks to the NextAuth-backed `/api/users` store — a separate account + * database from the FastAPI `/api/v1/users` store covered by + * `api.role-permissions.spec.ts`. + */ +export class ManageUsersPanel extends BasePage { + constructor(page: Page, baseUrl: string) { + super(page, baseUrl); + } + + row(email: string): Locator { + return this.page.locator('tbody tr').filter({ hasText: email }); + } + + /** Scopes to the currently-open dialog matching a heading, to avoid + * ambiguous matches against hidden/portalled sibling dialogs. */ + private dialog(headingText: string): Locator { + return this.page + .getByRole('dialog') + .filter({ has: this.page.getByRole('heading', { name: headingText, exact: true }) }); + } + + async createUser(input: NewUserInput): Promise { + await this.click(this.page.getByRole('button', { name: 'Add User', exact: true })); + const dialog = this.dialog('Add New User'); + await waitForVisible(dialog); + await this.fill(dialog.locator('#add-email'), input.email); + await this.fill(dialog.locator('#add-password'), input.password); + await this.fill(dialog.locator('#add-name'), input.name); + if (input.isAdmin) { + await this.click(dialog.locator('#add-role')); + await this.click(this.page.getByRole('option', { name: 'Admin', exact: true })); + } + if (input.hasWriteAccess) { + await this.click(dialog.locator('#add-write-access')); + } + await this.click(dialog.getByRole('button', { name: 'Create User', exact: true })); + await waitForVisible(this.row(input.email), { timeout: 15_000 }); + } + + async openEditDialog(email: string): Promise { + await this.click(this.row(email).getByRole('button', { name: `Edit ${email}`, exact: true })); + await waitForVisible(this.dialog('Edit User')); + } + + private async setCheckbox(locator: Locator, checked: boolean): Promise { + const isChecked = await locator.isChecked(); + if (isChecked !== checked) { + await this.click(locator); + } + } + + async setActive(active: boolean): Promise { + await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active); + } + + async setWriteAccess(enabled: boolean): Promise { + await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled); + } + + async saveEdit(): Promise { + const dialog = this.dialog('Edit User'); + await this.click(dialog.getByRole('button', { name: 'Save Changes', exact: true })); + await waitForHidden(dialog); + } + + /** Clicks the row's delete/deactivate icon and confirms in the dialog. + * Hard-deletes if the account has no related records, otherwise the + * backend soft-deactivates it — either way `isActive` ends up false. */ + async deleteUser(email: string): Promise { + await this.click( + this.row(email).getByRole('button', { name: `Deactivate ${email}`, exact: true }), + ); + const dialog = this.dialog('Delete User'); + await waitForVisible(dialog); + await this.click(dialog.getByRole('button', { name: 'Delete', exact: true })); + await waitForHidden(dialog); + } + + async statusBadgeText(email: string): Promise { + return (await this.row(email).locator('td').nth(2).innerText()).trim(); + } + + async roleBadgeText(email: string): Promise { + return (await this.row(email).locator('td').nth(3).innerText()).trim(); + } + + async writeAccessText(email: string): Promise { + return (await this.row(email).locator('td').nth(4).innerText()).trim(); + } + + async waitForRowGone(email: string, timeout = 15_000): Promise { + await waitForHidden(this.row(email), { timeout }); + } +} diff --git a/e2e/tests/admin.manage-users-actions.spec.ts b/e2e/tests/admin.manage-users-actions.spec.ts new file mode 100644 index 0000000..55fb5b2 --- /dev/null +++ b/e2e/tests/admin.manage-users-actions.spec.ts @@ -0,0 +1,153 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../fixtures'; +import { LoginPage } from '../pages/LoginPage'; + +/** + * Real Manage Users CRUD through the admin UI (`admin.manage-users.spec.ts` + * only opens/closes the panel — it never exercises create/edit/delete), plus + * a causal cross-session check: an admin deactivating a user immediately + * signs that user out of their *already open* session, because the NextAuth + * `jwt` callback re-checks `isActive` from the DB on every request (see + * `viewer-frontend/app/api/auth/[...nextauth]/route.ts`, issue #57 fix). + * + * punimtagdev shares its Postgres DB with MirrorMatch and reuses the same + * pre-seeded accounts as other specs (E2E_ADMIN_EMAIL, E2E_VIEWER_EMAIL), so + * every test here creates its own disposable `e2e-manage-test-*` account and + * deletes it in a `finally`, regardless of outcome. + */ +function throwawayEmail(tag: string): string { + return `e2e-manage-test-${tag}-${Date.now()}@e2e.invalid`; +} + +const THROWAWAY_PASSWORD = 'E2eThrowaway!123'; + +interface AdminUser { + id: number; + email: string; +} + +/** Best-effort cleanup — looks the account up by email since we may not + * have its id if the test failed before/during creation. */ +async function deleteUserByEmail(page: Page, baseUrl: string, email: string): Promise { + const res = await page.request.get(`${baseUrl}/api/users?status=all`); + if (!res.ok()) return; + const body = (await res.json()) as { users?: AdminUser[] }; + const match = body.users?.find((u) => u.email === email); + if (match) { + await page.request.delete(`${baseUrl}/api/users/${match.id}`); + } +} + +test.describe('admin manage users: create/edit/delete @smoke', () => { + test('admin can create, edit, and delete a user through the panel', async ({ + page, + playkitConfig, + loginPage, + accountMenu, + manageUsersPanel, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)'); + const email = throwawayEmail('crud'); + + try { + await loginPage.openLogin(); + await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password); + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + + await accountMenu.openManageUsers(); + + await timings.measure('create_user', () => + manageUsersPanel.createUser({ email, password: THROWAWAY_PASSWORD, name: 'E2E Throwaway' }), + ); + expect(await manageUsersPanel.statusBadgeText(email)).toMatch(/active/i); + expect(await manageUsersPanel.roleBadgeText(email)).toMatch(/user/i); + expect(await manageUsersPanel.writeAccessText(email)).toMatch(/no/i); + + await timings.measure('edit_user', async () => { + await manageUsersPanel.openEditDialog(email); + await manageUsersPanel.setWriteAccess(true); + await manageUsersPanel.saveEdit(); + }); + expect(await manageUsersPanel.writeAccessText(email)).toMatch(/yes/i); + + await timings.measure('delete_user', () => manageUsersPanel.deleteUser(email)); + await manageUsersPanel.waitForRowGone(email); + + await accountMenu.closeManageUsers(); + } finally { + await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined); + } + }); +}); + +test.describe('admin manage users: causal cross-session effect @smoke', () => { + test('deactivating a user via admin UI immediately revokes their open session', async ({ + browser, + page, + playkitConfig, + loginPage, + accountMenu, + manageUsersPanel, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)'); + const email = throwawayEmail('deactivate'); + let userContext: import('@playwright/test').BrowserContext | undefined; + + try { + await loginPage.openLogin(); + await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password); + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + + await accountMenu.openManageUsers(); + await timings.measure('create_throwaway', () => + manageUsersPanel.createUser({ email, password: THROWAWAY_PASSWORD, name: 'E2E Throwaway' }), + ); + await accountMenu.closeManageUsers(); + + // Independent second session/actor — the throwaway user, logged in + // for real, concurrently with the admin. + userContext = await browser.newContext(); + const userPage = await userContext.newPage(); + const userLogin = new LoginPage(userPage, playkitConfig.baseUrl); + + await timings.measure('throwaway_login', async () => { + await userLogin.openLogin(); + await userLogin.signIn(email, THROWAWAY_PASSWORD); + await userPage.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await userPage.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + }); + + const preRes = await userPage.request.get(`${playkitConfig.baseUrl}/api/auth/session`); + const preBody = await preRes.json(); + expect(preBody?.user?.email).toBe(email); + + // Admin deactivates the throwaway user. The throwaway user's session + // is never touched directly — no sign-out, no reload triggered by us. + await accountMenu.openManageUsers(); + await timings.measure('deactivate_user', async () => { + await manageUsersPanel.openEditDialog(email); + await manageUsersPanel.setActive(false); + await manageUsersPanel.saveEdit(); + }); + expect(await manageUsersPanel.statusBadgeText(email)).toMatch(/inactive/i); + await accountMenu.closeManageUsers(); + + // The already-open session must now read as signed-out: the jwt + // callback re-checks `isActive` against the DB on every request. + await timings.measure('session_revoked', async () => { + const postRes = await userPage.request.get(`${playkitConfig.baseUrl}/api/auth/session`); + const postBody = await postRes.json(); + expect(postBody?.user).toBeFalsy(); + }); + } finally { + await userContext?.close(); + await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined); + } + }); +});