Files
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

102 lines
7.9 KiB
JavaScript

const {openSetup,isolatedUser,demoPassword}=require('./support/browser.cjs');
// Run against an explicitly seeded localhost development instance.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
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 require('./support/browser.cjs').launchBrowser();
const errors = [];
async function signIn(name, secret) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message));
await page.goto(base); await page.getByLabel('Username', { exact: true }).fill(name);
await page.getByLabel('Password', { exact: true }).fill(secret ?? await demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.getByRole('heading', { name: 'Calendar', exact: true }).waitFor();
return page;
}
async function save(page) {
await page.getByRole('dialog').getByRole('button', { name: 'Save changes', exact: true }).click();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
}
try {
// Workspaces come only from configuration: this suite owns Sandbox 1 (an administrator and one employee).
const label = 'Sandbox 1';
const team1 = await isolatedUser({ sandbox: 1, prefix: 'browser-lead', display: 'Alice Morgan', employees: ['Bob Chen'] });
const [bobAccount] = team1.staff;
const alice = await signIn(team1.username, team1.password);
await alice.locator('.day').first().waitFor();
await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true });
const workspaceId = await alice.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(w=>w.name===label).id, label);
await alice.getByLabel('Workspace', { exact: true }).selectOption(workspaceId);
await alice.waitForFunction(id => Alpine.$data(document.body).workspace?.id===id, workspaceId);
const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; });
assert.deepEqual(Object.keys(created.listing), ['workspaces']);
assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1);
assert.equal(created.record.document.name,label);
assert.equal('tenant_id' in created.record,false);
assert.equal((await alice.request.get(base+'/api/tenants')).status(),404);
const session0 = await alice.evaluate(() => fetch('/api/session').then(r=>r.json()));
const creation = await alice.request.post(base+'/api/workspaces', { headers: { Origin: base, 'X-CSRF-Token': session0.csrf }, data: { name: 'Nope' } });
assert([404,405].includes(creation.status()), 'Workspaces cannot be created through the API');
await openSetup(alice,'Workspace settings');
await alice.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor();
assert.equal(await alice.getByRole('button', { name: '+ Add teammate' }).count(),0);
assert.equal(await alice.locator(`#workspace-members [data-username=${bobAccount.username}] .role-badge`).innerText(),'Employee');
assert.equal(await alice.locator(`#workspace-members [data-username=${team1.username}] .role-badge`).innerText(),'Workspace administrator');
await openSetup(alice,'Schedules');
await alice.locator('.schedule-create > summary').click();
await alice.getByRole('button', { name: '+ Rotation', exact: true }).click();
const start = new Date(Date.now() + 2 * 86400000).toISOString().slice(0,10);
const end = new Date(Date.now() + 3 * 86400000).toISOString().slice(0,10);
await alice.getByRole('dialog').getByLabel('Schedule name').fill('Weekly rotation');
await alice.getByRole('dialog').getByLabel('Anchor date').fill(start.split('-').reverse().join('-')); await save(alice);
await alice.getByRole('link', { name: 'Calendar', exact: true }).click();
await alice.locator('[data-duty-date="'+start+'"]').click();
await alice.getByRole('dialog').getByRole('button', { name: 'Bob Chen', exact: true }).click();
await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment');
await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click();
await alice.getByRole('dialog').waitFor({state:'hidden'});
const bob = await signIn(bobAccount.username, bobAccount.password);
const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label);
await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam);
await bob.waitForFunction(id => { const a=Alpine.$data(document.body); return a.workspace?.id===id && !a.busy; }, selectedTeam);
await bob.getByRole('link', { name: /Requests/ }).click();
await bob.getByRole('heading', { name: 'Requests', exact: true }).waitFor();
await bob.waitForFunction(() => { const a=Alpine.$data(document.body); return !a.busy && a.needsMe.length>0; });
// The calendar's reply panel holds a hidden twin of this button; target the inbox row's actions.
await bob.locator('.request-card .actions button.primary', { hasText: /^Accept$/ }).click();
await bob.locator('.badge.applied').waitFor();
await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true });
await bob.getByRole('link', { name: 'Calendar', exact: true }).click();
await bob.waitForFunction(start => [...document.querySelectorAll('.day')].some(el => el.dataset.dutyDate===start && el.textContent.includes('Bob')), start);
const session = await alice.evaluate(() => fetch('/api/session').then(r=>r.json()));
const workspace = await alice.evaluate(() => fetch('/api/workspaces').then(r=>r.json()));
const team = workspace.workspaces.find(t => t.name === label);
const endpoint = `/api/workspaces/${team.id}`;
let response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: base }, data: {} });
assert.equal(response.status(), 403, 'Missing CSRF must be rejected');
response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: 'https://other.example', 'X-CSRF-Token': session.csrf }, data: {} });
assert.equal(response.status(), 403, 'Cross-origin mutation must be rejected');
const carol = await signIn('carol');
response = await carol.request.get(base + endpoint); assert.equal(response.status(),404,'Other team members cannot read this team');
const stranger = await browser.newContext();response = await stranger.request.get(base + endpoint);assert.equal(response.status(),401);
await bob.setViewportSize({ width:390,height:844 });
await bob.getByLabel('Choose workspace', {exact:true}).waitFor();
await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true });
assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow');
assert.deepEqual(errors, [], 'No browser runtime errors');
console.log('PASS: calendar, configured workspaces (no creation API), role-labelled membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
} catch (error) {
console.error('Browser errors:', errors);
for(const context of browser.contexts()) for(const page of context.pages()) {
console.error('Visible alert:', await page.locator('[role=alert]').textContent().catch(()=>''));
await page.screenshot({ path:'test-results/failure.png',fullPage:true }).catch(()=>{});
}
throw error;
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode=1; });