2026-10-07 13:28:43 +01:00
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' );
2026-10-07 15:06:12 +01:00
// 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 );
2026-10-07 13:28:43 +01:00
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 );
2026-10-07 13:28:43 +01:00
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 ; });