From d4e02cab8bbab0c71cf2f025b82b6835c0bcb13e Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Fri, 18 Sep 2026 02:39:30 +0300 Subject: [PATCH] Recover login from empty API responses with one authenticated session --- docs/LOGIN-RECOVERY-20260918.md | 9 ++++ docs/release-manifest.json | 2 +- package.json | 2 +- public/app-runtime.js | 69 +++++++++++++------------ public/core/auth-security-v1774.js | 17 +++---- public/core/cloud-transport.js | 35 +++++++++++++ public/core/performance.js | 2 +- public/index.html | 6 +-- public/service-worker.js | 7 +-- tests/account-access.spec.mjs | 1 + tests/login-recovery.spec.mjs | 82 ++++++++++++++++++++++++++++++ tests/playwright.config.mjs | 2 +- tests/release-check.mjs | 10 ++-- tests/static-security.mjs | 4 +- 14 files changed, 188 insertions(+), 60 deletions(-) create mode 100644 docs/LOGIN-RECOVERY-20260918.md create mode 100644 public/core/cloud-transport.js create mode 100644 tests/login-recovery.spec.mjs diff --git a/docs/LOGIN-RECOVERY-20260918.md b/docs/LOGIN-RECOVERY-20260918.md new file mode 100644 index 0000000..3e59e11 --- /dev/null +++ b/docs/LOGIN-RECOVERY-20260918.md @@ -0,0 +1,9 @@ +# Recover login when the API proxy returns no data + +The API proxy intermittently returned HTTP 200 with an empty HTML response for authentication and workspace membership requests. The same Supabase backend returned a valid session and the existing workspace directly. The former membership loader treated malformed responses like missing workspaces, while an existing login gate kept its loading message. A second authentication client used by the fallback also shared the same session storage key. + +The shared transport now validates JSON responses and uses the same backend directly for failed reads and password login. Authorization and the single SDK session are preserved. Only GET/HEAD and explicitly allowlisted read RPCs can be retried, plus password login. Database writes are not replayed. Both channels have bounded waits; permission and credential errors are returned without fallback. + +Membership requests for the same account share one in-flight operation. Late responses from a different account are ignored. Missing RPC compatibility is used only for a missing function, not network errors. A failed membership request produces a retry/exit screen and cannot be mistaken for a developer account with no company. Successful login still opens the normal application. + +Regression coverage includes empty/invalid proxy responses, identical request bodies and authorization on fallback, non-replayed writes, credential rejection, concurrent membership loads, recovery UI, and a real Supabase SDK login against controlled endpoint responses. The full flow is checked on desktop and mobile without changing production credentials or authentication requirements. diff --git a/docs/release-manifest.json b/docs/release-manifest.json index c630145..c2e374e 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v89-20260917-calendar-compact", + "pwaCache": "v90-20260918-login-recovery", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, diff --git a/package.json b/package.json index 1b13855..3b76394 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "17.7.3", "type": "module", "scripts": { - "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js", + "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js", "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs", "check:release": "node tests/release-check.mjs", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db", diff --git a/public/app-runtime.js b/public/app-runtime.js index 4383e9f..b398a5f 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -2113,23 +2113,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const SUPABASE_API_PROXY = 'https://api.caterium.ru'; const PROXY_FETCH_TIMEOUT_MS = 7000; - function supabaseProxyFetch(input, init) { - try { - const url = typeof input === 'string' ? input : input?.url; - if (url && url.indexOf(DEFAULT_SUPABASE_URL) === 0) { - const proxied = SUPABASE_API_PROXY + url.slice(DEFAULT_SUPABASE_URL.length); - input = typeof input === 'string' ? proxied : new Request(proxied, input); - } - } catch (_) {} - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROXY_FETCH_TIMEOUT_MS); - const externalSignal = init?.signal; - if (externalSignal) { - if (externalSignal.aborted) controller.abort(); - else externalSignal.addEventListener('abort', () => controller.abort(), {once:true}); - } - return fetch(input, {...init, signal: controller.signal}).finally(() => clearTimeout(timer)); - } + const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS}); let config = loadConfig(); let client = null; @@ -2140,6 +2124,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; let supportMode = null; let membershipsLoading = false; let membershipsLoaded = false; + let membershipError = ''; + let membershipLoad = null; let realtimeChannel = null; let syncTimer = null; let isSyncing = false; @@ -3123,7 +3109,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const signature=`${config.url}|${config.key}`; if(!client||signature!==clientSignature){ client=window.supabase.createClient(config.url,config.key,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true},global:{fetch:supabaseProxyFetch}});clientSignature=signature; - client.auth.onAuthStateChange((_event,next)=>{if(session?.user?.id!==next?.user?.id)window.CateriumBranding?.resetSidebar();session=next;membershipsLoading=Boolean(next?.user);membershipsLoaded=!next?.user;setTimeout(async()=>{try{if(!next?.user){const sw=await switchTenantLocal('');if(sw.changed){location.reload();return;}}else{const sw=await loadMemberships();if(sw){location.reload();return;}}}finally{renderCloudUI();updatePill();}},0);}); + client.auth.onAuthStateChange((_event,next)=>{const changed=session?.user?.id!==next?.user?.id;if(changed)window.CateriumBranding?.resetSidebar();session=next;if(!changed&&(membershipsLoading||(membershipsLoaded&&!membershipError)))return;membershipsLoading=Boolean(next?.user);membershipsLoaded=!next?.user;setTimeout(async()=>{try{if(!next?.user){const sw=await switchTenantLocal('');if(sw.changed){location.reload();return;}}else{const sw=await loadMemberships();if(sw){location.reload();return;}}}finally{renderCloudUI();updatePill();}},0);}); } const {data,error}=await client.auth.getSession();if(error)throw error;session=data.session||null; const tenantSwitched=session?await loadMemberships():(await switchTenantLocal('')).changed; @@ -3158,18 +3144,26 @@ window.SUN_LEGACY_CATALOG_V175=[]; }catch(error){handleError(error,signUp?'Не удалось создать аккаунт.':'Не удалось войти.');} } - async function loadMemberships() { - memberships=[];workspace=null;membershipsLoaded=false; - if(!client||!session?.user)return false; + function loadMemberships() { + const userId=session?.user?.id||''; + if(membershipLoad?.userId===userId)return membershipLoad.promise; + if(!client||!userId){memberships=[];workspace=null;membershipsLoading=false;membershipsLoaded=true;membershipError='';return Promise.resolve(false)} + const task={userId,promise:null};membershipLoad=task; + task.promise=runMembershipLoad(task).finally(()=>{if(membershipLoad!==task)return;membershipLoad=null;membershipsLoading=false;membershipsLoaded=true;try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace)}}))}catch(_){}}); + return task.promise; + } + async function runMembershipLoad(task) { + const activeClient=client,current=()=>membershipLoad===task&&client===activeClient&&session?.user?.id===task.userId; + memberships=[];workspace=null;membershipsLoaded=false;membershipError=''; membershipsLoading=true; try{ let rows=[]; const rpc=await sunCloudAwait(client.rpc('sun_my_workspaces'),'Загрузка рабочей базы'); if(!rpc.error&&Array.isArray(rpc.data)){ rows=rpc.data.map(x=>({workspace_id:x.id,name:x.name,role:x.role,display_name:x.display_name,is_active:x.is_active,permissions:x.permissions})); - }else{ + }else if(rpc.error&&['PGRST202','42883'].includes(rpc.error.code)){ // Compatibility fallback for an older server schema. - const direct=await sunCloudAwait(client.from('sun_workspace_members').select('workspace_id,role,display_name,is_active,permissions').eq('user_id',session.user.id),'Загрузка рабочей базы'); + const direct=await sunCloudAwait(activeClient.from('sun_workspace_members').select('workspace_id,role,display_name,is_active,permissions').eq('user_id',task.userId),'Загрузка рабочей базы'); if(direct.error)throw rpc.error||direct.error; const active=(direct.data||[]).filter(x=>x.is_active!==false); if(active.length){ @@ -3177,21 +3171,24 @@ window.SUN_LEGACY_CATALOG_V175=[]; const w=await sunCloudAwait(client.from('sun_workspaces').select('id,name').in('id',ids),'Загрузка рабочей базы');if(w.error)throw w.error;(w.data||[]).forEach(x=>names[x.id]=x.name); rows=active.map(x=>({...x,name:names[x.workspace_id]||'Рабочая база'})); } - } + }else throw rpc.error||new Error('Не удалось прочитать список компаний. Повторите загрузку.'); + if(!current())return false; memberships=rows.filter(x=>x.is_active!==false).map(x=>({id:x.workspace_id,name:x.name||'Рабочая база',role:x.role,display_name:x.display_name||'',is_active:x.is_active!==false,permissions:x.permissions||{}})); if(memberships.length&&!memberships.some(x=>x.id===config.workspaceId))config.workspaceId=memberships[0].id; if(!memberships.length)config.workspaceId=''; saveConfig();selectWorkspace(); const switched=await switchTenantLocal(workspace?.id||''); + if(!current())return false; membershipsLoaded=true; if(switched.changed)return true; try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),user:clone(session.user)}}));}catch(_){} return false; }catch(error){ - membershipsLoaded=true; - setStatus('error',error?.message||'Не удалось загрузить рабочую базу.'); + if(!current())return false; + membershipError=error?.message||'Не удалось загрузить рабочую базу.'; + setStatus('error',membershipError); return false; - }finally{membershipsLoading=false;} + } } function selectWorkspace(){workspace=memberships.find(x=>x.id===config.workspaceId)||memberships[0]||null;if(workspace&&config.workspaceId!==workspace.id){config.workspaceId=workspace.id;saveConfig();}} @@ -3360,7 +3357,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; setAutoSyncDeveloper, snapshotLocal:()=>collectLocalPayload(), reloadMemberships:async()=>{await loadMemberships();renderCloudUI();return workspace?clone(workspace):null;}, - status:()=>({connected:Boolean(client),signedIn:Boolean(session),supportMode:supportMode?clone(supportMode):null,workspace:workspace?clone(workspace):null,membershipsLoading,membershipsLoaded,membershipCount:memberships.length,dirty,lastStatus,lastError}) + status:()=>({connected:Boolean(client),signedIn:Boolean(session),supportMode:supportMode?clone(supportMode):null,workspace:workspace?clone(workspace):null,membershipsLoading,membershipsLoaded,membershipError,membershipCount:memberships.length,dirty,lastStatus,lastError}) }; async function boot(){ @@ -3761,9 +3758,17 @@ window.SUN_LEGACY_CATALOG_V175=[]; } if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');try{localStorage.removeItem(LOCAL_ONLY_KEY)}catch(_){}return;} if(!st.signedIn&&window.CateriumAccessPolicy?.emergencyLocalActive()){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;} - if($('sunCloudAuthGateV3')){if(st.signedIn&&st.membershipsLoading){const e=$('sunGateErrorV3');if(e)e.textContent='Загружаю рабочую базу…';}return;} + const gateState=st.signedIn?(st.membershipsLoading?'loading':st.membershipError?'error':'missing'):'login'; + const currentGate=$('sunCloudAuthGateV3'); + if(currentGate){if(st.signedIn&¤tGate.dataset.authState!==gateState)currentGate.remove();else{if(st.signedIn){const e=$('sunGateErrorV3');if(e)e.textContent=st.membershipsLoading?'Загружаю рабочую базу…':st.membershipError||e.textContent;}return;}} document.body.classList.add('sun-cloud-auth-required'); - const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate'; + const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.dataset.authState=gateState; + if(st.signedIn&&st.membershipError){ + gate.innerHTML=`

