Files
punimtag/e2e/tests/admin.manage-users-actions.spec.ts
T
ilia c0c997f796
CI / skip-ci-check (pull_request) Successful in 5s
CI / docker-ci (pull_request) Successful in 7s
CI / secret-scan (pull_request) Successful in 12s
CI / viewer-unit (pull_request) Successful in 1m29s
CI / e2e (pull_request) Successful in 4m8s
CI / admin-unit (pull_request) Successful in 4m24s
test: timing budgets + CI hardening (artifact v3 pin, npm cache retry)
Closes three items from the outstanding e2e/CI gap list:

- Timing budgets: timings.measure() only ever recorded durations for the
  (still-unwired) Pushgateway export — nothing failed CI when a step got
  slow. Add e2e/timing-budgets.ts (expectWithinBudget + shared BUDGET_MS
  buckets) and wire it into every measure() call site across the suite.
  Mail-wait steps are deliberately left unbudgeted (external mail-trap
  delivery latency, not a code performance signal).

- actions/upload-artifact@v4 doesn't work against this Gitea/act runner's
  artifact backend — pin to v3 for the e2e failure-report upload.

- Shared act_runner npm cache has corrupted platform-native tarballs before
  (@next/swc-linux-x64-musl) and reds viewer-unit/admin-unit/e2e with no
  product bug involved. All three npm ci steps now retry once after
  `npm cache clean --force` on first failure.

Verified: full local suite green against DEV (37 passed, 6 skipped, no
budget assertion failures) before wiring into CI.
2026-07-15 08:56:31 -04:00

174 lines
7.7 KiB
TypeScript

import type { Page } from '@playwright/test';
import { test, expect } from '../fixtures';
import { LoginPage } from '../pages/LoginPage';
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
/**
* 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' }),
);
// Generous bucket — shared-CI DEV LXC under concurrent load (see file header).
expectWithinBudget(timings, 'create_user', BUDGET_MS.heavyCrud);
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();
});
expectWithinBudget(timings, 'edit_user', BUDGET_MS.heavyCrud);
expect(await manageUsersPanel.writeAccessText(email)).toMatch(/yes/i);
await timings.measure('delete_user', () => manageUsersPanel.deleteUser(email));
expectWithinBudget(timings, 'delete_user', BUDGET_MS.heavyCrud);
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)');
// Two full UI logins + create/edit/verify against a shared-CI DEV LXC push
// this past the default 60s under load (observed: a single login step
// alone took ~90s during a concurrent CI burst) — give it more headroom.
test.setTimeout(120_000);
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' }),
);
expectWithinBudget(timings, 'create_throwaway', BUDGET_MS.heavyCrud);
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 });
});
// Login flows on this file have hit ~90s under a concurrent CI burst
// (see comment above) — budget matches the file's own 120s headroom.
expectWithinBudget(timings, 'throwaway_login', BUDGET_MS.heavyCrud);
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();
});
expectWithinBudget(timings, 'deactivate_user', BUDGET_MS.heavyCrud);
// The table defaults to (and refetches with) the "Active only" filter,
// so the just-deactivated row drops out of view immediately — switch to
// "All" before reading its badge, or this hangs forever waiting for a
// row that the current filter will never show again.
await manageUsersPanel.showAllUsers();
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();
});
expectWithinBudget(timings, 'session_revoked', BUDGET_MS.uiAction);
} finally {
await userContext?.close();
await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined);
}
});
});