From 1e644e6dfb900920a339ef72e7ce8106fac9f88d Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Sun, 20 Sep 2026 13:55:56 +0300 Subject: [PATCH] Fix employee sync and expose mobile profile/logout (#37) Keep notification-read state personal to user/company, preserve server read-only sections during staff synchronization, and respect separate order-create/edit/delete rights. Add visible mobile header session actions and a sticky logout that survives profile RPC failure; scope asynchronous profile/branding to user and workspace. Targeted browser suites and isolated SQL recovery tests passed in run 35506145405, iPhone screenshots reviewed. Full main QA remains required before production promotion. Workspace branding RPC migration is included but has NOT been applied to production Supabase; older servers retain safe owner-only fallback. No live membership/business-data repair is claimed without identifying the reported employee. --- .github/workflows/deploy-timeweb.yml | 6 + docs/releases/2026-09-20-EMPLOYEE-SESSION.md | 46 +++++++ public/app-runtime.js | 42 ++++++- public/core/account-center-v1780.js | 54 ++++++-- public/core/company-branding.js | 16 ++- public/core/sun-safe.js | 9 +- public/index.html | 12 +- public/service-worker.js | 2 +- ...20260920104500_workspace_sidebar_brand.sql | 34 +++++ tests/employee-session.spec.mjs | 117 ++++++++++++++++++ tests/playwright.config.mjs | 4 +- tests/production-employee-session.mjs | 41 ++++++ tests/recovery/employee-session.mjs | 20 +++ tests/recovery/smoke.mjs | 1 + 14 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 docs/releases/2026-09-20-EMPLOYEE-SESSION.md create mode 100644 supabase/migrations/20260920104500_workspace_sidebar_brand.sql create mode 100644 tests/employee-session.spec.mjs create mode 100644 tests/production-employee-session.mjs create mode 100644 tests/recovery/employee-session.mjs diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml index 118c2df..8a8221a 100644 --- a/.github/workflows/deploy-timeweb.yml +++ b/.github/workflows/deploy-timeweb.yml @@ -43,6 +43,9 @@ jobs: files=( index.html app-runtime.js + core/account-center-v1780.js + core/company-branding.js + core/sun-safe.js core/login-signature-v1776.js core/login-signature-v1776.css core/help-center.js @@ -124,6 +127,9 @@ jobs: - name: Check the published single-page banquet menu timeout-minutes: 4 run: node tests/production-banquet-client-menu.mjs + - name: Check published employee session and mobile logout + timeout-minutes: 4 + run: node tests/production-employee-session.mjs - name: Save production UI verification if: always() uses: actions/upload-artifact@v4 diff --git a/docs/releases/2026-09-20-EMPLOYEE-SESSION.md b/docs/releases/2026-09-20-EMPLOYEE-SESSION.md new file mode 100644 index 0000000..94927ac --- /dev/null +++ b/docs/releases/2026-09-20-EMPLOYEE-SESSION.md @@ -0,0 +1,46 @@ +# Company employee sessions and mobile logout + +## Application changes + +Notification read flags now use a device-local key scoped to the authenticated +user and selected company. They do not enter the company synchronization payload. +Older remote `sunReadNotificationsV1` values are preserved, not overwritten or +removed by an employee. Read-only sections are taken from the remote snapshot +instead of uploading bootstrap defaults or empty caches as staff changes. Order +reconciliation respects the existing create/edit/delete permissions separately. +The server still enforces all permissions; no role or membership is granted. + +On mobile, the main header contains labelled Profile and Logout buttons with SVG +icons. Profile also has a sticky top Logout button, including while its detail RPC +is pending or unavailable. Logout uses the existing bounded local-device sign-out +and tenant-cache preservation. Late account/company replies cannot restore stale +profile or sidebar information. + +## Database change — must be deployed separately + +Apply `supabase/migrations/20260920104500_workspace_sidebar_brand.sql` to the +Caterium database. It adds a membership-checked, selected-workspace sidebar RPC. +An exclusive branding assignment is associated with its owner's company only +when that company is unambiguous. Multiple owned companies need an explicit +assignment by an authorized database administrator, never a guessed name match. +Active confirmed employees of the assigned company receive the Solnce sidebar. +The old owner-only RPC remains compatible with older clients. + +Publishing application assets does NOT apply SQL migrations. Until the database +migration is applied, the client safely falls back to the old owner-only RPC; +this release must not be reported as fixing employee branding on that server. +No company catalog, order, client, membership, or production account is changed +by the migration. The separate ai-staff project is outside this change. + +## Verification scope + +Browser scenarios use synthetic company accounts and do not access production +business data. They cover an empty employee device, pre-existing baselines, +allowed order edits with forbidden UI preferences, record-level permissions, +personal notification isolation, selected-workspace branding, old-server +compatibility, late replies, and visible mobile logout with unavailable details. +The recovery database suite checks the new RPC against actual SQL permissions. + +The reported employee's actual company membership is a separate diagnosis and +requires the exact new email and an authorized server/account read. Empty UI alone +is not evidence that company orders were deleted. diff --git a/public/app-runtime.js b/public/app-runtime.js index e84e4c8..8b16f70 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -2308,7 +2308,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; if (!key.startsWith('sun')) return false; if (key === CONFIG_KEY || key === CLIENT_ID_KEY) return false; if (key === 'sunUsersV1' || key === 'sunSessionUserV1') return false; - if (key === 'sunFallbackBackupsV1') return false; + if (key === 'sunFallbackBackupsV1' || key === 'sunReadNotificationsV1') return false; if (key === 'sunStaticMapGeocodeCacheV2') return false; if (key === 'sunCloudSyncMetaV1' || key === 'sunCloudSyncTokenV1' || key === 'sunCloudSyncWorkspaceV1') return false; if (key === 'sunOrdersView' || key === 'sunCalendarMode' || key === 'sunCatalogViewModeV1' || key === 'sunNewOrderSplitRatioV5') return false; @@ -2509,7 +2509,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; } function setSignOutUiBusy(busy) { - const selectors=['#sunCloudSignOutV2','#sunLogoutBtn','#sunGateSignOutV3','#sunCloudUsePasswordV3','[data-signout]','[data-saas-signout]']; + const selectors=['#cacLogout','#cacHeaderLogout','#cateriumMobileLogout','#sunCloudSignOutV2','#sunLogoutBtn','#sunGateSignOutV3','#sunCloudUsePasswordV3','[data-signout]','[data-saas-signout]']; document.querySelectorAll(selectors.join(',')).forEach(btn=>{ if(!(btn instanceof HTMLButtonElement))return; if(busy){btn.dataset.sunOldText=btn.textContent||'';btn.disabled=true;btn.textContent='Выходим…';} @@ -2641,6 +2641,22 @@ window.SUN_LEGACY_CATALOG_V175=[]; return clone(local); } + // Match the server's section permissions without expanding a user's rights. + // Bootstrap/default UI writes in a read-only section cannot block a valid order + // save or turn an omitted company setting into a deletion. + function canWriteStorageKey(key) { + if(key==='sunOrders')return ['orders.create','orders.edit','orders.delete'].some(hasPermission); + if(key==='sunAuditLogV1')return Boolean(workspace&&workspace.is_active!==false); + const permissions={sunBoxes:'catalog.edit',sunOfficialCatalogVersion:'catalog.edit', + sunClientLoyaltyV1:'clients.edit',sunClientCommunicationV1:'clients.edit', + sunFinanceRecordsV2:'money.edit',sunStock:'stock.edit',sunStockMoves:'stock.edit', + sunEmployees:'team.edit',sunSuppliers:'suppliers.edit',sunPromoCodesV1:'mailings.edit'}; + return hasPermission(permissions[key]||(key.startsWith('sunRoute')?'routes.edit':key.startsWith('sunMarketing')?'mailings.edit':'settings.edit')); + } + function equalSyncPayload(a,b){ + const filtered=p=>({...p,storage:Object.fromEntries(Object.entries(p?.storage||{}).filter(([key])=>shouldSyncKey(key)))}); + return equal(filtered(a),filtered(b)); + } function mergePayloads(basePayload, localPayload, remotePayload) { const conflicts = []; const base = basePayload?.storage || {}; @@ -2650,6 +2666,12 @@ window.SUN_LEGACY_CATALOG_V175=[]; const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]); for (const key of keys) { const b = base[key], l = local[key], r = remote[key]; + if(!shouldSyncKey(key)||(workspace&&!canWriteStorageKey(key))){ + // Keep legacy remote-only preferences byte-for-byte, never upload local + // notification flags and never ask staff to delete an owner's settings. + if(r!==undefined)merged[key]=clone(r); + continue; + } if (l === undefined && r === undefined) continue; if (l === undefined) { if (b !== undefined && !equal(r,b)) conflicts.push(key); @@ -2663,6 +2685,16 @@ window.SUN_LEGACY_CATALOG_V175=[]; } merged[key] = mergeNode(b, l, r, conflicts, key); } + if(workspace&&(remote.sunOrders||merged.sunOrders)){ + const existing=remote.sunOrders?.v,proposed=merged.sunOrders?.v; + if(idArray(existing)&&(!proposed||idArray(proposed))){ + const byId=new Map(existing.map(o=>[String(o.id),o])); + const rows=(proposed||[]).filter(o=>byId.has(String(o.id))||hasPermission('orders.create')) + .map(o=>byId.has(String(o.id))&&!hasPermission('orders.edit')?clone(byId.get(String(o.id))):o); + if(!hasPermission('orders.delete')){const ids=new Set(rows.map(o=>String(o.id)));for(const o of existing)if(!ids.has(String(o.id)))rows.push(clone(o));} + merged.sunOrders={...(merged.sunOrders||remote.sunOrders),v:rows}; + } + } return {payload:{format:'sun-cloud-v2',version:2,storage:merged}, conflicts:[...new Set(conflicts)]}; } @@ -2955,7 +2987,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; if(!current())return; if (!remote || payloadEmpty(remote.payload)) { if (!canWrite()) throw new Error('В облаке нет базы, а у пользователя нет прав на её создание.'); - const row = await upsertPayload(localPayload, remote?.revision ?? null); + const row = await upsertPayload(mergePayloads(null,localPayload,remote?.payload).payload, remote?.revision ?? null); if(!current())return; await setBaseline(row); if(!current())return; dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return; @@ -2970,8 +3002,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; const merged = mergePayloads(baseline.payload, localPayload, remote.payload); let finalRow = remote; - const changedVsRemote = !equal(merged.payload, remote.payload); - const changedVsLocal = !equal(merged.payload, localPayload); + const changedVsRemote = !equalSyncPayload(merged.payload, remote.payload); + const changedVsLocal = !equalSyncPayload(merged.payload, localPayload); if (changedVsRemote) { if (!canWrite()) { diff --git a/public/core/account-center-v1780.js b/public/core/account-center-v1780.js index 070e41e..40ba540 100644 --- a/public/core/account-center-v1780.js +++ b/public/core/account-center-v1780.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; if(window.CateriumAccountCenterV1780)return; -const VERSION='17.8.0-account-center-v3'; +const VERSION='17.8.0-employee-session-20260920'; const $=(s,r=document)=>r.querySelector(s); const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??''); @@ -14,6 +14,10 @@ const ROLE_LABELS={admin:'Владелец',manager:'Менеджер',kitchen:' let snapshot=null; let modal=null; let lastUserId=''; +let snapshotScope='',loadSequence=0,openSequence=0; +const accountScope=()=>JSON.stringify([session()?.user?.id||'',workspace()?.id||'']); +const profileIcon=''; +const logoutIcon=''; function installStyle(){ if($('#caterium-account-center-style'))return; @@ -21,8 +25,9 @@ function installStyle(){ #cateriumAccountCenter{position:fixed;inset:0;z-index:21000;display:none;place-items:center;padding:22px;background:#102c3d99;backdrop-filter:blur(5px)} #cateriumAccountCenter.on{display:grid} #cateriumAccountCenter .cac-shell{width:min(980px,100%);max-height:min(860px,92vh);overflow:auto;background:#f6f2ea;border-radius:22px;box-shadow:0 28px 90px #0004;color:#2f2b25} - #cateriumAccountCenter .cac-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:28px 30px 20px;border-bottom:1px solid #d9d1c5} + #cateriumAccountCenter .cac-head{position:sticky;top:0;z-index:2;background:#f6f2ea;display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:28px 30px 20px;border-bottom:1px solid #d9d1c5} #cateriumAccountCenter h2{margin:0;font:500 38px/1.04 Georgia,'Times New Roman',serif;letter-spacing:-.03em;color:#27231f} + #cateriumAccountCenter .cac-head-actions{display:flex;align-items:center;gap:12px;flex-shrink:0} #cateriumAccountCenter .cac-sub{margin-top:7px;color:#8a8379;font-size:14px} #cateriumAccountCenter .cac-close{border:0;background:transparent;font-size:31px;color:#5c564d;padding:0 4px} #cateriumAccountCenter .cac-body{padding:24px 30px 30px} @@ -45,31 +50,44 @@ function installStyle(){ #cateriumAccountButton{display:flex!important} #sun-cloud-users-modal [data-caterium-owner-row="1"]{background:#fff9e8;border:1px solid #ead39a;border-radius:10px;padding:10px} #sun-cloud-users-modal .caterium-owner-chip{display:inline-flex;align-items:center;border-radius:999px;background:#f4dfad;color:#6d5118;padding:5px 9px;font-size:11px;font-weight:900} + #cateriumMobileAccountActions{display:none} + #cateriumMobileAccountActions button svg,#cateriumAccountButton svg{width:20px;height:20px;flex-shrink:0} + @media(max-width:900px){ + body.sun-enterprise-sidebar header #cateriumMobileAccountActions{display:flex!important;width:100%;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:6px 0 0} + #cateriumMobileAccountActions button{display:flex!important;align-items:center;justify-content:center;gap:7px;min-height:44px;border:1px solid #b8b4a9;border-radius:10px;padding:8px 12px;background:#faf7ef;color:#333c30;font:700 13px Arial;touch-action:manipulation} + #cateriumAccountCenter h2{font-size:27px} + #cateriumAccountCenter .cac-head-actions{gap:8px} + #cacHeaderLogout{min-height:44px;font-size:12px;padding:8px!important;white-space:nowrap} + } + @media print{#cateriumMobileAccountActions,#cateriumAccountCenter{display:none!important}} @media(max-width:720px){#cateriumAccountCenter{padding:0;place-items:stretch}#cateriumAccountCenter .cac-shell{width:100%;max-height:none;height:100dvh;border-radius:0}#cateriumAccountCenter .cac-grid{grid-template-columns:1fr}#cateriumAccountCenter .cac-card.wide{grid-column:auto}#cateriumAccountCenter .cac-head,#cateriumAccountCenter .cac-body{padding-left:18px;padding-right:18px}} `;document.head.appendChild(s); } function ensureModal(){ if(modal)return modal; - modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='

Мой аккаунт

Профиль и безопасность Caterium
'; + modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='

Мой аккаунт

Профиль и безопасность Caterium
'; document.body.appendChild(modal); + $('#cacHeaderLogout',modal).onclick=logout; modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('.cac-close'))close();}); return modal; } async function loadSnapshot(){ const c=client(),ws=workspace();if(!c||!ws?.id)return null; + const scope=accountScope(),request=++loadSequence; const {data,error}=await c.rpc('caterium_account_snapshot',{p_workspace:ws.id}); - if(error)throw error;snapshot=data||null;return snapshot; + if(scope!==accountScope()||request!==loadSequence)return null; + if(error)throw error;snapshot=data||null;snapshotScope=scope;return snapshot; } function render(){ const body=$('#cateriumAccountCenterBody');if(!body)return; - const ss=session(),s=snapshot||{},owner=Boolean(s.is_owner); + const ss=session(),ws=workspace(),s=snapshotScope===accountScope()?(snapshot||{}):{},owner=Boolean(s.is_owner); const companyNo=s.company_number?`№${s.company_number}`:'—'; body.innerHTML=`

Профиль

${owner?'
Главный аккаунт компанииТолько этот аккаунт создаёт сотрудников и распределяет права.
':''} -
Компания${esc(s.company_name||'—')} · ${esc(companyNo)}
Статус${esc(owner?'Владелец':ROLE_LABELS[s.role]||s.role||'Сотрудник')}
+
Компания${esc(s.company_name||ws?.name||'—')} · ${esc(companyNo)}
Статус${esc(owner?'Владелец':ROLE_LABELS[s.role||ws?.role]||s.role||ws?.role||'Сотрудник')}
@@ -111,12 +129,15 @@ async function savePassword(){ try{const {error}=await c.auth.updateUser({password:p});if(error)throw error;$('#cacPassword').value='';$('#cacPassword2').value='';toast('Пароль изменён.','success')} catch(e){toast(e?.message||String(e),'error',6500)}finally{btn.disabled=false} } -async function logout(){try{await cloud()?.signOut?.()}catch(_){try{await client()?.auth.signOut()}catch(__){}location.reload()}} +async function logout(){if(typeof cloud()?.signOut==='function')return cloud().signOut();try{await client()?.auth.signOut({scope:'local'})}finally{location.reload()}} async function open(){ - ensureModal();modal.classList.add('on');const body=$('#cateriumAccountCenterBody');body.innerHTML='

Загружаю аккаунт…

'; - try{await loadSnapshot();render()}catch(e){body.innerHTML=`

${esc(e?.message||e||'Не удалось загрузить аккаунт.')}

`} + ensureModal();const request=++openSequence,scope=accountScope();modal.classList.add('on');render(); + const body=$('#cateriumAccountCenterBody');body.setAttribute('aria-busy','true'); + try{await loadSnapshot();if(request===openSequence&&scope===accountScope())render()} + catch(e){if(request===openSequence&&scope===accountScope()){render();const note=document.createElement('p');note.className='cac-note';note.textContent='Не удалось обновить данные профиля. Выход из аккаунта доступен.';body.prepend(note)}} + finally{if(request===openSequence)body.removeAttribute('aria-busy')} } -function close(){modal?.classList.remove('on')} +function close(){openSequence++;modal?.classList.remove('on')} function sanitizeRbac(){ const body=$('#sunRbacBody');if(!body)return; qa('select option[value="admin"]',body).forEach(o=>o.remove()); @@ -133,18 +154,25 @@ function ensureSidebarEntry(){ if(!button){ button=document.createElement('button'); button.id='cateriumAccountButton';button.type='button';button.className='sun-side-action';button.title='Профиль, email и пароль'; - button.innerHTML='Мой аккаунт'; + button.innerHTML=profileIcon+'Мой аккаунт'; footer.insertBefore(button,footer.querySelector('#sunLogoutBtn')||footer.firstChild); } if(!button.dataset.cateriumAccountBound){button.dataset.cateriumAccountBound='1';button.addEventListener('click',open)} } +function ensureMobileEntry(){ + const header=$('body > header');if(!header)return; + let bar=$('#cateriumMobileAccountActions'); + if(!session()?.user){bar?.remove();return} + if(!bar){bar=document.createElement('div');bar.id='cateriumMobileAccountActions';bar.innerHTML='';header.appendChild(bar);$('#cateriumMobileAccountButton',bar).onclick=open;$('#cateriumMobileLogout',bar).onclick=logout;} +} function bindEntry(){ const label=$('#sunCurrentUserLabel');if(label&&!label.dataset.cateriumAccountBound){label.dataset.cateriumAccountBound='1';label.title='Открыть личный кабинет';label.addEventListener('click',open)} - const ss=session();const uid=ss?.user?.id||'';if(uid&&uid!==lastUserId){lastUserId=uid;snapshot=null;} - ensureSidebarEntry();sanitizeRbac(); + const uid=accountScope();if(uid!==lastUserId){lastUserId=uid;snapshot=null;snapshotScope='';loadSequence++;close();} + ensureSidebarEntry();ensureMobileEntry();sanitizeRbac(); } installStyle();ensureModal();bindEntry(); const obs=new MutationObserver(bindEntry);obs.observe(document.documentElement,{childList:true,subtree:true}); +window.addEventListener('sun:cloud-tenant-changing',()=>{snapshot=null;snapshotScope='';loadSequence++;close()}); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(bindEntry,0)); setInterval(()=>{if(!document.hidden)bindEntry()},3000); window.CateriumAccountCenterV1780=Object.freeze({VERSION,open,close,reload:async()=>{await loadSnapshot();render();return snapshot}}); diff --git a/public/core/company-branding.js b/public/core/company-branding.js index 6536b48..2cbbd79 100644 --- a/public/core/company-branding.js +++ b/public/core/company-branding.js @@ -60,6 +60,7 @@ let sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false}; let request=0,checkedUser='',pendingUser=''; + const sidebarScope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']); function sidebarHTML(){return `${esc(sidebar.name)}${esc(sidebar.name)}`;} function renderSidebar(){ const node=document.querySelector('body > header .brand');if(!node)return; @@ -70,14 +71,18 @@ async function refreshSidebar(){ const cloud=window.SunCloudV2,session=cloud?.getSession?.(),uid=session?.user?.id||''; if(!uid){resetSidebar();return;} - if(checkedUser===uid||pendingUser===uid){renderSidebar();return;} - resetSidebar();pendingUser=uid;const current=request; + const ws=cloud.getWorkspace?.()?.id||'',scope=sidebarScope(); + if(checkedUser===scope||pendingUser===scope){renderSidebar();return;} + resetSidebar();pendingUser=scope;const current=request; try{ - const result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand'); - if(current!==request||window.SunCloudV2?.getSession?.()?.user?.id!==uid)return; + // New RPC verifies active membership and the selected company. An older + // database keeps its owner-only branding until the additive migration runs. + let result=ws?await cloud.getClient()?.rpc('caterium_workspace_sidebar_brand',{p_workspace:ws}):null; + if(!ws||['PGRST202','42883'].includes(result?.error?.code))result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand'); + if(current!==request||sidebarScope()!==scope)return; if(result?.error||!result?.data)return; if(result.data.variant==='solnce')sidebar={name:'Солнце Кейтеринг',logo:'sun-logo.png',special:true}; - checkedUser=uid;renderSidebar(); + checkedUser=scope;renderSidebar(); }catch(_){/* Until identity is confirmed, show the shared product brand. */} finally{if(current===request)pendingUser='';} } @@ -90,6 +95,7 @@ nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()), escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,prepareLogoImage,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar}); for(const event of ['sun:cloud-state-applied','sun:cloud-permissions-changed','suncloudsync'])window.addEventListener(event,()=>{refreshSidebar();changed()}); + window.addEventListener('sun:cloud-tenant-changing',resetSidebar); window.addEventListener('storage',event=>{if([NAME_KEY,LOGO_KEY,'sunCompanyProfileV1'].includes(event.key))changed();if(/^sb-.*-auth-token$/.test(event.key||''))resetSidebar()}); if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); diff --git a/public/core/sun-safe.js b/public/core/sun-safe.js index 04d7d91..a819a23 100644 --- a/public/core/sun-safe.js +++ b/public/core/sun-safe.js @@ -4,6 +4,11 @@ '&':'&','<':'<','>':'>','"':'"',"'":''' })[ch]); const escapeAttr=escapeHTML; + // Personal UI preferences must never be included in a company payload. + const personalStorageKey=name=>{ + const cloud=window.SunCloudV2,user=cloud?.getSession?.()?.user?.id,ws=cloud?.getWorkspace?.()?.id; + return user&&ws?'caterium:personal:'+encodeURIComponent(user)+':'+encodeURIComponent(ws)+':'+encodeURIComponent(name):''; + }; const idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,''); const jsArg=value=>escapeHTML(JSON.stringify(String(value??''))); // Existing trial databases and saved offers still reference the PNG originals. @@ -29,14 +34,14 @@ if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node); return node; }; - window.SunSafe=Object.freeze({escapeHTML,escapeAttr,idToken,jsArg,safeImageSrc,imageAssetSrc,setText,insertBefore}); + window.SunSafe=Object.freeze({escapeHTML,escapeAttr,personalStorageKey,idToken,jsArg,safeImageSrc,imageAssetSrc,setText,insertBefore}); // Small bootstrap for account/profile UI. Keeping it here makes the account // center available on every Caterium screen without touching the legacy monolith. if(!document.getElementById('cateriumAccountCenterV1780Script')){ const script=document.createElement('script'); script.id='cateriumAccountCenterV1780Script'; - script.src='core/account-center-v1780.js?v=20260912-v17-8-0-account-center-2'; + script.src='core/account-center-v1780.js?v=20260920-employee-session'; script.async=true; document.head.appendChild(script); } diff --git a/public/index.html b/public/index.html index 03bfc3f..d65f319 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/service-worker.js b/public/service-worker.js index ded00b9..76bfd11 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session'; const VERSION='20260918-ui-stability'; const CORE=[ './core/banquet-client-menu.js?v=20260920-onepage', diff --git a/supabase/migrations/20260920104500_workspace_sidebar_brand.sql b/supabase/migrations/20260920104500_workspace_sidebar_brand.sql new file mode 100644 index 0000000..2ad4193 --- /dev/null +++ b/supabase/migrations/20260920104500_workspace_sidebar_brand.sql @@ -0,0 +1,34 @@ +begin; +-- Extend the exclusive identity to the selected company's active employees. +-- Keep the original zero-argument RPC for old installed clients. +alter table public.caterium_sidebar_brand_assignment + add column if not exists workspace_id uuid references public.sun_workspaces(id) on delete set null; + +-- Never guess between several companies owned by the assigned account. +update public.caterium_sidebar_brand_assignment a +set workspace_id=w.id +from public.sun_workspaces w +where a.workspace_id is null and w.created_by=a.user_id + and (select count(*) from public.sun_workspaces x where x.created_by=a.user_id)=1; + +create or replace function public.caterium_workspace_sidebar_brand(p_workspace uuid) +returns jsonb language plpgsql stable security definer +set search_path=public,pg_temp as $$ +declare variant text:='caterium'; +begin + if auth.uid() is null or public.sun_member_role(p_workspace) is null + or not exists(select 1 from auth.users where id=auth.uid() and email_confirmed_at is not null) + then raise exception 'Нет доступа к компании'; end if; + if exists( + select 1 from public.caterium_sidebar_brand_assignment a + join auth.users owner_account on owner_account.id=a.user_id and owner_account.email_confirmed_at is not null + join public.sun_workspaces w on w.id=p_workspace + where a.workspace_id=w.id or (a.workspace_id is null and w.created_by=a.user_id + and (select count(*) from public.sun_workspaces x where x.created_by=a.user_id)=1) + ) then variant:='solnce'; end if; + return jsonb_build_object('variant',variant,'workspace_id',p_workspace); +end $$; +revoke all on function public.caterium_workspace_sidebar_brand(uuid) from public,anon; +grant execute on function public.caterium_workspace_sidebar_brand(uuid) to authenticated; +notify pgrst,'reload schema'; +commit; diff --git a/tests/employee-session.spec.mjs b/tests/employee-session.spec.mjs new file mode 100644 index 0000000..a6d52ac --- /dev/null +++ b/tests/employee-session.spec.mjs @@ -0,0 +1,117 @@ +import {test,expect} from '@playwright/test'; +import fs from 'node:fs'; +import {trainingInit} from './training-catalog-fixture.mjs'; + +async function blank(page){ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'
'})); + await page.goto('/index.html'); +} +async function syncHarness(page){ + await blank(page);await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'}); + const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); + source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={ + testInit:(c,permissions,role='manager')=>{client=c;session={user:{id:'employee',email:'employee@example.invalid'}};workspace={id:'company',name:'Компания',role,permissions,is_active:true};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'}, + testBaseline:setBaseline,testMerge:mergePayloads,testShouldSync:shouldSyncKey,testApply:applyPayload,`); + await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); +} + +for(const hasBaseline of [false,true])test(`employee with an empty device pulls company catalog and orders without a write, baseline=${hasBaseline}`,async({page})=>{ + await syncHarness(page); + const result=await page.evaluate(async hasBaseline=>{ + const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:[{id:'real-box',name:'Рабочий бокс',price:1200}]},sunOrders:{t:'j',v:[{id:'real-order',event:'Рабочий заказ',total:2400}]},sunCompanyProfileV1:{t:'j',v:{name:'Рабочая компания'}},sunReadNotificationsV1:{t:'j',v:['owner-read']}}}; + const calls=[];window.toasts=[];window.SunEnterprise={toast:(s,t)=>toasts.push({s,t})}; + SunCloudV2.testInit({rpc:async(name,args)=>{calls.push(name);if(name==='sun_fetch_app_state')return {data:[{payload,revision:7}]};throw new Error('Unexpected write '+name)}},{'app.read':true,'orders.view':true,'orders.create':true,'orders.edit':true,'orders.delete':false,'catalog.view':true,'catalog.edit':false,'settings.edit':false}); + localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');localStorage.setItem('sunReadNotificationsV1','["employee-read"]');localStorage.setItem('sunPrintSettingsV1','{"localDefault":true}'); + if(hasBaseline)await SunCloudV2.testBaseline({payload,revision:7}); + await SunCloudV2.syncNow({quiet:true}); + return {calls,toasts,status:SunCloudV2.status().lastStatus,orders:JSON.parse(localStorage.sunOrders),boxes:JSON.parse(localStorage.sunBoxes),runtimeOrderIds:orders.map(o=>o.id),runtimeBoxIds:boxes.map(b=>b.id),profile:JSON.parse(localStorage.sunCompanyProfileV1)}; + },hasBaseline); + expect(result.status).toBe('ready');expect(result.calls).toEqual(['sun_fetch_app_state']);expect(result.toasts.filter(t=>t.t==='error')).toEqual([]); + expect(result.orders.map(o=>o.id)).toEqual(['real-order']);expect(result.boxes.map(o=>o.id)).toEqual(['real-box']);expect(result.runtimeOrderIds).toEqual(['real-order']);expect(result.runtimeBoxIds).toEqual(['real-box']);expect(result.profile.name).toBe('Рабочая компания'); +}); + +test('an allowed employee order edit does not change notifications or read-only sections',async({page})=>{ + await syncHarness(page); + const r=await page.evaluate(async()=>{ + const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:[{id:'box',name:'Рабочий бокс',price:500}]},sunOrders:{t:'j',v:[{id:1,total:500}]},sunReadNotificationsV1:{t:'j',v:['owner-only']},sunCompanyProfileV1:{t:'j',v:{name:'Компания'}}}}; + let row={payload,revision:1};const writes=[];window.toasts=[];window.SunEnterprise={toast:(s,t)=>toasts.push({s,t})}; + SunCloudV2.testInit({rpc:async(name,args)=>{if(name==='sun_fetch_app_state')return {data:[structuredClone(row)]};if(name==='sun_save_app_state_v17'){ + for(const k of new Set([...Object.keys(payload.storage),...Object.keys(args.p_payload.storage)]))if(k!=='sunOrders'&&JSON.stringify(payload.storage[k])!==JSON.stringify(args.p_payload.storage[k]))return {error:{message:'Нет права изменять раздел: '+k}}; + writes.push(structuredClone(args.p_payload));row={payload:structuredClone(args.p_payload),revision:2};return {data:[row]}; + }throw new Error(name)}},{'app.read':true,'orders.view':true,'orders.edit':true,'catalog.view':true,'catalog.edit':false,'settings.edit':false}); + await SunCloudV2.testApply(payload,true);await SunCloudV2.testBaseline(row); + localStorage.setItem('sunOrders','[{"id":1,"total":700}]');localStorage.setItem('sunReadNotificationsV1','["employee-only"]');localStorage.setItem('sunPrintSettingsV1','{"bootstrap":true}'); + await SunCloudV2.syncNow({quiet:true});return {writes,toasts,status:SunCloudV2.status().lastStatus}; + }); + expect(r.status).toBe('ready');expect(r.writes).toHaveLength(1);expect(r.writes[0].storage.sunOrders.v).toEqual([{id:1,total:700}]);expect(r.writes[0].storage.sunReadNotificationsV1.v).toEqual(['owner-only']);expect(r.writes[0].storage.sunPrintSettingsV1).toBeUndefined();expect(r.toasts.filter(t=>t.t==='error')).toEqual([]); +}); + +test('order reconciliation preserves records without delete rights and does not grant create/edit rights',async({page})=>{ + await syncHarness(page); + const r=await page.evaluate(()=>{ + const p=rows=>({format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:rows}}}),base=p([{id:1,total:500},{id:2,total:300}]),local=p([{id:1,total:700},{id:3,total:900}]); + const merge=permissions=>{SunCloudV2.testInit({},permissions);return SunCloudV2.testMerge(base,local,base).payload.storage.sunOrders.v}; + return {editor:merge({'orders.edit':true}),creator:merge({'orders.create':true}),deleter:merge({'orders.delete':true}),all:merge({'orders.create':true,'orders.edit':true,'orders.delete':true})}; + }); + expect(r.editor).toEqual([{id:1,total:700},{id:2,total:300}]);expect(r.creator).toEqual([{id:1,total:500},{id:3,total:900},{id:2,total:300}]);expect(r.deleter).toEqual([{id:1,total:500}]);expect(r.all).toEqual([{id:1,total:700},{id:3,total:900}]); +}); + +test('notification read flags are personal per user and company and absent from cloud payload',async({page})=>{ + await syncHarness(page); + const r=await page.evaluate(async()=>{ + SunCloudV2.testInit({},{});const own=SunSafe.personalStorageKey('notifications-read');localStorage.setItem(own,'["n1"]');localStorage.setItem('sunReadNotificationsV1','["old-shared"]'); + const getSession=SunCloudV2.getSession,getWorkspace=SunCloudV2.getWorkspace; + SunCloudV2.getSession=()=>({user:{id:'another-employee'}});const another=SunSafe.personalStorageKey('notifications-read'); + SunCloudV2.getSession=getSession;SunCloudV2.getWorkspace=()=>({id:'another-company'});const otherCompany=SunSafe.personalStorageKey('notifications-read'); + SunCloudV2.getSession=()=>null;const signedOut=SunSafe.personalStorageKey('notifications-read');SunCloudV2.getSession=getSession;SunCloudV2.getWorkspace=getWorkspace; + return {own,another,otherCompany,signedOut,otherRead:localStorage.getItem(another),payload:await SunCloudV2.snapshotLocal(),oldSync:SunCloudV2.testShouldSync('sunReadNotificationsV1')}; + }); + expect(new Set([r.own,r.another,r.otherCompany]).size).toBe(3);expect(r.signedOut).toBe('');expect(r.otherRead).toBeNull();expect(r.oldSync).toBe(false);expect(Object.keys(r.payload.storage).some(k=>/Notifications|personal/.test(k))).toBe(false); + const html=fs.readFileSync('public/index.html','utf8');expect(html).toContain("personalStorageKey('notifications-read')");expect(html).not.toContain("const readKey='sunReadNotificationsV1'"); +}); + +test('employee sidebar follows verified workspace, resets during a switch and ignores late replies',async({page})=>{ + await blank(page); + await page.evaluate(()=>{ + window.selected='solnce-company';window.brandCalls=[];window.late=null; + window.SunCloudV2={getSession:()=>({user:{id:'employee'}}),getWorkspace:()=>({id:selected}),getClient:()=>({rpc:async(name,args)=>{brandCalls.push({name,args});if(args?.p_workspace==='pending-company')return new Promise(resolve=>late=resolve);return {data:{variant:args?.p_workspace==='solnce-company'?'solnce':'caterium'}}}})}; + }); + await page.addScriptTag({url:'/core/company-branding.js'});await expect(page.locator('header .brand')).toContainText('Солнце Кейтеринг'); + await page.evaluate(()=>{selected='pending-company';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));CateriumBranding.refreshSidebar()}); + await expect(page.locator('header .brand')).toContainText('Caterium'); + await page.evaluate(async()=>{selected='ordinary-company';await CateriumBranding.refreshSidebar();late({data:{variant:'solnce'}})}); + await expect(page.locator('header .brand')).toContainText('Caterium'); + expect(await page.evaluate(()=>brandCalls.every(c=>c.name==='caterium_workspace_sidebar_brand'&&c.args.p_workspace))).toBe(true); +}); + +test('older server fallback does not fabricate employee company branding',async({page})=>{ + await blank(page);await page.evaluate(()=>{window.calls=[];window.SunCloudV2={getSession:()=>({user:{id:'employee'}}),getWorkspace:()=>({id:'company'}),getClient:()=>({rpc:async name=>{calls.push(name);return name==='caterium_workspace_sidebar_brand'?{error:{code:'PGRST202'}}:{data:{variant:'caterium'}}}})}}); + await page.addScriptTag({url:'/core/company-branding.js'});await expect.poll(()=>page.evaluate(()=>calls.length)).toBe(2);await expect(page.locator('header .brand')).toContainText('Caterium'); +}); + +test('mobile header has visible profile/logout; failed or pending account details never hide exit',async({page},info)=>{ + test.setTimeout(60000);await page.setViewportSize({width:390,height:844}); + await page.addInitScript(trainingInit);await page.route('https://**/*',r=>r.abort()); + await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.SunCloudV2?.getWorkspace()?.id&&window.CateriumAccountCenterV1780); + await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000}); + const profile=page.locator('#cateriumMobileAccountButton'),logout=page.locator('#cateriumMobileLogout'); + await expect(profile).toBeVisible();await expect(logout).toBeVisible();await expect(logout).toHaveText('Выйти'); + const box=await logout.boundingBox();expect(box.height).toBeGreaterThanOrEqual(44);expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(391); + await page.evaluate(()=>{window.logoutCalls=0;window.SunCloudV2.signOut=async()=>{logoutCalls++};const c=SunCloudV2.getClient(),rpc=c.rpc;c.rpc=(name,args)=>name==='caterium_account_snapshot'?new Promise(resolve=>window.resolveProfile=resolve):rpc(name,args)}); + await page.screenshot({path:`test-results/employee-mobile-header-${info.project.name}.png`}); + await profile.click();await expect(page.locator('#cacHeaderLogout')).toBeVisible();await expect(page.locator('#cacLogout')).toBeAttached(); + await page.locator('#cacHeaderLogout').click();expect(await page.evaluate(()=>logoutCalls)).toBe(1); + await page.evaluate(()=>resolveProfile({error:{message:'Offline'}}));await expect(page.locator('#cateriumAccountCenterBody')).toContainText('Выход из аккаунта доступен'); + await expect(page.locator('#cacHeaderLogout')).toBeVisible();await page.screenshot({path:`test-results/employee-profile-error-${info.project.name}.png`}); + await page.locator('#cateriumAccountCenter .cac-close').click();await logout.click();expect(await page.evaluate(()=>logoutCalls)).toBe(2); + const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1);expect(overflow).toBe(false); +}); + +test('late profile details cannot reappear after changing account or workspace',async({page})=>{ + await blank(page);await page.evaluate(()=>{window.user='a';window.ws='company-a';window.pending=[];window.SunCloudV2={getSession:()=>({user:{id:user,email:user+'@example.invalid'}}),getWorkspace:()=>({id:ws,name:ws,role:'manager'}),getClient:()=>({rpc:()=>new Promise(resolve=>pending.push(resolve))})}}); + await page.addScriptTag({url:'/core/sun-safe.js'});await page.waitForFunction(()=>window.CateriumAccountCenterV1780); + await page.evaluate(()=>{CateriumAccountCenterV1780.open()});await expect(page.locator('#cateriumAccountCenter')).toHaveClass('on'); + await page.evaluate(()=>{user='b';ws='company-b';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));pending[0]({data:{company_name:'PRIVATE OLD COMPANY',is_owner:true}})}); + await expect(page.locator('#cateriumAccountCenter')).not.toHaveClass('on');await page.evaluate(()=>{CateriumAccountCenterV1780.open()}); + await expect(page.locator('#cateriumAccountCenterBody')).toContainText('company-b');await expect(page.locator('#cateriumAccountCenterBody')).not.toContainText('PRIVATE OLD COMPANY'); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 4fbb891..fdb5ed0 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-employee-session.mjs b/tests/production-employee-session.mjs new file mode 100644 index 0000000..a2b9258 --- /dev/null +++ b/tests/production-employee-session.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {chromium,expect} from '@playwright/test'; +import {trainingInit} from './training-catalog-fixture.mjs'; + +// Read only public application assets. Use synthetic SDK responses; no live +// customer credentials, database reads, writes, or session termination. +const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru'); +assert.equal(base.protocol,'https:'); +const output='production-ui-results';await fs.mkdir(output,{recursive:true}); +const files=['core/account-center-v1780.js','core/company-branding.js','core/sun-safe.js','app-runtime.js','index.html','service-worker.js']; +for(const file of files){ + const response=await fetch(new URL(file+'?employee_check='+Date.now(),base),{signal:AbortSignal.timeout(20000),headers:{'Cache-Control':'no-cache'}}); + assert(response.ok,'Published asset unavailable: '+file); + const actual=Buffer.from(await response.arrayBuffer()),expected=await fs.readFile('public/'+file); + assert.equal(createHash('sha256').update(actual).digest('hex'),createHash('sha256').update(expected).digest('hex'),'Published revision differs: '+file); +} +const browser=await chromium.launch(); +try{ + const context=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true,serviceWorkers:'block'}); + await context.route('**/*',route=>{const req=route.request(),url=new URL(req.url());return req.method()==='GET'&&url.origin===base.origin&&!url.pathname.startsWith('/api/')?route.continue():route.abort()}); + await context.addInitScript(trainingInit); + const page=await context.newPage();await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000}); + await page.waitForFunction(()=>window.SunCloudV2?.getWorkspace()?.id&&window.CateriumAccountCenterV1780); + await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000}); + await expect(page.locator('#cateriumMobileLogout')).toBeVisible();await expect(page.locator('#cateriumMobileLogout')).toHaveText('Выйти'); + await expect(page.locator('#cateriumMobileAccountButton')).toBeVisible(); + await page.evaluate(()=>{window.exitChecks=0;SunCloudV2.signOut=async()=>{exitChecks++};const c=SunCloudV2.getClient(),rpc=c.rpc;c.rpc=(n,a)=>n==='caterium_account_snapshot'?Promise.resolve({error:{message:'Synthetic offline'}}):rpc(n,a)}); + await page.screenshot({path:output+'/employee-mobile-header.png'}); + await page.locator('#cateriumMobileAccountButton').click(); + await expect(page.locator('#cateriumAccountCenterBody')).toContainText('Выход из аккаунта доступен'); + await expect(page.locator('#cacHeaderLogout')).toBeVisible();await page.locator('#cacHeaderLogout').click(); + assert.equal(await page.evaluate(()=>exitChecks),1); + await page.screenshot({path:output+'/employee-profile-offline.png'}); + await page.locator('#cateriumAccountCenter .cac-close').click();await page.locator('#cateriumMobileLogout').click();assert.equal(await page.evaluate(()=>exitChecks),2); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1),false); + await fs.writeFile(output+'/employee-session.json',JSON.stringify({checkedAt:new Date().toISOString(),base:base.href,assetHashes:'match',mobileLogout:'pass',profileRpcFailure:'exit remains usable',backend:'synthetic, blocked',liveMembership:'not inspected',sqlMigration:'not deployed by frontend workflow'},null,2)); + console.log('PASS published employee session: exact assets, visible mobile logout, profile unavailable fallback; synthetic backend only'); + await context.close(); +}finally{await browser.close()} diff --git a/tests/recovery/employee-session.mjs b/tests/recovery/employee-session.mjs new file mode 100644 index 0000000..d3cb1df --- /dev/null +++ b/tests/recovery/employee-session.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +export async function run(db,{owner,employee,workspace,foreign}){ + const q=(sql,args=[])=>db.query(sql,args),one=async(sql,args)=>(await q(sql,args)).rows[0]; + const actor=async id=>{await db.exec('reset role');await q("select set_config('request.jwt.claim.sub',$1,false),set_config('request.jwt.claims',$2,false)",[id,JSON.stringify({sub:id,aal:'aal1',role:'authenticated'})]);await db.exec('set role authenticated')}; + const brand=async (id=workspace)=>(await one('select public.caterium_workspace_sidebar_brand($1) as b',[id])).b; + await db.exec('reset role');const before=await one('select payload,revision from public.sun_app_state where workspace_id=$1',[workspace]); + await actor(owner);assert.equal((await brand()).variant,'solnce'); + await actor(employee);assert.equal((await brand()).variant,'solnce','active company employee inherits sidebar identity'); + await assert.rejects(brand(foreign),/Нет доступа/); + await assert.rejects(q('update public.caterium_sidebar_brand_assignment set workspace_id=$1',[foreign]),/permission denied/); + await db.exec('reset role');await q('update public.sun_workspace_members set is_active=false where user_id=$1 and workspace_id=$2',[employee,workspace]); + await actor(employee);await assert.rejects(brand(),/Нет доступа/); + await db.exec('reset role');await q('update public.sun_workspace_members set is_active=true where user_id=$1 and workspace_id=$2',[employee,workspace]);await q('update auth.users set email_confirmed_at=null where id=$1',[employee]); + await actor(employee);await assert.rejects(brand(),/Нет доступа/); + await db.exec('reset role');await q('update auth.users set email_confirmed_at=now() where id=$1',[employee]);await q("insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions) values($1,$2,'viewer',true,public.sun_role_default_permissions('viewer'))",[foreign,employee]); + await actor(employee);assert.equal((await brand(foreign)).variant,'caterium','same employee in another workspace does not carry branding'); + await db.exec('reset role');const after=await one('select payload,revision from public.sun_app_state where workspace_id=$1',[workspace]);assert.deepEqual(after,before,'branding never writes business state'); + await db.exec('set role anon');await assert.rejects(brand(),/permission denied/);await db.exec('reset role'); + console.log('PASS employee sidebar: active membership, selected workspace, confirmation, no branding spoof, no business-data changes'); +} diff --git a/tests/recovery/smoke.mjs b/tests/recovery/smoke.mjs index f74ffa7..9eafefe 100644 --- a/tests/recovery/smoke.mjs +++ b/tests/recovery/smoke.mjs @@ -151,5 +151,6 @@ await db.exec('reset role; set role anon');await denied('select public.caterium_ await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_demo($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_demo_seed_v1()',[],/permission denied/); console.log('PASS demo: trial auto seed, 10 box + 18 banquet TTKs, stock links, additive upgrade preserving orders/prices/balances, idempotency, populated catalog guard, Solnce exclusion, own developer account and preserved clients'); console.log('PASS smoke: owner onboarding, save/read, optimistic conflict, normalized catalog/orders/clients, backups, employees, cross-tenant isolation, viewer write denial, AAL2, developer RPCs'); +await (await import('./employee-session.mjs')).run(db,{owner:ids[1],employee:ids[3],workspace:ws,foreign}); await db.close(); }