Не удалось загрузить рабочую базу

${esc(ss?.user?.email||'Аккаунт авторизован')}

Вход выполнен, но сервер не вернул данные компании. Повторите загрузку.

${esc(st.membershipError)}
`; + document.body.appendChild(gate); + gate.querySelector('#sunGateRetryWorkspaceV3').onclick=async()=>{const button=gate.querySelector('#sunGateRetryWorkspaceV3');button.disabled=true;try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}}finally{gate.remove();ensureAuthGate();}}; + gate.querySelector('#sunGateSignOutV3').onclick=()=>cloud()?.signOut?.();return; + } if(st.signedIn&&ss?.user&&!ws){ const pending=getPendingRegistration(ss.user.email||''); gate.innerHTML=`
Caterium

${pending?'Создаю компанию':st.membershipsLoading?'Загружаю рабочую базу':'Аккаунт готов'}

${esc(ss.user.email||'Аккаунт авторизован')}

${pending?'Регистрация завершена. Сейчас подготовим вашу рабочую компанию.':st.membershipsLoading?'Проверяю доступы этого аккаунта на сервере.':'У этого аккаунта пока нет компании. Укажите название, чтобы создать её.'}

${!pending&&!st.membershipsLoading?'':''}
${pending?'Подготавливаю компанию…':st.membershipsLoading?'Загружаю рабочую базу…':''}
`; @@ -4688,7 +4693,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');} function enhanceOnboardingGate(){ - const gate=$('sunCloudAuthGateV3'),ss=session(),ws=workspace(),st=cloud()?.status?.();if(window.SunDeveloperV22?.isPlatformAdmin?.())return;if(!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipsLoaded===false||gate.dataset.saasEnhanced==='1')return; + const gate=$('sunCloudAuthGateV3'),ss=session(),ws=workspace(),st=cloud()?.status?.();if(window.SunDeveloperV22?.isPlatformAdmin?.())return;if(!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipError||st?.membershipsLoaded===false||gate.dataset.saasEnhanced==='1')return; gate.dataset.saasEnhanced='1';const card=gate.querySelector('.sun-cloud-auth-card');if(!card)return; card.innerHTML=`

