test: playkit v0.3.1 adoption, Zod widening, PROD smoke, NextAuth write gates #68
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const isChecked = await locator.isChecked();
|
||||
if (isChecked !== checked) {
|
||||
await this.click(locator);
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(active: boolean): Promise<void> {
|
||||
await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active);
|
||||
}
|
||||
|
||||
async setWriteAccess(enabled: boolean): Promise<void> {
|
||||
await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled);
|
||||
}
|
||||
|
||||
async saveEdit(): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
return (await this.row(email).locator('td').nth(2).innerText()).trim();
|
||||
}
|
||||
|
||||
async roleBadgeText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(3).innerText()).trim();
|
||||
}
|
||||
|
||||
async writeAccessText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(4).innerText()).trim();
|
||||
}
|
||||
|
||||
async waitForRowGone(email: string, timeout = 15_000): Promise<void> {
|
||||
await waitForHidden(this.row(email), { timeout });
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user