test(e2e): role-shadowing specs in apps + scripts suites
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>
This commit is contained in:
@@ -2,6 +2,36 @@ import { expect, type Page } from '@playwright/test';
|
|||||||
import { test } from '../fixtures/ids';
|
import { test } from '../fixtures/ids';
|
||||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
import { adminApi } from '../fixtures/api';
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor' | 'app_admin';
|
||||||
|
}): Promise<{ appId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { appId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
||||||
// historical-slug takeover flow and adversarial inputs.
|
// historical-slug takeover flow and adversarial inputs.
|
||||||
@@ -224,3 +254,82 @@ test.describe('B2 apps adversarial', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps role shadowing', () => {
|
||||||
|
test('viewer member sees no "New app" on the apps list', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vlist');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
// Member can see the apps list (just the one they belong to)
|
||||||
|
// but the create-app affordance is hidden.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vdom');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||||
|
).toBeVisible();
|
||||||
|
// Settings tab is absent.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
// Domains tab still listable, but no Add-domain submit.
|
||||||
|
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor sees New script but no Settings tab', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Helpers for tests that drive the dashboard as a non-bootstrap admin
|
||||||
|
// (member with an app-membership row, custom InstanceRole, etc.).
|
||||||
|
//
|
||||||
|
// `loginAsUserToken` exchanges username/password for a bearer token
|
||||||
|
// via the admin API. `pageWithUserToken` opens a fresh browser
|
||||||
|
// context, seeds the dashboard's localStorage entry, and returns the
|
||||||
|
// page ready to navigate. Callers are responsible for closing the
|
||||||
|
// returned page's context.
|
||||||
|
|
||||||
|
import { expect, request, type Browser, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
export async function loginAsUserToken(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const probe = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await probe.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 probe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pageWithUserToken(
|
||||||
|
browser: Browser,
|
||||||
|
token: string
|
||||||
|
): Promise<Page> {
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
// Seed localStorage on the right origin, then navigate normally.
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { expect, type Browser, type Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { test } from '../fixtures/ids';
|
import { test } from '../fixtures/ids';
|
||||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
import { adminApi } from '../fixtures/api';
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
// Phase B5 — App Members. Setup creates one or two extra admin
|
// Phase B5 — App Members. Setup creates one or two extra admin
|
||||||
// users via the API; tests drive the Members tab through the
|
// users via the API; tests drive the Members tab through the
|
||||||
// dashboard like a real app admin would.
|
// dashboard like a real app admin would.
|
||||||
|
|
||||||
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
|
||||||
|
|
||||||
const cleanup = new CleanupRegistry();
|
const cleanup = new CleanupRegistry();
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
await cleanup.run();
|
await cleanup.run();
|
||||||
@@ -38,36 +37,6 @@ async function createMemberUser(username: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginAsUserToken(username: string, password: string): Promise<string> {
|
|
||||||
const probe = await (await import('@playwright/test')).request.newContext({
|
|
||||||
baseURL: API_BASE
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const res = await probe.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 probe.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pageWithUserToken(browser: Browser, token: string): Promise<Page> {
|
|
||||||
const ctx = await browser.newContext({ storageState: undefined });
|
|
||||||
const page = await ctx.newPage();
|
|
||||||
// Seed localStorage on the right origin, then navigate normally.
|
|
||||||
await page.goto('/admin/login');
|
|
||||||
await page.evaluate(
|
|
||||||
([key, value]) => {
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
},
|
|
||||||
['picloud.admin.token', token]
|
|
||||||
);
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('B5 app members', () => {
|
test.describe('B5 app members', () => {
|
||||||
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
||||||
const slug = uniqueSlug('mem');
|
const slug = uniqueSlug('mem');
|
||||||
|
|||||||
@@ -2,6 +2,41 @@ import { expect, type Page } from '@playwright/test';
|
|||||||
import { test } from '../fixtures/ids';
|
import { test } from '../fixtures/ids';
|
||||||
import { CleanupRegistry } from '../fixtures/cleanup';
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
import { adminApi } from '../fixtures/api';
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppScriptAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor';
|
||||||
|
}): Promise<{ scriptId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
|
||||||
|
});
|
||||||
|
expect(scriptRes.ok()).toBe(true);
|
||||||
|
const scriptId = ((await scriptRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { scriptId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
||||||
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
||||||
@@ -175,6 +210,105 @@ test.describe('B3 settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('B3 scripts role shadowing', () => {
|
||||||
|
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vscr');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Header Delete is hidden for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Save/Format on the Edit tab are hidden for viewers.
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.locator('.editor-header').getByRole('button', { name: 'Format' })
|
||||||
|
).toHaveCount(0);
|
||||||
|
// Test invoke is still visible (everyone with read access).
|
||||||
|
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
|
||||||
|
// Routing tab loads, no +Add route.
|
||||||
|
await page.getByRole('button', { name: /Routing/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
|
||||||
|
// Settings tab is absent for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer: CodeMirror is read-only', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vro');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
const cm = page.locator('.cm-content').first();
|
||||||
|
await expect(cm).toBeVisible();
|
||||||
|
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
|
||||||
|
// is in effect; that's the canonical signal for read-only mode.
|
||||||
|
await expect(cm).toHaveAttribute('contenteditable', 'false');
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor: Save visible, Delete header hidden', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('escr');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'editor'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Editor sees Save (disabled until the buffer changes — that's fine).
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
|
||||||
|
// Delete stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Settings stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('B3 adversarial', () => {
|
test.describe('B3 adversarial', () => {
|
||||||
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
||||||
const slug = uniqueSlug('loop');
|
const slug = uniqueSlug('loop');
|
||||||
|
|||||||
Reference in New Issue
Block a user