Создайте компанию

${esc(ss.user.email||'Аккаунт авторизован')} · 14 дней Полного тарифа бесплатно

У этого аккаунта пока нет рабочей базы. Приглашения временно отключены на период локального тестирования.

`; const err=$('sunSaaSGateErrorV16'); @@ -5359,7 +5364,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; async function renderJournal(body){const [activity,errors]=await Promise.all([rpc('sun_dev_list_activity',{p_limit:150}),rpc('sun_dev_list_errors',{p_workspace:null,p_limit:100})]);body.innerHTML=`

Действия разработчика

${(activity||[]).map(a=>``).join('')||''}
ДатаДействиеКомпания / аккаунтДетали
${fmtDate(a.created_at)}${esc(a.action)}
${esc(a.actor_email||'')}
${esc(a.workspace_name||a.target_email||'—')}${esc(JSON.stringify(a.details||{}))}
Записей пока нет.

Ошибки приложения

${(errors||[]).map(e=>``).join('')||''}
ДатаКомпанияВерсияОшибка
${fmtDate(e.created_at)}${esc(e.workspace_name||'—')}${esc(e.app_version||'—')}${esc(e.level||'error')}
${esc(e.message||'')}
Ошибок нет.
`} - function enhanceDeveloperGate(){const gate=$('sunCloudAuthGateV3'),ss=session(),ws=cloud()?.getWorkspace?.(),st=cloud()?.status?.();if(!platformAdmin||!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipsLoaded===false)return;const card=gate.querySelector('.sun-cloud-auth-card');if(!card||card.dataset.devEnhanced==='1')return;card.dataset.devEnhanced='1';gate.dataset.saasEnhanced='1';card.innerHTML=`
Caterium

