Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
+1267
File diff suppressed because it is too large.
Load diff
@@ -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});
|
||||
|
||||
@@ -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,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 @@
|
||||
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;});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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,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,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,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,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;});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
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 @@
|
||||
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,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,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,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
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user