Lifts loginAsUserToken + pageWithUserToken out of members.spec.ts into fixtures/role-page.ts (third file that needs them). Adds shadowing coverage: viewer member sees no New-app / Add-domain / Settings / Save / +Add-route, editor sees Save but no Delete header, and CodeMirror renders contenteditable=false for viewers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
5.6 KiB
TypeScript
169 lines
5.6 KiB
TypeScript
import { expect } from '@playwright/test';
|
|
import { test } from '../fixtures/ids';
|
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
|
import { adminApi } from '../fixtures/api';
|
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
|
|
|
// Phase B5 — App Members. Setup creates one or two extra admin
|
|
// users via the API; tests drive the Members tab through the
|
|
// dashboard like a real app admin would.
|
|
|
|
const cleanup = new CleanupRegistry();
|
|
test.afterEach(async () => {
|
|
await cleanup.run();
|
|
});
|
|
|
|
async function createApp(slug: string): Promise<string> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
|
expect(res.ok()).toBe(true);
|
|
return ((await res.json()) as { id: string }).id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
async function createMemberUser(username: string): Promise<string> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/admins', {
|
|
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
return ((await res.json()) as { id: string }).id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
test.describe('B5 app members', () => {
|
|
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('inv');
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
|
|
|
// Invite. Both selects sit in `form.create-form`; locate them
|
|
// by position to avoid getByLabel ambiguity (the Svelte
|
|
// markup nests both labels in a flex row, which makes their
|
|
// accessible names overlap).
|
|
const form = page.locator('form.create-form');
|
|
await form.locator('select').nth(0).selectOption({ label: username });
|
|
await form.locator('select').nth(1).selectOption('editor');
|
|
await page.getByRole('button', { name: /^Add member$/ }).click();
|
|
await expect(page.locator('.member-row')).toContainText(username);
|
|
|
|
// Remove via action menu + confirm modal.
|
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
|
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
|
|
await expect(page.locator('.member-row')).toHaveCount(0);
|
|
});
|
|
|
|
test('role change via action menu updates the role chip', async ({
|
|
page,
|
|
uniqueSlug,
|
|
uniqueUsername
|
|
}) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('role');
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
// Seed the membership via API to skip the invite UI.
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
|
data: { user_id: userId, role: 'viewer' }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
|
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
|
|
|
|
const row = page.locator('.member-row', { hasText: username });
|
|
await expect(row).toContainText(/editor/i);
|
|
});
|
|
|
|
test('non-app-admin viewers do not see the Members tab', async ({
|
|
browser,
|
|
uniqueSlug,
|
|
uniqueUsername
|
|
}) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('viewer');
|
|
const password = 'e2e-member-pw';
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
// Grant viewer membership (not app_admin) so the user can see
|
|
// the app at all.
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
|
data: { user_id: userId, role: 'viewer' }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
|
|
const token = await loginAsUserToken(username, password);
|
|
const viewerPage = await pageWithUserToken(browser, token);
|
|
try {
|
|
await viewerPage.goto(`/admin/apps/${slug}`);
|
|
// Scripts tab loads — that's what a viewer sees.
|
|
await expect(
|
|
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
|
).toBeVisible();
|
|
// Members tab button is absent for non-app-admins.
|
|
await expect(
|
|
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await viewerPage.context().close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('B5 app members adversarial', () => {
|
|
test('role dropdown exposes only the documented values', async ({
|
|
page,
|
|
uniqueSlug,
|
|
uniqueUsername
|
|
}) => {
|
|
const slug = uniqueSlug('mem');
|
|
const username = uniqueUsername('rolelist');
|
|
await createApp(slug);
|
|
const userId = await createMemberUser(username);
|
|
cleanup.app(slug);
|
|
cleanup.adminUser(userId);
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
|
const form = page.locator('form.create-form');
|
|
const roleSelect = form.locator('select').nth(1);
|
|
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
|
|
Array.from(el.options).map((o) => o.value)
|
|
);
|
|
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
|
|
});
|
|
});
|