Аккаунт разработчика

${esc(ss.user.email||'')}

Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.

`;card.querySelector('[data-open-dev]').onclick=()=>open();card.querySelector('[data-signout]').onclick=()=>cloud()?.signOut?.()} + function enhanceDeveloperGate(){const gate=$('sunCloudAuthGateV3'),ss=session(),ws=cloud()?.getWorkspace?.(),st=cloud()?.status?.();if(!platformAdmin||!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipError||st?.membershipsLoaded===false)return;const card=gate.querySelector('.sun-cloud-auth-card');if(!card||card.dataset.devEnhanced==='1')return;card.dataset.devEnhanced='1';gate.dataset.saasEnhanced='1';card.innerHTML=`
Caterium

Аккаунт разработчика

${esc(ss.user.email||'')}

Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.

`;card.querySelector('[data-open-dev]').onclick=()=>open();card.querySelector('[data-signout]').onclick=()=>cloud()?.signOut?.()} function syncChrome(){if(platformAdmin){ensureNav();enhanceDeveloperGate()}else{$('sunDeveloperNavV22')?.remove()}ensureSupportBanner()} function boot(){installStyle();let tries=0;const timer=setInterval(async()=>{tries++;if(document.hidden)return;if(client()&&session()?.user){await checkPlatformAdmin(false);enhanceDeveloperGate();ensureSupportBanner()}else if(!session()?.user){platformAdmin=false;syncChrome()}if(tries>40)clearInterval(timer)},3000);window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>checkPlatformAdmin(true),120));window.addEventListener('sun:cloud-state-applied',()=>ensureSupportBanner());document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'&&platformAdmin)setTimeout(()=>{$('sunSaaSSettingsCardV16')?.querySelector('[data-saas-admin]')?.remove()},120)},true);setTimeout(()=>checkPlatformAdmin(true),900)} diff --git a/public/core/auth-security-v1774.js b/public/core/auth-security-v1774.js index cbf5230..ed052fe 100644 --- a/public/core/auth-security-v1774.js +++ b/public/core/auth-security-v1774.js @@ -1,20 +1,15 @@ (()=>{ 'use strict'; - const VERSION='17.8.4-auth-proxy-fallback'; + const VERSION='17.8.5-single-session-login'; const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; - const DIRECT_SUPABASE_URL='https://usfjwhztqoopzzfmfbis.supabase.co'; - const DIRECT_SUPABASE_KEY='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; - let busy=false,directClient=null; + let busy=false; const $=id=>document.getElementById(id); const cloud=()=>window.SunCloudV2||null; const client=()=>cloud()?.getClient?.()||null; const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??''); - function directAuthClient(){if(directClient)return directClient;try{if(!window.supabase?.createClient)return null;directClient=window.supabase.createClient(DIRECT_SUPABASE_URL,DIRECT_SUPABASE_KEY,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true}});return directClient}catch(_){return null}} - function isProxyServerError(error){const text=`${error?.message||error||''} ${error?.status||''} ${error?.statusCode||''}`;return /(?:http\s*)?500|internal server error|failed to fetch|networkerror|load failed/i.test(text)} - async function signInWithFallback(c,email,password){let first=null;try{first=await c.auth.signInWithPassword({email,password})}catch(error){if(!isProxyServerError(error))throw error;first={error}}if(first?.data?.session)return{authClient:c,result:first};if(first?.error&&!isProxyServerError(first.error))throw first.error;const fallback=directAuthClient();if(!fallback)throw first?.error||new Error('Сервис входа временно недоступен.');const second=await fallback.auth.signInWithPassword({email,password});if(second.error)throw second.error;return{authClient:fallback,result:second}} function redirectUrl(){try{const u=new URL(location.href);u.hash='';return u.toString();}catch(_){return location.href.split('#')[0];}} function savePendingRegistration(email,promoCode){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:'Новая компания',promoCode:String(promoCode||'').trim().toUpperCase(),createdAt:new Date().toISOString()}));}catch(_){}} @@ -46,10 +41,10 @@ if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;} busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Выполняю вход…'); try{ - const signed=await signInWithFallback(c,email,password),result=signed.result,authClient=signed.authClient; + const result=await c.auth.signInWithPassword({email,password});if(result.error)throw result.error; const authUser=result.data?.user||result.data?.session?.user||null;const userId=authUser?.id||'';if(userId)try{sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1')}catch(_){} - const persisted=await authClient.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.'); - setError(gate,authClient===c?'Вход выполнен. Открываю Caterium…':'Вход выполнен через резервный канал. Открываю Caterium…'); + const persisted=await c.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.'); + setError(gate,'Вход выполнен. Открываю Caterium…'); setTimeout(()=>location.reload(),120); }catch(error){setError(gate,String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;} } @@ -110,4 +105,4 @@ const init=()=>{neutralizeLegacyCompanyUI();setTimeout(()=>{const gate=$('sunCloudAuthGateV3');if(gate&&pendingRegistration())finishOwnerOnboarding(gate);},500)}; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init,{once:true});else init(); window.CateriumAuthSecurityV1774=Object.freeze({VERSION,redirectUrl,ensureRegistrationFields}); -})(); \ No newline at end of file +})(); diff --git a/public/core/cloud-transport.js b/public/core/cloud-transport.js new file mode 100644 index 0000000..0acf8ca --- /dev/null +++ b/public/core/cloud-transport.js @@ -0,0 +1,35 @@ +(()=>{ + 'use strict'; + const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin']); + function create({upstream,proxy,timeout=7000,fallbackTimeout=4000}){ + const origin=new URL(upstream).origin; + return async function(input,init={}){ + const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin; + const read=original.method==='GET'||original.method==='HEAD'||(original.method==='POST'&&url.pathname.startsWith('/rest/v1/rpc/')&&READ_RPCS.has(url.pathname.slice('/rest/v1/rpc/'.length))); + const passwordLogin=original.method==='POST'&&url.pathname==='/auth/v1/token'&&url.searchParams.get('grant_type')==='password'; + const safeFallback=isBackend&&(read||passwordLogin); + const expectJson=original.method!=='HEAD'&&(passwordLogin||url.pathname.startsWith('/rest/v1/rpc/')||(read&&(url.pathname.startsWith('/rest/v1/')||url.pathname.startsWith('/auth/v1/')))); + async function attempt(target,limit){ + const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason); + if(original.signal.aborted)abort();else original.signal.addEventListener('abort',abort,{once:true}); + const timer=setTimeout(()=>controller.abort(),limit); + try{ + const response=await fetch(new Request(target,original.clone()),{signal:controller.signal}); + if(expectJson&&response.ok&&response.status!==204){ + const text=await response.clone().text(); + try{if(!text.trim()||!response.headers.get('content-type')?.includes('json'))throw new Error();JSON.parse(text)} + catch(_){throw new Error('Сервис вернул пустой или некорректный ответ. Повторите загрузку.')} + } + return response; + }finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)} + } + try{ + const response=await attempt(isBackend?proxy+url.pathname+url.search:original.url,timeout); + if(!safeFallback||response.status<500)return response; + }catch(error){if(!safeFallback||original.signal.aborted)throw error} + // Same backend, same authorization, one SDK session. Never replay writes. + return attempt(original.url,fallbackTimeout); + }; + } + window.CateriumCloudTransport=Object.freeze({create}); +})(); diff --git a/public/core/performance.js b/public/core/performance.js index a9161d3..52aa000 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; const VERSION='17.7.3'; - const RELEASE='20260917-calendar-compact'; + const RELEASE='20260918-login-recovery'; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; function installAuthBoot(){ diff --git a/public/index.html b/public/index.html index 2156b85..faee967 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 04bbdd6..0b76513 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ -const CACHE='sun-catering-pwa-v89-20260917-calendar-compact'; -const VERSION='20260917-calendar-compact'; +const CACHE='sun-catering-pwa-v90-20260918-login-recovery'; +const VERSION='20260918-login-recovery'; const CORE=[ - './','./index.html',`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`, + './','./index.html',`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`, `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, './offer-gallery/001.jpg','./offer-gallery/002.jpg', './catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg', @@ -9,6 +9,7 @@ const CORE=[ './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' ]; const CRITICAL_FRESH=new Set([ + '/core/cloud-transport.js', '/core/banquet-menu.js','/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js' ]); self.addEventListener('install',event=>{ diff --git a/tests/account-access.spec.mjs b/tests/account-access.spec.mjs index 9548680..ebe8f0a 100644 --- a/tests/account-access.spec.mjs +++ b/tests/account-access.spec.mjs @@ -18,6 +18,7 @@ test('workspace changes isolate client caches and ignore delayed responses from await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''})); await page.goto('/index.html'); 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 cloud=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); cloud=cloud.replace('async function boot(){','async function boot(){return;'); diff --git a/tests/login-recovery.spec.mjs b/tests/login-recovery.spec.mjs new file mode 100644 index 0000000..5e12b65 --- /dev/null +++ b/tests/login-recovery.spec.mjs @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import {test,expect} from '@playwright/test'; + +test('cloud reads and password login recover from empty proxy responses without replaying writes',async({page})=>{ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''})); + await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'}); + const result=await page.evaluate(async()=>{ + const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read'; + window.fetch=async request=>{calls.push({url:request.url,body:await request.text(),auth:request.headers.get('authorization')});if(scenario==='denied')return new Response('{"error":"denied"}',{status:401});if(scenario==='write')return new Response('',{status:503});return request.url.startsWith(proxy)?new Response('',{headers:{'content-type':'text/html'}}):new Response('[{"id":"company"}]',{headers:{'content-type':'application/json'}})}; + const send=window.CateriumCloudTransport.create({upstream,proxy}); + const read=await (await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',headers:{Authorization:'Bearer test-token'},body:'{}'})).json(); + const readCalls=calls.splice(0); + await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{"email":"test@example.invalid","password":"test"}'});const authCalls=calls.splice(0); + scenario='write';const write=await send(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'});const writeCalls=calls.splice(0); + scenario='empty-write';let writeError='';try{await send(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){writeError=e.message}const emptyWriteCalls=calls.splice(0); + scenario='denied';const denied=await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{}'});const deniedCalls=calls.splice(0); + return {read,readCalls,authCalls,write:write.status,writeCalls,writeError,emptyWriteCalls,denied:denied.status,deniedCalls}; + }); + expect(result.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://backend.example.invalid/rest/v1/rpc/sun_my_workspaces']); + expect(result.readCalls.every(c=>c.auth==='Bearer test-token'&&c.body==='{}')).toBe(true); + expect(result.authCalls).toHaveLength(2);expect(result.authCalls[0].body).toBe(result.authCalls[1].body); + expect(result.write).toBe(503);expect(result.writeCalls).toHaveLength(1);expect(result.writeError).toContain('пустой');expect(result.emptyWriteCalls).toHaveLength(1); + expect(result.denied).toBe(401);expect(result.deniedCalls).toHaveLength(1); +}); + +test('membership loading is shared and an invalid response remains an error until retry succeeds',async({page})=>{ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''}));await page.goto('/index.html'); + 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 cloud=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); + cloud=cloud.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testLoad:loadMemberships,testInit:c=>{client=c;session={user:{id:'test-user'}};config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},`); + await page.addScriptTag({content:'var orders=[];var boxes=[];'+cloud}); + const result=await page.evaluate(async()=>{ + let calls=0,resolve;const c=window.SunCloudV2;c.testInit({rpc:()=>{calls++;return new Promise(r=>resolve=r)}}); + const a=c.testLoad(),b=c.testLoad();resolve({data:'',error:null});await Promise.all([a,b]);const failed=c.status(); + const retry=c.testLoad();resolve({data:[{id:'company',name:'Test',role:'admin',is_active:true}],error:null});await retry; + return {calls,failed,ready:c.status()}; + }); + expect(result.calls).toBe(2);expect(result.failed.membershipsLoading).toBe(false);expect(result.failed.membershipError).toContain('список компаний');expect(result.failed.workspace).toBeNull(); + expect(result.ready.membershipError).toBe('');expect(result.ready.workspace.id).toBe('company');expect(result.ready.membershipsLoading).toBe(false); +}); + +test('a failed company load replaces the stale login form with an actionable retry screen',async({page})=>{ + await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort()); + await page.goto('/index.html',{waitUntil:'domcontentloaded'});await expect(page.locator('#sunGateEmailV3')).toBeVisible(); + await page.evaluate(()=>{ + window.loginState={connected:true,signedIn:true,membershipsLoading:true,membershipsLoaded:false};window.retryCount=0; + window.SunCloudV2={getSession:()=>({user:{id:'test',email:'test@example.invalid'}}),getWorkspace:()=>null,getClient:()=>null,status:()=>window.loginState,hasPermission:()=>false,reloadMemberships:async()=>{window.retryCount++;return null}}; + window.dispatchEvent(new Event('sun:cloud-permissions-changed')); + }); + await expect(page.locator('#sunGateEmailV3')).toHaveCount(0); + await page.evaluate(()=>{window.loginState={...window.loginState,membershipsLoading:false,membershipsLoaded:true,membershipError:'Ответ сервера не получен'};window.dispatchEvent(new Event('sun:cloud-permissions-changed'))}); + await expect(page.getByRole('heading',{name:'Не удалось загрузить рабочую базу'})).toBeVisible();await expect(page.locator('#sunGateErrorV3')).toHaveText('Ответ сервера не получен'); + await page.getByRole('button',{name:'Повторить загрузку',exact:true}).click();expect(await page.evaluate(()=>window.retryCount)).toBe(1); + await expect(page.getByRole('button',{name:'Повторить загрузку',exact:true})).toBeEnabled();await expect(page.locator('body > header')).toBeHidden(); +}); + +test('real SDK login opens the ordinary app when the proxy returns empty successful responses',async({page})=>{ + const userId='11111111-1111-4111-8111-111111111111',workspaceId='22222222-2222-4222-8222-222222222222',expires=Math.floor(Date.now()/1000)+3600; + const user={id:userId,aud:'authenticated',role:'authenticated',email:'test@example.invalid',email_confirmed_at:new Date().toISOString(),app_metadata:{provider:'email'},user_metadata:{}}; + const token=[{alg:'HS256',typ:'JWT'},{sub:userId,role:'authenticated',aud:'authenticated',exp:expires,iat:expires-3600,aal:'aal1'},'test'].map(x=>typeof x==='string'?x:Buffer.from(JSON.stringify(x)).toString('base64url')).join('.'); + const seen=[],warnings=[];page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())}); + const handle=async route=>{ + const request=route.request(),url=new URL(request.url());seen.push(url.host+url.pathname); + const headers={'access-control-allow-origin':'*'}; + if(request.method()==='OPTIONS')return route.fulfill({status:204,headers}); + if(url.host==='api.caterium.ru')return route.fulfill({status:200,contentType:'text/html',body:'',headers}); + let body=null; + if(url.pathname==='/auth/v1/token')body={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user}; + else if(url.pathname==='/auth/v1/user')body=user; + else if(url.pathname.endsWith('/sun_my_workspaces'))body=[{id:workspaceId,name:'Test workspace',role:'admin',is_active:true,permissions:{}}]; + else if(url.pathname.endsWith('/sun_is_platform_admin'))body=true; + return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body),headers}); + }; + await page.route('**://api.caterium.ru/**',handle);await page.route('**://*.supabase.co/**',handle); + await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.CateriumAuthSecurityV1774&&window.SunCloudV2?.getClient()); + await page.locator('#sunGateEmailV3').fill(user.email);await page.locator('#sunGatePasswordV3').fill('test-password');await page.locator('#sunGateSubmitV3').click(); + await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000});await expect(page.locator('body > header')).toBeVisible(); + expect(await page.evaluate(()=>window.SunCloudV2.getWorkspace()?.id)).toBe(workspaceId); + expect(seen.some(s=>s==='api.caterium.ru/rest/v1/rpc/sun_my_workspaces')).toBe(true); + expect(seen.some(s=>s.endsWith('.supabase.co/rest/v1/rpc/sun_my_workspaces'))).toBe(true); + expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 8a3f3dd..e1e4b5b 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['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'], + testMatch:['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'], 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}, diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 8ba47de..c6161f7 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent') check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v89-20260917-calendar-compact')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); -check(index.includes('20260917-calendar-compact')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); +check(sw.includes('v90-20260918-login-recovery')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); +check(index.includes('20260918-login-recovery')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); @@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30 check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.channel==='production','release manifest channel is production'); -check(String(releaseManifest.pwaCache||'').includes('v89-20260917-calendar-compact'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v90-20260918-login-recovery'),'release manifest points to current PWA cache'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); @@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(pkg.version==='17.7.3','package version is v17.7.3'); -check(index.includes('20260917-calendar-compact'),'index cache bust is v17.7.3'); -check(sw.includes('v89-20260917-calendar-compact')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); +check(index.includes('20260918-login-recovery'),'index cache bust is v17.7.3'); +check(sw.includes('v90-20260918-login-recovery')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index 9b93d08..781d47b 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('20260917-calendar-compact')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); -if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260917-calendar-compact')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); +if(!sw.includes('20260918-login-recovery')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); +if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-login-recovery')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');