test(dashboard): add e2e instance users spec (B6)
Eight tests covering the Users admin page: invite happy path (form → reveal modal → ack-gated dismiss → row in table), live username validation, search filter, deactivate/reactivate, delete with phrase modal, member-role redirect to /profile?denied=users, plus adversarial inputs (too-short username, script-tag email). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
209
dashboard/tests/e2e/users/users.spec.ts
Normal file
209
dashboard/tests/e2e/users/users.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { expect, type Browser, type Page, request } from '@playwright/test';
|
||||
import { test } from '../fixtures/ids';
|
||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||
import { adminApi } from '../fixtures/api';
|
||||
|
||||
// Phase B6 — Instance Users (/admin/users). Covers the bootstrap
|
||||
// admin's view of the user directory: invite, edit, deactivate,
|
||||
// search, delete, plus the member-role redirect and adversarial
|
||||
// inputs to the invite form.
|
||||
|
||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
async function createMember(username: string, password = 'e2e-member-pw'): Promise<string> {
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/admins', {
|
||||
data: { username, password, instance_role: 'member' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginToken(username: string, password: string): Promise<string> {
|
||||
const ctx = await request.newContext({ baseURL: API_BASE });
|
||||
try {
|
||||
const res = await ctx.post('/api/v1/admin/auth/login', {
|
||||
data: { username, password },
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function pageWithToken(browser: Browser, token: string): Promise<Page> {
|
||||
const ctx = await browser.newContext({ storageState: undefined });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/admin/login');
|
||||
await page.evaluate(
|
||||
([key, value]) => {
|
||||
localStorage.setItem(key, value);
|
||||
},
|
||||
['picloud.admin.token', token]
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
test.describe('B6 instance users', () => {
|
||||
test('invite happy path: form → reveal modal → user in list', async ({
|
||||
page,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const username = uniqueUsername('inv');
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||
const modal = page.locator('form.modal');
|
||||
await modal.getByLabel('Username').fill(username);
|
||||
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||
await modal.getByRole('button', { name: /^Create user$/ }).click();
|
||||
|
||||
// Reveal modal shows the one-time password.
|
||||
const reveal = page.locator('.reveal-modal');
|
||||
await expect(reveal).toBeVisible();
|
||||
await expect(reveal).toContainText(/User created — /);
|
||||
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
||||
await reveal.getByRole('checkbox', { name: /shared this/i }).check();
|
||||
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||
|
||||
// Now in the table.
|
||||
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible();
|
||||
|
||||
// API cleanup — we don't have the user id from the UI alone.
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const list = await api.get('/api/v1/admin/admins');
|
||||
const all = (await list.json()) as Array<{ id: string; username: string }>;
|
||||
const u = all.find((x) => x.username === username);
|
||||
if (u) cleanup.adminUser(u.id);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('username live validation: bad chars → submit disabled', async ({ page }) => {
|
||||
await page.goto('/admin/users');
|
||||
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||
const modal = page.locator('form.modal');
|
||||
await modal.getByLabel('Username').fill('UPPER_CASE_invalid');
|
||||
await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i);
|
||||
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('search filters the table by username', async ({ page, uniqueUsername }) => {
|
||||
const target = uniqueUsername('hit');
|
||||
const decoy = uniqueUsername('miss');
|
||||
const ids = await Promise.all([createMember(target), createMember(decoy)]);
|
||||
ids.forEach((id) => cleanup.adminUser(id));
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.getByPlaceholder(/Search by username/).fill(target);
|
||||
await expect(page.locator('.row', { hasText: target })).toBeVisible();
|
||||
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('deactivate then reactivate toggles the inactive indicator', async ({
|
||||
page,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const username = uniqueUsername('toggle');
|
||||
const userId = await createMember(username);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.getByPlaceholder(/Search by username/).fill(username);
|
||||
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||
await expect(row).toContainText(/inactive/i);
|
||||
|
||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
||||
await expect(row).not.toContainText(/inactive/i);
|
||||
});
|
||||
|
||||
test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({
|
||||
page,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const username = uniqueUsername('del');
|
||||
const userId = await createMember(username);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.getByPlaceholder(/Search by username/).fill(username);
|
||||
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||
await page.getByRole('menuitem', { name: /^Delete$/ }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const confirm = dialog.getByRole('button', { name: /^Delete user$/ });
|
||||
await expect(confirm).toBeDisabled();
|
||||
await dialog.getByRole('textbox').fill('not-the-username');
|
||||
await expect(confirm).toBeDisabled();
|
||||
await dialog.getByRole('textbox').fill(username);
|
||||
await expect(confirm).toBeEnabled();
|
||||
await confirm.click();
|
||||
|
||||
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({
|
||||
browser,
|
||||
uniqueUsername
|
||||
}) => {
|
||||
const username = uniqueUsername('memvw');
|
||||
const password = 'e2e-member-pw';
|
||||
const userId = await createMember(username, password);
|
||||
cleanup.adminUser(userId);
|
||||
|
||||
const token = await loginToken(username, password);
|
||||
const memberPage = await pageWithToken(browser, token);
|
||||
try {
|
||||
await memberPage.goto('/admin/users');
|
||||
await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/);
|
||||
await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
||||
} finally {
|
||||
await memberPage.context().close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('B6 instance users adversarial', () => {
|
||||
test('username too short: live invalid + submit disabled', async ({ page }) => {
|
||||
await page.goto('/admin/users');
|
||||
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||
const modal = page.locator('form.modal');
|
||||
await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2
|
||||
await expect(modal.locator('small.invalid')).toBeVisible();
|
||||
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('email with script tag fails validation, never executes', async ({ page }) => {
|
||||
page.on('dialog', async (d) => {
|
||||
await d.dismiss();
|
||||
throw new Error(`Unexpected dialog: ${d.message()}`);
|
||||
});
|
||||
|
||||
await page.goto('/admin/users');
|
||||
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||
const modal = page.locator('form.modal');
|
||||
await modal.getByLabel(/Email/).fill('<script>alert(1)</script>@x');
|
||||
await expect(modal.locator('small.invalid')).toContainText(/email/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user