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; });