Files
meguri/tests/browser-access.cjs
T
Aleksandr BogomiakovandClaude 548494f12e Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log
Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:12 +01:00

213 lines
16 KiB
JavaScript

const { openSetup, signIn, apiLogin, adminApi, demoPassword, launchBrowser, baseUrl } = require('./support/browser.cjs');
// Roles and access: demo login guide, instance administration, workspace-role permissions, isolation.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = baseUrl();
assert(['127.0.0.1', 'localhost'].includes(new URL(base).hostname), 'Browser tests require a disposable local instance');
fs.mkdirSync('test-results', { recursive: true });
(async () => {
const browser = await launchBrowser();
const errors = [];
const pages = [];
const open = async (viewport = { width: 1440, height: 1100 }) => {
const context = await browser.newContext({ locale: 'en-US', viewport });
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', e => errors.push(e.message));
pages.push(page);
return page;
};
const dialog = page => page.getByRole('dialog');
const save = async (page, name = 'Save changes') => {
await dialog(page).getByRole('button', { name, exact: true }).click();
await dialog(page).waitFor({ state: 'hidden' });
};
const selectWorkspace = async (page, name) => {
const id = await page.evaluate(name => Alpine.$data(document.body).directoryState.workspaces.find(w => w.name === name)?.id, name);
assert(id, 'workspace visible: ' + name);
await page.getByLabel('Workspace', { exact: true }).selectOption(id);
await page.waitForFunction(id => Alpine.$data(document.body).workspace?.id === id, id);
return id;
};
try {
const secret = await demoPassword();
const admin = await adminApi();
const overview = await admin.get('/api/admin/overview');
const commerce = overview.workspaces.find(w => w.name === 'Commerce Operations');
assert(commerce && commerce.admin_groups.length && commerce.member_groups.length, 'demo configuration defines groups per workspace');
// 1. Login screen: demo accounts with roles and the generated password; no registration; SSO only when configured.
const guest = await open();
await guest.goto(base);
await guest.locator('.demo-note').waitFor();
for (const [name, role] of [['admin', 'Instance administrator'], ['alice', 'Workspace administrator'], ['bob', 'Employee']])
assert.match(await guest.locator(`[data-demo-account=${name}]`).innerText(), new RegExp(role));
assert.equal((await guest.locator('[data-demo-password]').innerText()).trim(), secret);
assert.equal(await guest.getByRole('button', { name: /create (an )?account/i }).count(), 0, 'No self-registration');
const session = await guest.evaluate(() => fetch('/api/session').then(r => r.json()));
assert.equal(await guest.getByRole('link', { name: 'Sign in with SSO' }).isVisible(), !!session.auth.oidc);
const registration = await guest.request.post(base + '/api/register', { headers: { Origin: base }, data: { username: 'intruder1', password: 'long-enough-password', display_name: 'X' } });
assert([404, 405].includes(registration.status()), 'Registration endpoint is gone');
await guest.locator('[data-demo-account=alice]').click();
assert.equal(await guest.getByLabel('Username', { exact: true }).inputValue(), 'alice');
assert.equal(await guest.getByLabel('Password', { exact: true }).inputValue(), secret);
await guest.screenshot({ path: 'test-results/access-login.png', fullPage: true });
// 2. A failed single sign-on shows a localized message and a support reference, then cleans the URL.
const failed = await open();
await failed.goto(base + '/?login_error=no_access&ref=abc123');
await failed.locator('.login-error').waitFor();
assert.match(await failed.locator('.login-error').innerText(), /could not map your groups to any workspace\. Contact the service administrator\./);
assert.match(await failed.locator('.login-error').innerText(), /abc123/);
assert.equal(new URL(failed.url()).searchParams.has('login_error') || new URL(failed.url()).searchParams.has('ref'), false);
await failed.screenshot({ path: 'test-results/access-login-error.png', fullPage: true });
// 3. Instance administration.
const root = await open();
await signIn(root, 'admin');
await root.getByRole('link', { name: 'Instance', exact: true }).click();
await root.getByRole('heading', { name: 'Instance administration', exact: true }).waitFor();
assert.equal(new URL(root.url()).searchParams.get('view'), 'instance');
for (const key of overview.workspaces.map(w => w.key)) await root.locator(`#instance-workspaces [data-workspace-key="${key}"]`).waitFor();
assert.equal(await root.locator('#instance-workspaces .admin-workspace').count(), overview.workspaces.length);
assert.equal(await root.locator('#instance-workspaces').getByRole('button').count(), 0, 'Workspaces are read-only; only disabled ones can be deleted');
await root.locator('.warning-banner').waitFor();
assert.match(await root.locator('.warning-banner').innerText(), /Bootstrap access is enabled/);
assert.match(await root.locator('#instance-config').innerText(), /Password sign-in[\s\S]*Enabled/);
const adminRow = root.locator('#instance-users [data-username=admin]');
assert.match(await adminRow.innerText(), /Bootstrap/);
assert.equal(await adminRow.getByRole('button', { name: 'Edit groups' }).count(), 0, 'Bootstrap accounts are read-only');
assert.equal(await root.locator('#instance-users [data-username=alice]').getByRole('button', { name: 'Edit groups' }).count(), 1);
for (const u of overview.users.filter(u => u.sso)) assert.equal(await root.locator(`#instance-users [data-username=${u.username}]`).getByRole('button', { name: 'Edit groups' }).count(), 0, 'SSO groups are read-only');
await root.screenshot({ path: 'test-results/access-instance.png', fullPage: true });
const username = 'access-' + Date.now().toString(36);
await root.getByRole('button', { name: 'Add user', exact: true }).click();
await dialog(root).getByLabel('Username', { exact: true }).fill(username);
await dialog(root).getByLabel('Display name', { exact: true }).fill('Access tester');
await dialog(root).getByLabel('Password', { exact: true }).fill('first-test-password-1');
await dialog(root).getByLabel(commerce.member_groups[0], { exact: true }).check();
await save(root, 'Create user');
const row = root.locator(`#instance-users [data-username=${username}]`);
await row.waitFor();
assert.match(await row.innerText(), new RegExp(commerce.member_groups[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
await row.getByRole('button', { name: 'Edit groups' }).click();
await dialog(root).getByLabel(commerce.member_groups[0], { exact: true }).uncheck();
await save(root);
await row.getByText('No groups').waitFor();
// Without a matching group the account has no workspace access: it sees the empty state and no administration.
await row.getByRole('button', { name: 'Reset password' }).click();
await dialog(root).getByLabel('New password', { exact: true }).fill('second-test-password-2');
await save(root, 'Reset password');
// The event log shows these administrator actions, can be searched, and refuses non-administrators.
const log = root.locator('#instance-events');
await log.locator('[data-event-action="user.created"]').filter({ hasText: username }).first().waitFor();
await log.locator('[data-event-action="user.password_reset"]').filter({ hasText: username }).first().waitFor();
assert.match(await log.locator('[data-event-action="user.created"]').first().innerText(), /admin/);
await log.getByPlaceholder(/sign-in reference/).fill('no-such-event-zzzz-' + Date.now());
await log.getByRole('button', { name: 'Search', exact: true }).click();
await log.locator('[data-events-empty]').waitFor();
assert.equal(await log.locator('.event-row').count(), 0);
await log.getByPlaceholder(/sign-in reference/).fill(username);
await log.getByRole('button', { name: 'Search', exact: true }).click();
await log.locator('.event-row').first().waitFor();
const matching = await log.locator('.event-row').evaluateAll(rows => rows.map(r => r.textContent));
assert(matching.length >= 3 && matching.every(text => text.includes(username)), 'Search returns only events about the user');
assert.equal(await log.locator('[data-events-empty]').isVisible(), false);
assert.equal(await log.locator('[data-events-more]').isVisible(), false);
await root.screenshot({ path: 'test-results/access-event-log.png', fullPage: true });
// A support reference in the URL prefills the search.
const byReference = await open();
await signIn(byReference, 'admin');
await byReference.goto(`${base}/?view=instance&ref=${username}`);
await byReference.locator('#instance-events [data-event-action="user.created"]').first().waitFor();
assert.equal(await byReference.locator('#instance-events input[type=search]').inputValue(), username);
const mobile = await open({ width: 390, height: 844 });
await signIn(mobile, 'admin');
await mobile.goto(`${base}/?view=instance`);
await mobile.locator('#instance-events .event-row').first().waitFor();
assert(await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Event log must not overflow on mobile');
const forbidden = await (await apiLogin('bob')).call('GET', '/api/admin/events');
assert.equal(forbidden.status, 403);
assert.equal((await (await apiLogin('alice')).call('GET', '/api/admin/events?search=x')).status, 403);
const outsider = await open();
await signIn(outsider, username, 'second-test-password-2');
await outsider.getByRole('heading', { name: 'No workspace access yet', exact: true }).waitFor();
assert.equal(await outsider.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false);
assert.equal((await (await apiLogin(username, 'second-test-password-2')).call('GET', '/api/admin/overview')).status, 403);
// Your own groups cannot be edited from the page.
assert.equal(await root.locator('#instance-users [data-username=admin]').getByRole('button', { name: 'Edit groups' }).count(), 0);
// 4. Workspace administrators edit the employee role; employees see it read-only.
const lead = await open();
await signIn(lead, 'alice');
assert.equal(await lead.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false, 'Workspace administrators do not see instance administration');
assert.equal((await (await apiLogin('alice')).call('GET', '/api/admin/overview')).status, 403);
const commerceId = await selectWorkspace(lead, 'Commerce Operations');
await openSetup(lead, 'Workspace settings');
await lead.locator('#employee-permissions').waitFor();
assert.equal(await lead.locator('#employee-permissions [data-permission=force_overrides].on').count(), 0, 'Force is off by default');
assert.match(await lead.locator('#access-groups').innerText(), new RegExp(commerce.admin_groups[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
assert.equal(await lead.locator('#workspace-members [data-username=carol] .role-badge').innerText(), 'Employee');
assert.equal(await lead.getByRole('button', { name: /add teammate/i }).count(), 0);
await lead.getByRole('button', { name: 'Edit employee permissions', exact: true }).click();
assert.equal(await dialog(lead).getByLabel('View calendar', { exact: true }).isDisabled(), true, 'Viewing is mandatory');
assert.equal(await dialog(lead).getByLabel('Manage team access').count(), 0, 'Administrative permissions are never grantable');
await dialog(lead).getByLabel('Apply without confirmation', { exact: true }).check();
await lead.screenshot({ path: 'test-results/access-permissions.png', fullPage: true });
await save(lead);
await lead.locator('#employee-permissions [data-permission=force_overrides].on').waitFor();
assert.match(await lead.locator('#employee-permissions .info-note').innerText(), /without confirmation/);
const worker = await open();
await signIn(worker, 'carol');
await selectWorkspace(worker, 'Commerce Operations');
assert.equal(await worker.evaluate(() => Alpine.$data(document.body).can('force_overrides')), true, 'The force permission reaches employees');
await openSetup(worker, 'Workspace settings');
assert.equal(await worker.getByRole('button', { name: 'Edit employee permissions', exact: true }).count(), 0, 'Employees cannot edit permissions');
assert.equal(await worker.getByRole('button', { name: 'Edit working interval' }).count(), 0);
assert.equal(await worker.locator('#access-groups').isVisible(), false, 'Group mapping is shown to administrators only');
assert.equal(await worker.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false);
const carol = await apiLogin('carol');
const detail = await carol.get('/api/workspaces/' + commerceId);
const denied = await carol.call('PUT', `/api/workspaces/${commerceId}/member-permissions`, { revision: detail.workspace.revision, permissions: ['view', 'force_overrides', 'edit_schedules'] });
assert.equal(denied.status, 403);
// Employees may plan schedules, but workspace preferences and the scheduling directory are read-only.
assert.equal(await worker.getByRole('button', { name: 'Edit preferences', exact: true }).count(), 0, 'Employees cannot edit workspace preferences');
assert.equal(await worker.getByRole('button', { name: 'Edit person', exact: true }).count(), 0, 'Employees cannot edit the scheduling directory');
const rename = await carol.call('PUT', `/api/workspaces/${commerceId}/config`, { revision: detail.workspace.revision, document: { ...detail.workspace.document, name: 'Renamed by an employee' } });
assert.equal(rename.status, 403);
await worker.screenshot({ path: 'test-results/access-employee.png', fullPage: true });
// The change applies at once to everyone: turning it off removes the permission without a new login.
await lead.getByRole('button', { name: 'Edit employee permissions', exact: true }).click();
await dialog(lead).getByLabel('Apply without confirmation', { exact: true }).uncheck();
await save(lead);
await lead.locator('#employee-permissions [data-permission=force_overrides]:not(.on)').waitFor();
await worker.reload();
await worker.waitForFunction(() => { const a = Alpine.$data(document.body); return a.ready && a.workspace && !a.busy; });
await selectWorkspace(worker, 'Commerce Operations');
assert.equal(await worker.evaluate(() => Alpine.$data(document.body).can('force_overrides')), false);
// 5. Workspace isolation: a member of one workspace cannot read another, even by URL or API.
const dave = await open();
await signIn(dave, 'dave');
const visible = await dave.evaluate(() => Alpine.$data(document.body).directoryState.workspaces.map(w => w.name));
assert.deepEqual(visible, ['Acme Engineering']);
assert.equal((await (await apiLogin('dave')).call('GET', '/api/workspaces/' + commerceId)).status, 404);
await dave.goto(`${base}/?workspace=${commerceId}`);
await dave.waitForFunction(() => Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
assert.equal(await dave.evaluate(() => Alpine.$data(document.body).workspace.document.name), 'Acme Engineering');
assert.deepEqual(errors, []);
console.log('PASS: demo login guide, no self-registration, SSO error banner, instance administration and its read-only rules, role-based employee permissions, employee/outsider/isolation checks');
} catch (error) {
console.error('Browser errors:', errors);
for (const [index, page] of pages.entries()) {
console.error('Visible alert:', await page.locator('[role=alert]').allTextContents().catch(() => ''));
await page.screenshot({ path: `test-results/access-failure-${index}.png`, fullPage: true }).catch(() => {});
}
throw error;
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });