Files
meguri/tests/browser-access.cjs
T

224 lines
17 KiB
JavaScript
Raw Normal View History

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');
// The running version sits next to the brand on the login screen and on every page after sign-in.
const label = `v${session.version}`;
assert.match(session.version, /^\d+\.\d+\.\d+/);
assert.equal((await guest.locator('.login .brand .app-version').innerText()).trim(), label);
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);
assert.equal((await byReference.locator('.sidebar .brand .app-version').innerText()).trim(), label, 'Version on the instance page');
for (const view of ['calendar', 'requests', 'activity', 'settings']) {
await byReference.goto(`${base}/?view=${view}`);
await byReference.waitForFunction(() => Alpine.$data(document.body).ready);
assert.equal((await byReference.locator('.sidebar .brand .app-version').innerText()).trim(), label, `Version on ${view}`);
}
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');
assert.equal(await mobile.locator('.sidebar .brand .app-version').isVisible(), true, 'The version stays visible on the narrow rail');
await mobile.screenshot({ path: 'test-results/access-version-mobile.png' });
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);
2026-10-07 14:22:37 +01:00
// 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; });