Cancels once to assert the modal can be dismissed without side effects, then confirms to flip the user to inactive, then reactivates to assert that direction remains one-click. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
8.6 KiB
TypeScript
225 lines
8.6 KiB
TypeScript
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 confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', 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();
|
|
|
|
// Deactivate opens the confirm modal.
|
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog).toContainText(username);
|
|
|
|
// Cancel leaves the user active.
|
|
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
|
|
await expect(dialog).toHaveCount(0);
|
|
await expect(row).not.toContainText(/inactive/i);
|
|
|
|
// Open again and confirm — user becomes inactive.
|
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
|
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
|
|
await expect(row).toContainText(/inactive/i);
|
|
|
|
// Reactivate is still one-click (non-destructive — no modal).
|
|
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);
|
|
});
|
|
});
|