182 lines
13 KiB
JavaScript
182 lines
13 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');
|
|
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; });
|