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:
Aleksandr BogomiakovandClaude committed 2026-10-07 13:28:43 +01:00
1 parent e37d583179
commit 4764f3545f
68 files changed
+7220 -964

No files matched your search

+1267
View File
File diff suppressed because it is too large. Load diff
+6 -7
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
@@ -11,14 +11,13 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
const fmt=iso=>iso.split('-').reverse().join('-');
try {
const unique=Date.now();await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
// Sandbox 4 belongs to this suite: an administrator and two substitutes (Bob Chen, Carol Substitute).
const tester=await isolatedUser({sandbox:4,prefix:'absence',display:'Absence tester',employees:['Bob Chen','Carol Substitute']});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
const dates=await page.evaluate(async()=>{
const app=Alpine.$data(document.body);
for(const username of ['bob','carol']){await app.api(app.endpoint('/members'),'PUT',{revision:app.workspace.revision,username,permissions:['view','request_overrides']});await app.loadWorkspace();}
const bob=app.directory.find(p=>p.username==='bob').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
const bob=app.directory.find(p=>p.display_name==='Bob Chen').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
const rotation=crypto.randomUUID();
doc.schedules=[{id:rotation,name:'General',handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:start,shift_days:7},{id:crypto.randomUUID(),name:'Backup',handover:'09:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}];
for(let i=1;i<=9;i++)doc.schedules.push({id:crypto.randomUUID(),name:'Project '+i,handover:'11:00:00',type:'coverage',primary:app.user.id,secondary:bob,fallback:rotation});
+176
View File
@@ -0,0 +1,176 @@
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);
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; });
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{
for(const [user,names] of [['alice',['Acme Engineering','Customer Support','Regional Platform','Commerce Operations']],['bob',['Acme Engineering','Regional Platform']],['carol',['Acme Engineering','Customer Support','Commerce Operations']],['dave',['Acme Engineering']],['erin',['Acme Engineering']]]){
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
const teams=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces);assert.deepEqual(teams.map(t=>t.name).filter(n=>['Acme Engineering','Customer Support','Regional Platform','Commerce Operations'].includes(n)).sort(),names.sort());
if(user==='alice'){const schedules=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules);assert.equal(schedules.filter(s=>s.type==='priority').length,6);assert(schedules.filter(s=>s.type==='priority').every(s=>s.participants.length>=2&&s.participants.length<=3));assert.equal(schedules.find(s=>s.type==='rotation').participants.length,5);}
if(user==='alice')for(const name of ['Regional Platform','Commerce Operations']){
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -6,7 +6,7 @@ const assert=require('node:assert/strict');
const page=await browser.newPage({viewport:{width:1280,height:950}}),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.MEGURI_URL||'http://127.0.0.1:3000/?view=configuration');
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body),b=a.form.blocks.find(b=>b.kind==='escalation');a.editBinding(b.fields.repeat_count ||= a.literal(0),'number','Repeat count');});
const preview=page.getByRole('textbox',{name:'Value preview',exact:true});await preview.waitFor();assert.equal(await preview.inputValue(),'0');
+7 -14
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
// End-to-end coverage for scheduling contacts, country imports, working hours, and display timezones.
const assert=require('node:assert/strict');
@@ -14,26 +14,19 @@ fs.mkdirSync('test-results',{recursive:true});
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
const state=()=>page.evaluate(()=>{const app=Alpine.$data(document.body);return JSON.parse(JSON.stringify({workspace:app.workspace,user:app.user,endpoint:app.endpoint(),displayZone:app.displayZone}));});
try {
const label='Calendars '+Date.now();
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('calendars-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Calendar tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
// Sandbox 2 belongs to this suite: an administrator plus Bob Chen as an employee.
const tester=await isolatedUser({sandbox:2,prefix:'calendars',display:'Calendar tester',employees:['Bob Chen']});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
await openSetup(page,'Workspace settings');
await page.getByRole('button',{name:'+ Add teammate'}).click();await dialog().getByLabel('Registered username').fill('bob');await save();
await page.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor();
await openSetup(page,'Schedules');
await page.locator('.schedule-create > summary').click();
await page.getByRole('button',{name:'+ Rotation',exact:true}).click();
await dialog().getByLabel('Schedule name').fill('Distributed rotation');
await dialog().getByLabel('Handover time').fill('09:00');
await dialog().getByLabel('Remove participant').first().click();
await dialog().getByLabel('Remove participant').first().click();
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
const picker=()=>page.getByRole('region',{name:'Choose a person'});
await picker().getByLabel('Search workspace users or enter email').fill('Bob');
+5 -12
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
// Regression for dependency-aware removal and routing precedence through the real UI/API.
const assert=require('node:assert/strict');
@@ -24,17 +24,10 @@ fs.mkdirSync('test-results',{recursive:true});
assert.deepEqual((await state()).document.routes.map(r=>r.order),names.map((_,i)=>i));
};
try {
const unique=Date.now();
await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('configuration-'+unique);
await page.getByLabel('Display name',{exact:true}).fill('Configuration tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
// Seed only this test's new team; all mutations under test use visible controls.
const tester=await isolatedUser({sandbox:3,prefix:'configuration',display:'Configuration tester'});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
// Seed only this test's emptied workspace; all mutations under test use visible controls.
await page.evaluate(async()=>{
const app=Alpine.$data(document.body),doc=JSON.parse(JSON.stringify(app.doc));
doc.schedules=['Payments · primary','Payments · secondary','Alternate'].map(name=>({id:crypto.randomUUID(),name,handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}));
+8 -6
View File
@@ -1,4 +1,5 @@
const {isolatedUser}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
@@ -7,19 +8,20 @@ fs.mkdirSync('test-results',{recursive:true});
(async()=>{
const browser=await require('./support/browser.cjs').launchBrowser();
const errors=[];
async function login(name){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.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.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('.day').first().waitFor();return page;}
async function login(name,secret){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill(secret);await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.ready&&a.user&&!a.busy&&a.workspace;});return page;}
async function refresh(page){await page.reload();await page.locator('.day').first().waitFor();}
const dialog=p=>p.getByRole('dialog');
const post=(page,path,body)=>page.evaluate(async({path,body})=>{const a=Alpine.$data(document.body);return a.api(path,'POST',body);},{path,body});
let alice,bob;
try{
alice=await login('alice');bob=await login('bob');
// Isolate the workflow from the seeded examples and from other browser suites.
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.username==='bob').user.id};});
const team=await post(alice,'/api/workspaces',{name:'Cover UX '+Date.now()});
// Sandbox 6 belongs to this suite: an administrator (Alice Morgan) and one employee (Bob Chen).
const sandbox=await isolatedUser({sandbox:6,prefix:'cover-lead',display:'Alice Morgan',employees:['Bob Chen']});
const [bobAccount]=sandbox.staff,team=sandbox.workspace;
alice=await login(sandbox.username,sandbox.password);bob=await login(bobAccount.username,bobAccount.password);
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.display_name==='Bob Chen').user.id};});
const endpoint=`/api/workspaces/${team.id}`;
const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);let r=await a.api(endpoint+'/members','PUT',{revision:1,username:'bob',permissions:['view','request_overrides']});const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);const r=(await a.api(endpoint)).workspace;const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const url=base+'/?workspace='+team.id;
await alice.goto(url);await bob.goto(url);await bob.locator('.day').first().waitFor();
assert.equal(await bob.getByRole('button',{name:'Schedules',exact:true}).isVisible(),false,'Setup stays out of the daily flow');
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict');
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
await p.evaluate(()=>{const a=Alpine.$data(document.body),alice=a.user.id,bob=a.people.find(x=>x.display_name==='Bob Chen').id,schedule=a.doc.schedules[0].id; a.doc.requests=[{id:'test',status:'pending',required_approvers:[alice,bob],approvals:[alice],legs:[{schedule_id:schedule,from_user:alice,to_user:bob,start:'2026-10-08T11:00:00+03:00',end:'2026-10-10T08:00:00.000Z'}]}];a.detail.coverage={total:1,issues:[{schedule_id:schedule,user_id:alice,start:'2026-10-08T08:00:00Z',end:'2026-10-11T08:00:00Z'}]};});
const box=p.locator('.coverage-problems');await box.waitFor({state:'visible'});assert.equal(await box.locator('.coverage-issue').count(),2);await box.getByText('Waiting for: Bob Chen',{exact:true}).waitFor();assert.equal(await box.getByText('No substitute assigned',{exact:true}).count(),1);assert.equal(await box.getByText('Awaiting approval',{exact:true}).count(),1);
const parts=await p.evaluate(()=>Alpine.$data(document.body).coverageItems);assert.equal(parts[0].end,parts[1].start);assert.equal(parts[1].start,'2026-10-10T08:00:00.000Z');assert(!parts[1].awaiting);
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,5 +1,5 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({patLine truncated
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true})Line truncated
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=false;});
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+8 -7
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn,demoPassword}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
@@ -13,9 +13,9 @@ fs.mkdirSync('test-results',{recursive:true});
const save=async(name='Save changes')=>{await dialog().getByRole('button',{name,exact:true}).click();await dialog().waitFor({state:'hidden'});};
const checked=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult && !a.generationLoading && a.generationResult.key===JSON.stringify(a.generationPayload());});
try {
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);assert.match(await page.locator('.demo-note').innerText(),/alice[\s\S]*all four workspaces[\s\S]*bob[\s\S]*carol/);
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);for(const [name,role] of [['admin','Instance administrator'],['alice','Workspace administrator'],['bob','Employee']])assert.match(await page.locator(`[data-demo-account=${name}]`).innerText(),new RegExp(role));assert.equal((await page.locator('[data-demo-password]').innerText()).trim(),await demoPassword());
await page.screenshot({path:'test-results/demo-login.png',fullPage:true});
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
const demo=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {matrix:a.directoryState.workspaces.find(t=>t.name==='Acme Engineering'),support:a.directoryState.workspaces.find(t=>t.name==='Customer Support')};});
assert(demo.matrix && demo.support);assert.notEqual(demo.matrix.id,demo.support.id);
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.matrix.id);await openSetup(page,'Variables & rules');await checked();
@@ -40,11 +40,12 @@ fs.mkdirSync('test-results',{recursive:true});
await openSetup(page,'Escalations & routing');await page.screenshot({path:'test-results/support-demo.png',fullPage:true});
// Distinct members cannot read the other example's team, even with its URL.
for(const [username,denied] of [['bob',demo.support],['dave',demo.support]]){
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:'meguri-demo-only'}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:await require('./support/browser.cjs').demoPassword()}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
}
// Mutating UI checks use a separate account, leaving the examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();await page.getByRole('button',{name:'New here? Create an account',exact:true}).click();const unique=Date.now();await page.getByLabel('Username',{exact:true}).fill('matrix-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Matrix tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Matrix '+unique);await save();
// Mutating UI checks use Sandbox 5 and a separate administrator, leaving the demo examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();
const tester=await isolatedUser({sandbox:5,prefix:'matrix',display:'Matrix tester'});
await signIn(page,tester.username,tester.password);await page.getByLabel('Workspace',{exact:true}).waitFor();
await openSetup(page,'Variables & rules');
await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked();
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationCount('create')),2);
+1 -1
View File
@@ -2,7 +2,7 @@ const assert=require('node:assert/strict'),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));
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();const errors=[];let page;try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
const matrix=await page.evaluate(()=>Alpine.$data(document.body).selected);const support=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.id!==Alpine.$data(document.body).selected).id);
assert.equal(new URL(page.url()).searchParams.get('view'),'configuration');await page.reload();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
await page.getByRole('link',{name:'Requests',exact:true}).click();await page.waitForURL(/view=requests/);await page.getByLabel('Workspace',{exact:true}).selectOption(support);await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,support);assert.equal(new URL(page.url()).searchParams.get('workspace'),support);
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
const panel=page.locator('.rule-result');const ready=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});await ready();
assert.equal(await panel.locator('.rule-result-row').count(),18);const text=await panel.innerText();assert.match(text,/Payments/);assert.match(text,/Alice Morgan/);assert.match(text,/Time off/);assert.match(text,/Escalation/);assert.match(text,/Routing/);await panel.screenshot({path:'test-results/rule-result.png'});
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,3 +1,3 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
+25 -13
View File
@@ -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()) {
+326
View File
@@ -0,0 +1,326 @@
//! Demonstration lifecycle. Kept in its own test binary because installing the demonstration
//! notice is process-wide and disables real provider writes.
mod support;
use chrono::{Duration, Utc};
use cot::db::query;
use meguri::{domain::*, engine, models::SyncJobRow, service};
use uuid::Uuid;
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let source_url = runtime::application_url(url.as_str(), false).unwrap();
let source = Store::connect(&source_url).await.unwrap();
support::install_demo_notice();
let owner = meguri::identity::new_user_row("protected-owner".into(), "Keep me".into(), None);
owner.clone().insert(&source.db).await.unwrap();
let tenant = meguri::identity::ensure_workspace(
&source,
&support::unique_workspace("Protected workspace"),
)
.await
.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
url.set_path(&format!("/{alternate_name}"));
let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str()))
.await
.unwrap();
assert!(isolated);
assert_ne!(
reqwest::Url::parse(&selected).unwrap().path(),
reqwest::Url::parse(&source_url).unwrap().path()
);
let demo = Store::connect(&selected).await.unwrap();
assert!(runtime::is_demo_database(&demo.db).await);
meguri::demo::seed(&demo).await.unwrap();
assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 6);
assert_eq!(
WorkspaceRow::objects().all(&demo.db).await.unwrap().len(),
4
);
let old_user = query!(UserRow,$username=="alice")
.get(&demo.db)
.await
.unwrap()
.unwrap();
demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap();
runtime::reset(&demo.db).await.unwrap();
assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty());
assert!(runtime::is_demo_database(&demo.db).await);
demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap();
let rerun = Store::connect(&selected).await.unwrap();
meguri::demo::seed(&rerun).await.unwrap();
assert_ne!(
query!(UserRow,$username=="alice")
.get(&rerun.db)
.await
.unwrap()
.unwrap()
.id,
old_user.id
);
runtime::mark_normal(&source.db).await.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
assert!(
query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
assert!(
runtime::cleanup(
&source,
Utc::now() + Duration::days(100),
&runtime::Retention::default()
)
.await
.is_err()
);
assert!(
query!(UserRow,$id==owner.id.clone())
.exists(&source.db)
.await
.unwrap()
);
// Names are generated above exclusively for this test; never drop the configured DB.
admin
.raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)"))
.await
.unwrap();
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let url = runtime::application_url(url.as_str(), true).unwrap();
let raw = Database::new(&url).await.unwrap();
runtime::claim_empty(&raw).await.unwrap();
let db = Store::connect(&url).await.unwrap();
support::install_demo_notice();
meguri::demo::seed(&db).await.unwrap();
let alice = query!(UserRow,$username=="alice")
.get(&db.db)
.await
.unwrap()
.unwrap();
let alice_id = Uuid::parse_str(&alice.id).unwrap();
// A temporary workspace and account next to the permanent examples, both granted through
// the ordinary group mapping.
let mut config = meguri::demo::config().unwrap();
let temporary = support::unique_workspace("Temporary team");
config.workspaces.push(temporary.clone());
meguri::identity::ensure_workspace(&db, &temporary)
.await
.unwrap();
let visitor =
support::account(&db, &config, "Temporary visitor", &temporary.admin_groups).await;
let mut alice_groups = meguri::identity::stored_groups(&alice).unwrap();
alice_groups.extend(temporary.member_groups.clone());
meguri::identity::apply_access(&db, &config, alice_id, Some(alice_groups))
.await
.unwrap();
let mut team = meguri::identity::find_workspace(&db, &temporary.key)
.await
.unwrap()
.unwrap();
let tenant = team.clone();
let original_revision = team.revision;
// Editing does not extend lifetime: created_at is the expiration anchor.
let mut doc = team.document.clone();
doc.name = "Edited temporary team".into();
team = service::update_document(&db, visitor.id, team.id, team.revision, doc)
.await
.unwrap();
assert!(team.revision > original_revision);
let now = query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.created_at;
let schedule = Uuid::new_v4();
let mut document = team.document.clone();
document.schedules.push(Schedule {
id: schedule,
name: "Temporary rotation".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![visitor.id],
anchor: now.date_naive(),
shift_days: 7,
},
});
team = service::update_document(&db, visitor.id, team.id, team.revision, document)
.await
.unwrap();
let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap();
team = service::request_replacement(
&db,
visitor.id,
team.id,
service::NewRequest {
remaining: false,
revision: team.revision,
reason: "Demo coverage".into(),
force: false,
legs: vec![ReplacementLeg {
schedule_id: schedule,
start,
end: start + Duration::hours(1),
from_user: visitor.id,
to_user: alice_id,
}],
},
)
.await
.unwrap();
let ttl = runtime::Retention {
workspaces: 120,
accounts: 300,
requests: 60,
events: 180,
purge_grace: 30,
};
runtime::cleanup(&db, now + Duration::seconds(61), &ttl)
.await
.unwrap();
let after = db.workspace(team.id).await.unwrap();
assert!(after.document.requests.is_empty());
assert_eq!(after.revision, team.revision + 1);
assert_eq!(
query!(SyncJobRow,$workspace_id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.requested_revision,
after.revision
);
runtime::cleanup(&db, now + Duration::seconds(119), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_ok());
runtime::cleanup(&db, now + Duration::seconds(121), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_err());
assert!(
query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.demo_deleted_at
.is_some()
);
assert!(
service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
service::update_document(
&db,
visitor.id,
team.id,
team.revision,
team.document.clone()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(152), &ttl)
.await
.unwrap();
assert!(
!query!(WorkspaceRow,$id==team.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
runtime::cleanup(&db, now + Duration::seconds(242), &ttl)
.await
.unwrap();
assert!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
runtime::cleanup(&db, now + Duration::seconds(301), &ttl)
.await
.unwrap();
assert!(
service::login(
&db,
true,
visitor.username.clone(),
"test-only-password".into()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(332), &ttl)
.await
.unwrap();
assert!(
!query!(UserRow,$id==visitor.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert!(
!query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 6);
meguri::demo::seed(&db).await.unwrap();
assert_eq!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4);
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
+2
View File
@@ -121,6 +121,7 @@ fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
email: None,
display_name: "Person".into(),
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
aliases: vec![],
}));
doc.holidays.push(Holiday {
country: "CY".into(),
@@ -168,6 +169,7 @@ fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
email: Some("cover@example.com".into()),
display_name: "Cover".into(),
country: Some("CY".into()),
aliases: vec![],
});
doc.requests.push(ReplacementRequest {
effective_legs: None,
+25 -327
View File
@@ -14,323 +14,6 @@ use serde_json::json;
use support::Fixture;
use uuid::Uuid;
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let source_url = runtime::application_url(url.as_str(), false).unwrap();
let source = Store::connect(&source_url).await.unwrap();
let owner = service::register(
&source,
"protected-owner".into(),
"Keep me".into(),
"test-password-only".into(),
)
.await
.unwrap();
let tenant = service::create_workspace(&source, owner.id, "Protected workspace".into())
.await
.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
url.set_path(&format!("/{alternate_name}"));
let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str()))
.await
.unwrap();
assert!(isolated);
assert_ne!(
reqwest::Url::parse(&selected).unwrap().path(),
reqwest::Url::parse(&source_url).unwrap().path()
);
let demo = Store::connect(&selected).await.unwrap();
assert!(runtime::is_demo_database(&demo.db).await);
meguri::demo::seed(&demo).await.unwrap();
assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 5);
assert_eq!(
WorkspaceRow::objects().all(&demo.db).await.unwrap().len(),
4
);
let old_user = query!(UserRow,$username=="alice")
.get(&demo.db)
.await
.unwrap()
.unwrap();
demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap();
runtime::reset(&demo.db).await.unwrap();
assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty());
assert!(runtime::is_demo_database(&demo.db).await);
demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap();
let rerun = Store::connect(&selected).await.unwrap();
meguri::demo::seed(&rerun).await.unwrap();
assert_ne!(
query!(UserRow,$username=="alice")
.get(&rerun.db)
.await
.unwrap()
.unwrap()
.id,
old_user.id
);
runtime::mark_normal(&source.db).await.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
assert!(
query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
assert!(
runtime::cleanup(
&source,
Utc::now() + Duration::days(100),
&runtime::Retention::default()
)
.await
.is_err()
);
assert!(
query!(UserRow,$id==owner.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
// Names are generated above exclusively for this test; never drop the configured DB.
admin
.raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)"))
.await
.unwrap();
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let url = runtime::application_url(url.as_str(), true).unwrap();
let raw = Database::new(&url).await.unwrap();
runtime::claim_empty(&raw).await.unwrap();
let db = Store::connect(&url).await.unwrap();
meguri::demo::seed(&db).await.unwrap();
let alice = query!(UserRow,$username=="alice")
.get(&db.db)
.await
.unwrap()
.unwrap();
let alice_id = Uuid::parse_str(&alice.id).unwrap();
let visitor = service::register(
&db,
"visitor".into(),
"Temporary visitor".into(),
"test-password-only".into(),
)
.await
.unwrap();
let mut team = service::create_workspace(&db, visitor.id, "Temporary team".into())
.await
.unwrap();
let tenant = team.clone();
let original_revision = team.revision;
// Editing does not extend lifetime: created_at is the expiration anchor.
let mut doc = team.document.clone();
doc.name = "Edited temporary team".into();
team = service::update_document(&db, visitor.id, team.id, team.revision, doc)
.await
.unwrap();
assert!(team.revision > original_revision);
let now = query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.created_at;
team = service::set_member(
&db,
visitor.id,
team.id,
team.revision,
"alice".into(),
Permission::member(),
)
.await
.unwrap();
let schedule = Uuid::new_v4();
let mut document = team.document.clone();
document.schedules.push(Schedule {
id: schedule,
name: "Temporary rotation".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![visitor.id],
anchor: now.date_naive(),
shift_days: 7,
},
});
team = service::update_document(&db, visitor.id, team.id, team.revision, document)
.await
.unwrap();
let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap();
team = service::request_replacement(
&db,
visitor.id,
team.id,
service::NewRequest {
remaining: false,
revision: team.revision,
reason: "Demo coverage".into(),
force: false,
legs: vec![ReplacementLeg {
schedule_id: schedule,
start,
end: start + Duration::hours(1),
from_user: visitor.id,
to_user: alice_id,
}],
},
)
.await
.unwrap();
let ttl = runtime::Retention {
workspaces: 120,
accounts: 300,
requests: 60,
events: 180,
purge_grace: 30,
};
runtime::cleanup(&db, now + Duration::seconds(61), &ttl)
.await
.unwrap();
let after = db.workspace(team.id).await.unwrap();
assert!(after.document.requests.is_empty());
assert_eq!(after.revision, team.revision + 1);
assert_eq!(
query!(SyncJobRow,$workspace_id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.requested_revision,
after.revision
);
runtime::cleanup(&db, now + Duration::seconds(119), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_ok());
runtime::cleanup(&db, now + Duration::seconds(121), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_err());
assert!(
query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.demo_deleted_at
.is_some()
);
assert!(
service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
service::update_document(
&db,
visitor.id,
team.id,
team.revision,
team.document.clone()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(152), &ttl)
.await
.unwrap();
assert!(
!query!(WorkspaceRow,$id==team.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
runtime::cleanup(&db, now + Duration::seconds(242), &ttl)
.await
.unwrap();
assert!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
runtime::cleanup(&db, now + Duration::seconds(301), &ttl)
.await
.unwrap();
assert!(
service::login(&db, "visitor".into(), "test-password-only".into())
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(332), &ttl)
.await
.unwrap();
assert!(
!query!(UserRow,$id==visitor.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert!(
!query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 5);
meguri::demo::seed(&db).await.unwrap();
assert_eq!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4);
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn generation_is_workspace_scoped_atomic_and_revision_checked() {
@@ -369,9 +52,9 @@ async fn generation_is_workspace_scoped_atomic_and_revision_checked() {
.is_err()
);
draft.review_id = preview["review_id"].as_str().map(str::to_owned);
let other = service::create_workspace(&f.db, actor, "Independent workspace".into())
.await
.unwrap();
let other = f
.extra_workspace("Independent workspace", &f.people[0])
.await;
assert!(
generation_service::preview(&f.db, f.people[1].id, other.id, draft.clone())
.await
@@ -844,6 +527,7 @@ async fn email_contacts_are_scoped_people_without_login_or_access_and_import_is_
display_name: "New colleague".into(),
email: Some(" New.Person@Example.com ".into()),
country: Some("am".into()),
aliases: vec![],
});
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
participants.push(id);
@@ -954,9 +638,7 @@ async fn workspace_interval_propagates_atomically_and_user_timezone_does_not_mov
let actor = f.people[0].id;
let tenant = f.team.id;
let team = f.team.id;
let other = service::create_workspace(&f.db, actor, "Another team".into())
.await
.unwrap();
let other = f.extra_workspace("Another team", &f.people[0]).await;
let mut doc = f.team.document.clone();
doc.routes.push(RoutingRule {
id: Uuid::new_v4(),
@@ -1106,6 +788,7 @@ async fn jsm_resolves_exact_active_email_before_writes_and_explicit_ids_override
display_name: "Email contact".into(),
email: Some("new@example.com".into()),
country: Some("CY".into()),
aliases: vec![],
});
f.team.document.schedules[0].kind = ScheduleKind::Rotation {
participants: vec![contact],
@@ -1188,9 +871,7 @@ async fn approval_swap_tenant_isolation_and_atomic_outbox() {
.await
.is_err()
);
let other = service::create_workspace(&f.db, owner, "Private workspace".into())
.await
.unwrap();
let other = f.extra_workspace("Private workspace", &f.people[0]).await;
assert!(f.db.require(other.id, bob, Permission::View).await.is_err());
assert!(matches!(
service::update_document(
@@ -1304,6 +985,16 @@ async fn concurrent_edits_and_constraint_failure_preserve_history_and_job() {
let before = f.db.audit(team).await.unwrap().len();
let mut invalid = record.clone();
invalid.document.name = "Must roll back".into();
// A history row that already owns the next revision's key makes the final statement fail
// after the workspace update and job upsert were evaluated: everything must roll back.
let planted = format!("{team}:{}", record.revision + 1);
f.db.db
.raw_with(
"INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) VALUES($1,$2,$3,'{}',now())",
&[&planted, &team.to_string(), &(record.revision + 1)],
)
.await
.unwrap();
assert!(
f.db.commit(
&mut invalid,
@@ -1311,11 +1002,18 @@ async fn concurrent_edits_and_constraint_failure_preserve_history_and_job() {
Permission::ManageMembers,
"test.invalid",
json!({}),
Some((Uuid::new_v4(), Permission::member()))
Some(Permission::member())
)
.await
.is_err()
);
f.db.db
.raw_with(
"DELETE FROM meguri__workspace_history WHERE id=$1",
&[&planted],
)
.await
.unwrap();
assert_eq!(
f.db.workspace(team).await.unwrap().revision,
record.revision
+434
View File
@@ -0,0 +1,434 @@
//! End-to-end SSO over HTTP: the real Cot application, a real PostgreSQL database and a fake
//! OpenID provider. One test function because the instance configuration is process-wide.
#[path = "support/fake_oidc.rs"]
mod fake_oidc;
mod support;
use cot::{
AppBuilder,
config::{DatabaseConfig, ProjectConfig},
db::query,
project::RegisterAppsContext,
test::TestServerBuilder,
};
use fake_oidc::{CLIENT_ID, CLIENT_SECRET, Scenario};
use meguri::{
access::{self, Config},
identity::{self, BootstrapPasswords, SyncOptions},
models::{ExternalIdentityRow, UserRow},
oidc,
web::MeguriApp,
};
use reqwest::{Client, Response, StatusCode, redirect::Policy};
use serde_json::{Value, json};
use support::IsolatedDatabase;
struct TestProject {
database: String,
}
impl cot::Project for TestProject {
fn config(&self, _: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder()
.debug(false)
.database(DatabaseConfig::builder().url(self.database.clone()).build())
.build())
}
fn register_apps(&self, apps: &mut AppBuilder, _: &RegisterAppsContext) {
apps.register_with_views(MeguriApp, "");
}
}
fn cookies(response: &Response) -> Vec<String> {
response
.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap().to_owned())
.collect()
}
fn cookie_value(response: &Response, name: &str) -> Option<String> {
cookies(response).iter().find_map(|c| {
let value = c.strip_prefix(&format!("{name}="))?.split(';').next()?;
(!value.is_empty()).then(|| value.to_owned())
})
}
fn location(response: &Response) -> String {
response
.headers()
.get("location")
.expect("redirect location")
.to_str()
.unwrap()
.to_owned()
}
fn claims(subject: &str, name: &str, groups: &[&str]) -> Value {
json!({
"sub":subject,"preferred_username":name,"name":format!("{name} Person"),
"email":format!("{name}@corp.test"),"groups":groups
})
}
struct Harness {
client: Client,
base: String,
idp: fake_oidc::Server,
}
impl Harness {
async fn begin(&self) -> (String, String) {
let response = self
.client
.get(format!("{}/api/oidc/login", self.base))
.send()
.await
.unwrap();
assert!(response.status().is_redirection(), "{}", response.status());
let flow = cookies(&response)
.into_iter()
.find(|c| c.starts_with("meguri_oidc="))
.unwrap();
for attribute in ["HttpOnly", "SameSite=Lax", "Path=/api/oidc", "Max-Age=600"] {
assert!(flow.contains(attribute), "{flow}");
}
let state = flow["meguri_oidc=".len()..].split(';').next().unwrap();
(location(&response), state.to_owned())
}
async fn callback(&self, query: &str, cookie: Option<&str>) -> Response {
let mut request = self
.client
.get(format!("{}/api/oidc/callback?{query}", self.base));
if let Some(cookie) = cookie {
request = request.header("cookie", format!("meguri_oidc={cookie}"));
}
request.send().await.unwrap()
}
async fn attempt(&self, scenario: Scenario) -> Response {
let (url, state) = self.begin().await;
let code = self.idp.authorize(&url, scenario);
self.callback(&format!("code={code}&state={state}"), Some(&state))
.await
}
async fn session(&self, token: &str) -> Value {
self.client
.get(format!("{}/api/session", self.base))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
async fn signed_in(&self, response: &Response) -> String {
assert!(response.status().is_redirection());
assert_eq!(location(response), "/", "{:?}", cookies(response));
cookie_value(response, "meguri_session").expect("session cookie")
}
fn refused(&self, response: &Response, code: &str) -> String {
assert!(response.status().is_redirection());
let target = location(response);
assert!(
target.starts_with(&format!("/?login_error={code}&ref=")),
"{target}"
);
assert!(
cookie_value(response, "meguri_session").is_none(),
"a refused sign-in never sets a session"
);
target.split("ref=").nth(1).unwrap().to_owned()
}
}
#[cot::e2e_test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"]
async fn sso_login_over_http_validates_tokens_maps_groups_and_never_leaks_a_session() {
let iso = IsolatedDatabase::new().await;
let idp = fake_oidc::Server::start().await;
let project = TestProject {
database: iso.url().await,
};
let server = TestServerBuilder::new(project).start().await;
let base = server.url();
// The process-wide settings can only be known once both servers have addresses.
// SAFETY: this binary has a single test function; nothing else reads the environment.
unsafe {
std::env::set_var("MEGURI_PUBLIC_URL", &base);
}
let config = Config::parse(&format!(
r#"
oidc: {{issuer: '{}', client_id: {CLIENT_ID}}}
instance_admin_groups: ['/meguri/admins']
workspaces:
- {{key: payments, name: Payments, admin_groups: ['/payments/leads'], member_groups: ['/payments/team']}}
- {{key: search, name: Search, admin_groups: ['/search/leads'], member_groups: ['/search/team']}}
"#,
idp.issuer()
))
.unwrap();
access::install(config.clone());
oidc::install(oidc::Settings {
config: config.oidc.clone().unwrap(),
client_secret: CLIENT_SECRET.into(),
redirect_uri: format!("{base}/api/oidc/callback"),
});
identity::sync_config(
&iso.store,
&config,
SyncOptions {
passwords: BootstrapPasswords::Derived,
now: chrono::Utc::now(),
allow_disable_all: false,
},
)
.await
.unwrap();
let h = Harness {
client: Client::builder().redirect(Policy::none()).build().unwrap(),
base: base.clone(),
idp,
};
let users = || async { UserRow::objects_count(&iso).await };
// The login screen offers SSO only; password sign-in is switched off for this instance.
let session: Value = h
.client
.get(format!("{base}/api/session"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(session["auth"], json!({"oidc":true,"password":false}));
assert!(session["user"].is_null());
let password = h
.client
.post(format!("{base}/api/login"))
.header("origin", &base)
.json(&json!({"username":"anyone","password":"anything-at-all"}))
.send()
.await
.unwrap();
assert_eq!(password.status(), StatusCode::UNAUTHORIZED);
// Registration and workspace creation no longer exist.
for (path, expected) in [("/api/register", StatusCode::NOT_FOUND)] {
let response = h
.client
.post(format!("{base}{path}"))
.header("origin", &base)
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}");
}
// Happy path: an employee of Payments signs in and gets exactly that.
let (url, _) = h.begin().await;
let url = reqwest::Url::parse(&url).unwrap();
assert_eq!(url.path(), "/realms/corp/protocol/openid-connect/auth");
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["redirect_uri"], format!("{base}/api/oidc/callback"));
assert!(query["state"].len() >= 32 && query["nonce"].len() >= 32);
let response = h
.attempt(Scenario::new(claims(
"u-1",
"carol",
&["/payments/team", "/other"],
)))
.await;
let token = h.signed_in(&response).await;
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_session=")
&& c.contains("HttpOnly")
&& c.contains("SameSite=Lax"))
);
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_oidc=;") && c.contains("Max-Age=0")),
"the one-time flow cookie is cleared"
);
let session = h.session(&token).await;
assert_eq!(session["user"]["username"], "carol");
assert_eq!(session["user"]["email"], "carol@corp.test");
assert_eq!(session["user"]["instance_admin"], false);
assert_eq!(session["user"]["has_password"], false);
let workspaces: Value = h
.client
.get(format!("{base}/api/workspaces"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(workspaces["workspaces"].as_array().unwrap().len(), 1);
assert_eq!(workspaces["workspaces"][0]["key"], "payments");
// The administration API answers 403 to an ordinary employee.
let admin = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(admin.status(), StatusCode::FORBIDDEN);
assert_eq!(users().await, 1);
// Instance administrators and workspace leads come from their groups.
let response = h
.attempt(Scenario::new(claims("u-2", "root", &["/meguri/admins"])))
.await;
let admin_token = h.signed_in(&response).await;
assert_eq!(
h.session(&admin_token).await["user"]["instance_admin"],
true
);
let overview: Value = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(overview["workspaces"].as_array().unwrap().len(), 2);
assert_eq!(overview["config"]["oidc_issuer"], idp_issuer(&h));
// Groups can come from userinfo when the ID token does not carry them.
let mut scenario =
Scenario::new(json!({"sub":"u-3","preferred_username":"lena","email":"lena@corp.test"}));
scenario.userinfo = Some(json!({"sub":"u-3","groups":["/search/leads"]}));
let response = h.attempt(scenario).await;
let lena = h.signed_in(&response).await;
assert_eq!(h.session(&lena).await["user"]["username"], "lena");
assert_eq!(h.idp.state.lock().unwrap().userinfo_requests, 1);
// Unmapped users and a provider that sends no groups get the same explanation and no account.
let before = users().await;
let reference = h.refused(
&h.attempt(Scenario::new(claims("u-4", "stranger", &["/unknown"])))
.await,
"no_access",
);
assert_eq!(reference.len(), 8);
h.refused(
&h.attempt(Scenario::new(
json!({"sub":"u-5","preferred_username":"nogroups"}),
))
.await,
"no_access",
);
assert_eq!(users().await, before);
// Everything that can be wrong with a token or callback is refused without a session.
let mut wrong_issuer = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_issuer.issuer = Some("https://evil.example/realms/corp".into());
let mut wrong_audience = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_audience.audience = Some(json!("another-client"));
let mut expired = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
expired.expires_in = -600;
let mut wrong_nonce = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_nonce.nonce = Some("replayed-nonce".into());
let mut bad_signature = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
bad_signature.corrupt_signature = true;
for scenario in [
wrong_issuer,
wrong_audience,
expired,
wrong_nonce,
bad_signature,
] {
h.refused(&h.attempt(scenario).await, "failed");
}
let mut broken = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
broken.token_status = 500;
h.refused(&h.attempt(broken).await, "failed");
assert!(
!query!(ExternalIdentityRow,$subject=="u-6".to_owned())
.exists(&iso.store.db)
.await
.unwrap()
);
// Flow protections: state cookie binding, one-time use, missing and foreign state.
let good = || Scenario::new(claims("u-7", "dave", &["/payments/team"]));
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(
&format!("code={code}&state={state}"),
Some("0".repeat(64).as_str()),
)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), None)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.signed_in(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
)
.await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
"failed",
);
h.refused(
&h.callback("error=access_denied&state=x", Some("x")).await,
"failed",
);
h.refused(&h.callback("", None).await, "failed");
// A user whose groups were revoked is refused at the next sign-in and loses access at once.
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/search/team"])))
.await;
h.signed_in(&response).await;
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/unknown"])))
.await;
h.refused(&response, "no_access");
let row = query!(UserRow,$username=="carol".to_owned())
.get(&iso.store.db)
.await
.unwrap()
.unwrap();
let id = row.id;
assert!(
!query!(meguri::models::WorkspaceMemberRow,$user_id==id)
.exists(&iso.store.db)
.await
.unwrap()
);
drop(h);
server.close().await;
iso.drop().await;
}
fn idp_issuer(h: &Harness) -> String {
h.idp.issuer()
}
trait Count {
async fn objects_count(iso: &IsolatedDatabase) -> usize;
}
impl Count for UserRow {
async fn objects_count(iso: &IsolatedDatabase) -> usize {
use cot::db::Model;
UserRow::objects().all(&iso.store.db).await.unwrap().len()
}
}
+65 -1
View File
@@ -2,14 +2,78 @@ const { chromium } = require('playwright');
function launchBrowser() {
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
return chromium.launch({
headless: true,
...(channel === 'chromium' ? {} : { channel }),
...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
});
}
exports.launchBrowser = launchBrowser;
const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
exports.baseUrl = baseUrl;
// The demo generates one password per server start and publishes it to the login screen.
let password;
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(session => {
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
return session.demo_lifecycle.password;
});
exports.signIn = async (page, username, secret) => {
await page.goto(baseUrl());
await page.getByLabel('Username', { exact: true }).fill(username);
await page.getByLabel('Password', { exact: true }).fill(secret ?? await exports.demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForFunction(() => { const a = window.Alpine && Alpine.$data(document.body); return a && a.ready && a.user && !a.busy; });
};
// Minimal cookie-aware JSON client for test setup that must not go through the UI.
exports.apiLogin = async (username, secret) => {
const base = baseUrl();
const login = await fetch(base + '/api/login', { method: 'POST', headers: { Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: secret ?? await exports.demoPassword() }) });
if (!login.ok) throw new Error(`Login as ${username} failed: ${login.status}`);
const cookie = login.headers.getSetCookie().map(c => c.split(';')[0]).find(c => c.startsWith('meguri_session='));
const { csrf } = await login.json();
const call = async (method, path, body) => {
const response = await fetch(base + path, { method, headers: { Origin: base, 'Content-Type': 'application/json', Cookie: cookie, 'X-CSRF-Token': csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await response.json().catch(() => ({}));
return { status: response.status, data };
};
const ok = async (method, path, body) => {
const result = await call(method, path, body);
if (result.status >= 300) throw new Error(`${method} ${path} -> ${result.status} ${JSON.stringify(result.data)}`);
return result.data;
};
return { call, ok, get: path => ok('GET', path), post: (path, body) => ok('POST', path, body), put: (path, body) => ok('PUT', path, body), csrf, cookie };
};
exports.adminApi = () => exports.apiLogin('admin');
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display }) => {
const overview = await admin.get('/api/admin/overview');
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
const secret = 'local-browser-test-password';
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups });
return { username, password: secret, display, workspace: target };
};
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
exports.isolatedUser = async ({ sandbox, prefix, display, employees = [] }) => {
const admin = await exports.adminApi();
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display });
const staff = [];
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
return { admin, ...user, staff };
};
exports.openSetup = async (page, name) => {
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
+293
View File
@@ -0,0 +1,293 @@
//! A minimal OpenID provider for tests: discovery, JWKS, token and userinfo endpoints with
//! RS256 tokens. The private key is a throwaway test fixture and protects nothing.
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDI4xpMobGZ91uK
+uV7YFtGg0KorieswaP4ZhIqg3LkxIjpaMggyw0Fc4jliFPxn+uZAzKEF5lBrrdt
XBllxKayC6dLEHFBvrglZMwyzsj6fgssne/N1YRWsQsQ48d2KANzfGpkBB8/29g7
YhpNK6lWZo54yGFcFa2g68/yqbpQ6dSo191/CSqOnC+R2pdMe9NzI8kQBtZPkcft
hmQ05IQJoyNHgBgwUQO2vB7J23haATlvQUrZlv0ASFYgxJDKeLZOPvdLGEmezIvc
WZYdI5qEqiNDr9KRWLS/sDYxxOQkVxRP4OoWy5svru1ptJ3QEG50uUnPZkdpX/Ny
UaMFOieJAgMBAAECgf8UyqFa5XWp/ODExbcM7nE2ydLmiAuRJgvXmHfdLY72qqne
ajvq17OcvVVs3g3B3wE5PaKWPCAdhJdEtUCCB/HXTO25hOTOYAhTrdLuh6cvIUxF
wqlJGVNM7tM3ZS8mW8b7maLjMxQtep605CxYxjU5c+wD54yuIMkl58Q3bLENuM5G
Y5GeXwmAv4yrJXuFIe/h2F6A+p4IEQ64QF6VQ/rDeG8kniLXWNZjAndqGxPdLDjC
4YdXpSWeYM/dTGKUyt0SwE64vxytMNyoRWAO4i4v3JJbrST0SmAM2RwM803Ks0Ys
H9VvFoYt5kGhETOz4OKa3m6HrMwdUy7vnUYd870CgYEA59CLNpjU3Dl57RJRlHRw
2FAPM0uM9qBWiahDyhQAeHeypUFGeu1yZHZCH9Z5oOo28xRyAB27R8ozFX0JdPYl
HBuJH63boff4/DuzjmnAK92889m+cbyPcAd6BjOo545cMRiAXvu3b8cz2Mlks1bj
uWS5RIVbQvuiPqNEbHNHc5UCgYEA3diG58Ziug75V6k8U9wHyqWWRyvQdwdJQmey
FAB0LQdSsHUkPteGl2U6Ml7EL2AuqoZK5kVRK+8aGhBHyOlsjwj/sCfQfiTNDgve
MP7pWfUUjctO8ZvKHG3SG87BG2kLAW6l/vuhDfLwQe/PV8hC0yDegFfF1R4nQVYf
7XFv5yUCgYBftygEHw6k78Z8MLgmh/2gE05za5qEqqeQpz12TtPy6dmFinJDh49Y
E8wxwSjvXEXcEHERwzT063Q0RzCEbq3b8TKpNewd8HmKDEyTXO7QLfo65YlRBP5k
z3F+5g2zroA6dVdr4JtZN/Mca+t8cUAN6Jzmwflwqt3O5zL1ZJP4EQKBgQCt50Sp
f9x4mbBgRWvjGQKKmo9ZL0F1bSLCiWMRRuHLQohRhyJomK8LEvEx9xO3R6CNUER6
ii/9TquZbtW26zSMU7OLtUo8+EA+GJOOuOTX/cSk+pDh2QvwiS5BWiwKfqd/8ery
HPK9aoloRsK0v2mS85s35JZSYBbJoufYTCo6eQKBgGW1vIlLzHRMeboj82uW0yw2
Jkg6Ig45qtVhY89267E8cI7gdC92Y715nFR4v+0eiWo08k+FIaHyBsah0vklj1Q6
fj6ir9inIzkkrFmjs6u/g4h2GNGUXOJJwJzOU78C6oeB3G5OUM/pWFx0zypRGH/q
HpM1TJ5Fkyog20WBQS9+
-----END PRIVATE KEY-----";
const MODULUS: &str = "yOMaTKGxmfdbivrle2BbRoNCqK4nrMGj-GYSKoNy5MSI6WjIIMsNBXOI5YhT8Z_rmQMyhBeZQa63bVwZZcSmsgunSxBxQb64JWTMMs7I-n4LLJ3vzdWEVrELEOPHdigDc3xqZAQfP9vYO2IaTSupVmaOeMhhXBWtoOvP8qm6UOnUqNfdfwkqjpwvkdqXTHvTcyPJEAbWT5HH7YZkNOSECaMjR4AYMFEDtrweydt4WgE5b0FK2Zb9AEhWIMSQyni2Tj73SxhJnsyL3FmWHSOahKojQ6_SkVi0v7A2McTkJFcUT-DqFsubL67tabSd0BBudLlJz2ZHaV_zclGjBToniQ";
pub const CLIENT_ID: &str = "meguri-test";
pub const CLIENT_SECRET: &str = "test-client-secret";
/// What a single sign-in attempt looks like to the relying party.
#[derive(Clone)]
pub struct Scenario {
pub claims: Value,
pub userinfo: Option<Value>,
pub issuer: Option<String>,
pub audience: Option<Value>,
pub expires_in: i64,
pub nonce: Option<String>,
pub corrupt_signature: bool,
pub token_status: u16,
}
impl Scenario {
pub fn new(claims: Value) -> Self {
Self {
claims,
userinfo: None,
issuer: None,
audience: None,
expires_in: 300,
nonce: None,
corrupt_signature: false,
token_status: 200,
}
}
}
struct Grant {
scenario: Scenario,
nonce: String,
challenge: String,
}
#[derive(Default)]
pub struct State {
codes: HashMap<String, Grant>,
tokens: HashMap<String, Value>,
pub token_requests: usize,
pub userinfo_requests: usize,
}
pub struct Server {
pub base: String,
pub state: Arc<Mutex<State>>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
impl Server {
pub fn issuer(&self) -> String {
format!("{}/realms/corp", self.base)
}
/// Registers an authorization the way a browser visit to the provider would.
pub fn authorize(&self, authorization_url: &str, scenario: Scenario) -> String {
let url = reqwest::Url::parse(authorization_url).unwrap();
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["response_type"], "code");
assert_eq!(query["client_id"], CLIENT_ID);
assert_eq!(query["code_challenge_method"], "S256");
assert!(query["scope"].split(' ').any(|s| s == "openid"));
let code = format!("code-{}", uuid::Uuid::new_v4().simple());
self.state.lock().unwrap().codes.insert(
code.clone(),
Grant {
scenario,
nonce: query["nonce"].clone(),
challenge: query["code_challenge"].clone(),
},
);
code
}
pub async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let state = Arc::new(Mutex::new(State::default()));
let (shared, root) = (state.clone(), base.clone());
let task = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let (shared, root) = (shared.clone(), root.clone());
tokio::spawn(async move {
let mut buffer = vec![];
let (head_end, length) = loop {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buffer[..end]);
let length = head
.lines()
.find_map(|l| {
l.to_lowercase()
.strip_prefix("content-length: ")
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(0);
break (end + 4, length);
}
};
while buffer.len() < head_end + length {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&buffer[..head_end]).to_string();
let body =
String::from_utf8_lossy(&buffer[head_end..head_end + length]).to_string();
let mut first = head.lines().next().unwrap().split_whitespace();
let method = first.next().unwrap().to_owned();
let path = first.next().unwrap().to_owned();
let (status, result) = handle(
&mut shared.lock().unwrap(),
&root,
&method,
&path,
&head,
&body,
);
let body = result.to_string();
let response = format!(
"HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
});
}
});
Self { base, state, task }
}
}
fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> {
head.lines().find_map(|l| {
let (key, value) = l.split_once(':')?;
key.eq_ignore_ascii_case(name).then(|| value.trim())
})
}
fn sign(claims: &Value, corrupt: bool) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key".into());
let mut token = jsonwebtoken::encode(
&header,
claims,
&EncodingKey::from_rsa_pem(PRIVATE_KEY.as_bytes()).unwrap(),
)
.unwrap();
if corrupt {
let last = token.pop().unwrap();
token.push(if last == 'A' { 'B' } else { 'A' });
}
token
}
fn handle(
s: &mut State,
root: &str,
method: &str,
path: &str,
head: &str,
body: &str,
) -> (u16, Value) {
let issuer = format!("{root}/realms/corp");
let path = path.split('?').next().unwrap();
match (method, path) {
("GET", "/realms/corp/.well-known/openid-configuration") => (
200,
json!({
"issuer":issuer,
"authorization_endpoint":format!("{issuer}/protocol/openid-connect/auth"),
"token_endpoint":format!("{issuer}/protocol/openid-connect/token"),
"jwks_uri":format!("{issuer}/protocol/openid-connect/certs"),
"userinfo_endpoint":format!("{issuer}/protocol/openid-connect/userinfo"),
}),
),
("GET", "/realms/corp/protocol/openid-connect/certs") => (
200,
json!({"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":MODULUS,"e":"AQAB"}]}),
),
("POST", "/realms/corp/protocol/openid-connect/token") => {
s.token_requests += 1;
let expected = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{CLIENT_ID}:{CLIENT_SECRET}"))
);
if header(head, "authorization") != Some(expected.as_str()) {
return (401, json!({"error":"invalid_client"}));
}
let form: HashMap<_, _> = reqwest::Url::parse(&format!("http://x/?{body}"))
.unwrap()
.query_pairs()
.into_owned()
.collect();
let Some(grant) = form.get("code").and_then(|c| s.codes.remove(c)) else {
return (400, json!({"error":"invalid_grant"}));
};
let verifier = form.get("code_verifier").cloned().unwrap_or_default();
if form.get("grant_type").map(String::as_str) != Some("authorization_code")
|| URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) != grant.challenge
|| !form
.get("redirect_uri")
.is_some_and(|u| u.ends_with("/api/oidc/callback"))
{
return (400, json!({"error":"invalid_grant"}));
}
let scenario = grant.scenario;
if scenario.token_status != 200 {
return (scenario.token_status, json!({"error":"server_error"}));
}
let now = chrono::Utc::now().timestamp();
let mut claims = scenario.claims.clone();
claims["iss"] = json!(scenario.issuer.unwrap_or(issuer));
claims["aud"] = scenario.audience.unwrap_or(json!(CLIENT_ID));
claims["iat"] = json!(now);
claims["exp"] = json!(now + scenario.expires_in);
claims["nonce"] = json!(scenario.nonce.unwrap_or(grant.nonce));
let access = format!("access-{}", uuid::Uuid::new_v4().simple());
if let Some(info) = scenario.userinfo {
s.tokens.insert(access.clone(), info);
}
(
200,
json!({"id_token":sign(&claims, scenario.corrupt_signature),"access_token":access,"token_type":"Bearer"}),
)
}
("GET", "/realms/corp/protocol/openid-connect/userinfo") => {
s.userinfo_requests += 1;
let token = header(head, "authorization")
.and_then(|h| h.strip_prefix("Bearer "))
.unwrap_or_default();
match s.tokens.get(token) {
Some(info) => (200, info.clone()),
None => (401, json!({"error":"invalid_token"})),
}
}
_ => (404, json!({})),
}
}
+139 -42
View File
@@ -1,7 +1,11 @@
#![allow(dead_code)]
use chrono::{Duration, Utc};
use cot::db::Model;
use cot::db::query;
use meguri::{
access::{AuthConfig, Config, WorkspaceConfig},
admin::{self, NewUser},
domain::*,
identity,
models::UserRow,
service,
store::{Store, WorkspaceRecord},
@@ -14,54 +18,83 @@ pub struct Fixture {
pub team: WorkspaceRecord,
pub schedule: Uuid,
pub start: chrono::DateTime<Utc>,
pub config: Config,
}
pub fn config_with(workspaces: Vec<WorkspaceConfig>) -> Config {
Config {
auth: AuthConfig {
password_login: true,
bootstrap_admin: None,
},
oidc: None,
instance_admin_groups: vec![],
workspaces,
}
}
pub fn unique_workspace(name: &str) -> WorkspaceConfig {
let key = format!("t-{}", &Uuid::new_v4().simple().to_string()[..12]);
WorkspaceConfig {
admin_groups: vec![format!("{key}-leads")],
member_groups: vec![format!("{key}-staff")],
name: name.into(),
key,
}
}
pub async fn connect() -> Store {
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
Store::connect(&url).await.unwrap()
}
/// A password account whose groups are applied through the real access path.
pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String]) -> User {
let created = admin::create_password_user(
db,
config,
None,
NewUser {
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
password: "test-only-password".into(),
email: None,
groups: groups.to_vec(),
},
)
.await
.unwrap();
serde_json::from_value(created["user"].clone()).unwrap()
}
impl Fixture {
pub async fn new() -> Self {
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let db = Store::connect(&url).await.unwrap();
let db = connect().await;
let workspace = unique_workspace("Test team");
let config = config_with(vec![workspace.clone()]);
let ensured = identity::ensure_workspace(&db, &workspace).await.unwrap();
let mut people = vec![];
for name in ["Alice", "Bob", "Carol"] {
let user = User {
language: None,
id: Uuid::new_v4(),
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
country: "CY".into(),
timezone: None,
for (index, name) in ["Alice", "Bob", "Carol"].into_iter().enumerate() {
let groups = if index == 0 {
&workspace.admin_groups
} else {
&workspace.member_groups
};
UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
timezone: None,
password_hash: "test-only-unusable-password".into(),
created_at: Utc::now(),
}
.insert(&db.db)
.await
.unwrap();
people.push(user);
people.push(account(&db, &config, name, groups).await);
}
let people: [User; 3] = people.try_into().unwrap();
let mut team = service::create_workspace(&db, people[0].id, "Test team".into())
.await
.unwrap();
for person in &people[1..] {
team = service::set_member(
&db,
people[0].id,
team.id,
team.revision,
person.username.clone(),
Permission::member(),
)
.await
.unwrap();
}
let mut team = db.workspace(ensured.id).await.unwrap();
// Employees keep the narrow pre-existing grant set that the scheduling regressions assume.
team = service::set_member_permissions(
&db,
people[0].id,
team.id,
team.revision,
Permission::member(),
)
.await
.unwrap();
let schedule = Uuid::new_v4();
let day = Utc::now().date_naive() + Duration::days(2);
let mut doc = team.document.clone();
@@ -86,8 +119,28 @@ impl Fixture {
team,
schedule,
start,
config,
}
}
/// A second workspace whose administrator group is granted to `admin` only.
pub async fn extra_workspace(&self, name: &str, admin: &User) -> WorkspaceRecord {
let extra = unique_workspace(name);
let mut config = self.config.clone();
config.workspaces.push(extra.clone());
let ensured = identity::ensure_workspace(&self.db, &extra).await.unwrap();
let id = admin.id.to_string();
let row = query!(UserRow,$id==id)
.get(&self.db.db)
.await
.unwrap()
.unwrap();
let mut groups = identity::stored_groups(&row).unwrap();
groups.extend(extra.admin_groups);
identity::apply_access(&self.db, &config, admin.id, Some(groups))
.await
.unwrap();
self.db.workspace(ensured.id).await.unwrap()
}
pub fn leg(&self) -> ReplacementLeg {
ReplacementLeg {
schedule_id: self.schedule,
@@ -133,3 +186,47 @@ impl Fixture {
.unwrap();
}
}
/// Demonstration seeding reads the run's generated password from the process-wide notice.
pub fn install_demo_notice() {
meguri::demo_runtime::install(meguri::demo_runtime::Notice {
isolated: false,
retention: meguri::demo_runtime::Retention::default(),
password: meguri::access::random_password(),
accounts: meguri::demo::accounts(),
});
}
/// A uniquely named database for tests whose subject is whole-instance state (configuration
/// sync disables every workspace that is not configured, so shared databases cannot be used).
pub struct IsolatedDatabase {
pub store: Store,
admin: cot::db::Database,
name: String,
}
impl IsolatedDatabase {
pub async fn new() -> Self {
let base = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let admin = cot::db::Database::new(&base).await.unwrap();
let name = format!("meguri_access_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&base).unwrap();
url.set_path(&format!("/{name}"));
let store = Store::connect(url.as_str()).await.unwrap();
Self { store, admin, name }
}
pub async fn url(&self) -> String {
let base = std::env::var("MEGURI_TEST_DATABASE_URL").unwrap();
let mut url = reqwest::Url::parse(&base).unwrap();
url.set_path(&format!("/{}", self.name));
url.to_string()
}
pub async fn drop(self) {
drop(self.store);
self.admin
.raw(&format!("DROP DATABASE {} WITH (FORCE)", self.name))
.await
.unwrap();
}
}