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>
This commit is contained in:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
+25
-13
@@ -1,4 +1,4 @@
|
||||
const {openSetup}=require('./support/browser.cjs');
|
||||
const {openSetup,isolatedUser,demoPassword}=require('./support/browser.cjs');
|
||||
// Run against an explicitly seeded localhost development instance.
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
@@ -9,11 +9,11 @@ fs.mkdirSync('test-results', { recursive: true });
|
||||
(async () => {
|
||||
const browser = await require('./support/browser.cjs').launchBrowser();
|
||||
const errors = [];
|
||||
async function signIn(name) {
|
||||
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('meguri-demo-only');
|
||||
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;
|
||||
@@ -23,22 +23,30 @@ fs.mkdirSync('test-results', { recursive: true });
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
}
|
||||
try {
|
||||
const alice = await signIn('alice');
|
||||
// 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 label = 'Browser ' + Date.now();
|
||||
await alice.getByRole('button', { name: 'New workspace' }).click();
|
||||
await alice.getByRole('dialog').getByLabel('Name', { exact: true }).fill(label); await save(alice);
|
||||
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(created.record.revision,1);
|
||||
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.getByRole('button', { name: '+ Add teammate' }).click();
|
||||
await alice.getByRole('dialog').getByLabel('Registered username').fill('bob'); await save(alice);
|
||||
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();
|
||||
@@ -52,11 +60,15 @@ fs.mkdirSync('test-results', { recursive: true });
|
||||
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('bob');
|
||||
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('button', { name: 'Accept', exact: true }).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();
|
||||
@@ -77,7 +89,7 @@ fs.mkdirSync('test-results', { recursive: true });
|
||||
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, workspace creation, membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
|
||||
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()) {
|
||||
|
||||
Reference in new issue
Block a user