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>
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
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
|
|
// sometimes a baseline script) so each test can focus on the editor
|
|
// flow it actually covers.
|
|
|
|
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
|
|
|
const cleanup = new CleanupRegistry();
|
|
test.afterEach(async () => {
|
|
await cleanup.run();
|
|
});
|
|
|
|
async function createAppViaApi(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);
|
|
const body = (await res.json()) as { id: string };
|
|
return body.id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
async function createScriptViaApi(
|
|
appId: string,
|
|
name: string,
|
|
source = HELLO_RHAI
|
|
): Promise<string> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/scripts', {
|
|
data: { app_id: appId, name, source }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = (await res.json()) as { id: string };
|
|
return body.id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
|
const cm = page.locator(locator).first();
|
|
await cm.click();
|
|
await page.keyboard.press('ControlOrMeta+A');
|
|
await page.keyboard.press('Delete');
|
|
await page.keyboard.type(text);
|
|
}
|
|
|
|
test.describe('B3 scripts CRUD', () => {
|
|
test('create script via UI navigates to scripts list with the new entry', async ({
|
|
page,
|
|
uniqueSlug
|
|
}) => {
|
|
const slug = uniqueSlug('cscr');
|
|
await createAppViaApi(slug);
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/apps/${slug}`);
|
|
await page.getByRole('button', { name: /^New script$/ }).click();
|
|
await page.getByLabel('Name').fill('echo');
|
|
// The CodeMirror editor starts empty in create mode; type a
|
|
// minimal valid script.
|
|
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
|
|
await page.getByRole('button', { name: 'Create script' }).click();
|
|
|
|
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
|
|
});
|
|
|
|
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
|
|
const slug = uniqueSlug('edit');
|
|
const appId = await createAppViaApi(slug);
|
|
const scriptId = await createScriptViaApi(appId, 'edit-target');
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/scripts/${scriptId}`);
|
|
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
|
|
|
|
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
|
|
await fillCodeMirror(page, '.cm-content', updated);
|
|
await page.getByRole('button', { name: /^Save$/ }).click();
|
|
// Save button becomes disabled once the buffer matches the
|
|
// just-saved source — that's our settle signal.
|
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
|
|
|
await page.reload();
|
|
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
|
|
});
|
|
|
|
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
|
|
const slug = uniqueSlug('invrhai');
|
|
const appId = await createAppViaApi(slug);
|
|
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/scripts/${scriptId}`);
|
|
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
|
|
await page
|
|
.locator('.editor-header')
|
|
.getByRole('button', { name: 'Format' })
|
|
.click();
|
|
|
|
await expect(page.locator('.error.inline').first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('B3 test-invoke', () => {
|
|
test('valid JSON body returns status + body in the result panel', async ({
|
|
page,
|
|
uniqueSlug
|
|
}) => {
|
|
const slug = uniqueSlug('inv-ok');
|
|
const appId = await createAppViaApi(slug);
|
|
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/scripts/${scriptId}`);
|
|
// Body editor is the second .cm-content (source is first).
|
|
const bodyEditor = page.locator('.cm-content').nth(1);
|
|
await bodyEditor.click();
|
|
await page.keyboard.press('ControlOrMeta+A');
|
|
await page.keyboard.press('Delete');
|
|
await page.keyboard.type('{"hello":"world"}');
|
|
|
|
await page.getByRole('button', { name: /^Send$/ }).click();
|
|
await expect(page.locator('.status')).toContainText('HTTP 200');
|
|
await expect(page.locator('.result pre')).toContainText('ok');
|
|
});
|
|
|
|
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
|
|
const slug = uniqueSlug('inv-bad');
|
|
const appId = await createAppViaApi(slug);
|
|
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/scripts/${scriptId}`);
|
|
const bodyEditor = page.locator('.cm-content').nth(1);
|
|
await bodyEditor.click();
|
|
await page.keyboard.press('ControlOrMeta+A');
|
|
await page.keyboard.press('Delete');
|
|
await page.keyboard.type('{not valid json,');
|
|
|
|
// The Format button for the request body sits inside the
|
|
// Test-invoke card next to the body editor.
|
|
await page
|
|
.locator('.json-block')
|
|
.first()
|
|
.getByRole('button', { name: 'Format' })
|
|
.click();
|
|
await expect(page.locator('.error.inline').first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('B3 settings', () => {
|
|
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
|
|
const slug = uniqueSlug('settz');
|
|
const appId = await createAppViaApi(slug);
|
|
const scriptId = await createScriptViaApi(appId, 'settings-target');
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/scripts/${scriptId}`);
|
|
await page.getByRole('button', { name: 'Settings' }).click();
|
|
const timeout = page.getByLabel(/Timeout/);
|
|
await timeout.fill('0');
|
|
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
|
expect(invalid).toBe(true);
|
|
});
|
|
});
|
|
|
|
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');
|
|
const appId = await createAppViaApi(slug);
|
|
const scriptId = await createScriptViaApi(
|
|
appId,
|
|
'inf-loop',
|
|
'loop { let x = 1; }'
|
|
);
|
|
cleanup.app(slug);
|
|
|
|
await page.goto(`/admin/scripts/${scriptId}`);
|
|
await page.getByRole('button', { name: /^Send$/ }).click();
|
|
|
|
// Either the status renders with a 5xx code, or an error
|
|
// banner shows up. Either way, the page recovers.
|
|
await Promise.race([
|
|
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
|
|
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
|
|
]);
|
|
|
|
// The dashboard must remain interactive after the timeout.
|
|
await page.getByRole('button', { name: 'Settings' }).click();
|
|
await expect(page.getByLabel(/Timeout/)).toBeVisible();
|
|
});
|
|
});
|