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 { CleanupRegistry } from '../fixtures/cleanup';
|
||||
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
|
||||
// historical-slug takeover flow and adversarial inputs.
|
||||
@@ -224,3 +254,82 @@ test.describe('B2 apps adversarial', () => {
|
||||
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 { 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 API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
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('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
||||
const slug = uniqueSlug('mem');
|
||||
|
||||
@@ -2,6 +2,41 @@ import { expect, type Page } 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';
|
||||
|
||||
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
|
||||
// /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('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
||||
const slug = uniqueSlug('loop');
|
||||
|
||||
Reference in New Issue
Block a user