import fs from 'node:fs'; import {test,expect} from '@playwright/test'; async function delayedSessionApp(page,signedIn=true){ await page.route('https://**',r=>r.abort()); await page.addInitScript(signed=>{ localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:signed?'startup-company':'',localWorkspaceId:signed?'startup-company':'',tenantStorageReady:true,autoSync:false})); if(signed)localStorage.setItem('sb-startup-auth-token','stored-session-placeholder-without-credentials'); localStorage.setItem('sunEnterpriseSettingsV1',JSON.stringify({rolesEnabled:true})); localStorage.setItem('sunUsersV1',JSON.stringify([{id:'legacy-user',name:'Old local user',role:'owner',active:true,pinHash:'old-hash'}])); window.supabase={createClient:()=>({ auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:()=>new Promise(resolve=>{window.finishSessionRestore=resolve}),getUser:async()=>({data:{user:null}})}, rpc:async name=>({data:name==='sun_my_workspaces'?[{id:'startup-company',name:'Test company',role:'admin',is_active:true}]:null}), channel:()=>({on(){return this},subscribe(){return this}}),removeChannel:()=>{} })}; window.loginFormSeen=false;window.legacyFormSeen=false;new MutationObserver(()=>{if(document.getElementById('sunGateEmailV3'))window.loginFormSeen=true;if(document.getElementById('sunLoginOverlay'))window.legacyFormSeen=true}).observe(document,{childList:true,subtree:true}); },signedIn); await page.goto('/index.html',{waitUntil:'domcontentloaded'}); await page.waitForFunction(()=>Boolean(window.finishSessionRestore)); } test('restoring an existing session never mounts a password form before opening the app',async({page})=>{ await delayedSessionApp(page); await expect(page.locator('#sunCloudAuthGateV3')).toHaveAttribute('data-auth-state','restoring'); await expect(page.locator('#sunGateEmailV3')).toHaveCount(0);await expect(page.locator('body > header')).toBeHidden(); await page.evaluate(()=>finishSessionRestore({data:{session:{user:{id:'startup-user',email:'test@example.invalid'}}},error:null})); await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0);await expect(page.locator('body > header')).toBeVisible(); expect(await page.evaluate(()=>window.loginFormSeen)).toBe(false); expect(await page.evaluate(()=>window.legacyFormSeen)).toBe(false); }); test('the single current login is styled even when its decorative script is unavailable',async({page})=>{ await page.route('**/core/login-signature-v1776.js*',r=>r.abort()); await delayedSessionApp(page,false); await page.evaluate(()=>finishSessionRestore({data:{session:null},error:null})); await expect(page.locator('#sunGateEmailV3')).toBeVisible(); await expect(page.locator('#sunGateTitleV3')).toHaveText('Войти в рабочее пространство'); await expect(page.locator('#sunCloudAuthGateV3 .caterium-signature-brand')).toHaveCount(1); await expect(page.locator('#sunCloudAuthGateV3 .sun-cloud-auth-card')).toHaveCSS('background-color','rgba(0, 0, 0, 0)'); await expect(page.locator('#sunGateEmailV3')).toHaveCSS('border-radius','13px'); await expect(page.locator('#sunLoginOverlay,#cateriumAuthBootV1776')).toHaveCount(0); await page.locator('#sunGateSwitchV27').click();await expect(page.locator('#sunGateTitleV3')).toHaveText('Создать аккаунт Caterium'); await page.locator('#sunGateSwitchV27').click();await expect(page.locator('#sunGateTitleV3')).toHaveText('Войти в рабочее пространство'); }); test('slow connections use a healthy route, allow longer saves and preserve caller cancellation',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 upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[]; const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}}); window.fetch=(request,{signal})=>{calls.push(request.url);return request.url.startsWith(proxy)?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):Promise.resolve(ok())}; const send=CateriumCloudTransport.create({upstream,proxy,timeout:10,fallbackTimeout:100,writeTimeout:100}); await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}'}); await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'}); const routes=calls.splice(0); window.fetch=(request,{signal})=>{calls.push(request.url);return new Promise((resolve,reject)=>{const timer=setTimeout(()=>resolve(ok()),35);signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason)},{once:true})})}; const slowSave=CateriumCloudTransport.create({upstream,proxy,timeout:10,writeTimeout:100}); const saved=(await slowSave(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})).status;const saveCalls=calls.splice(0); const timeoutSend=CateriumCloudTransport.create({upstream,proxy,writeTimeout:5});let timeoutName=''; try{await timeoutSend(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){timeoutName=e.name}const timeoutCalls=calls.splice(0); const controller=new AbortController();controller.abort(new DOMException('Account changed','AbortError'));let cancel=''; try{await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}',signal:controller.signal})}catch(e){cancel=e.message} return {routes,saved,saveCalls,timeoutName,timeoutCalls,cancel,cancelCalls:calls}; }); expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','backend.example.invalid','backend.example.invalid']); expect(result.saved).toBe(200);expect(result.saveCalls).toHaveLength(1);expect(result.timeoutName).toBe('TimeoutError');expect(result.timeoutCalls).toHaveLength(1); expect(result.cancel).toBe('Account changed');expect(result.cancelCalls).toHaveLength(0); }); async function syncHarness(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 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=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`); await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); } test('an uncertain save is recovered by reading its committed revision without a duplicate write',async({page})=>{ await syncHarness(page);await page.clock.install(); await page.evaluate(async()=>{ window.calls=[];window.toasts=[];window.SunEnterprise={toast:message=>window.toasts.push(message)}; const payload={format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[]}}};let row={payload,revision:1}; localStorage.setItem('sunOrders',JSON.stringify([{id:'test-order',total:2200}])); SunCloudV2.testInit({rpc:async(name,args)=>{calls.push(name);if(name==='sun_fetch_app_state')return {data:[structuredClone(row)]};if(name==='sun_save_app_state_v17'){row={payload:structuredClone(args.p_payload),revision:2};return {error:{message:'TimeoutError: server response lost'}}}throw new Error(name)}}); await SunCloudV2.testBaseline({payload,revision:1});await SunCloudV2.syncNow(); }); expect(await page.evaluate(()=>SunCloudV2.status().lastStatus)).toBe('pending'); await page.clock.fastForward(2100); await expect.poll(()=>page.evaluate(()=>SunCloudV2.status().lastStatus)).toBe('ready'); const result=await page.evaluate(()=>({calls,toasts,orders:JSON.parse(localStorage.sunOrders)})); expect(result.calls.filter(x=>x==='sun_fetch_app_state')).toHaveLength(2);expect(result.calls.filter(x=>x==='sun_save_app_state_v17')).toHaveLength(1); expect(result.toasts).toEqual([]);expect(result.orders).toEqual([{id:'test-order',total:2200}]); }); test('a late sync response cannot apply the previous account data after sign-out',async({page})=>{ await syncHarness(page); const result=await page.evaluate(async()=>{ window.toasts=[];window.SunEnterprise={toast:message=>toasts.push(message)};let resolve; SunCloudV2.testInit({rpc:()=>new Promise(r=>resolve=r)}); const pending=SunCloudV2.syncNow();SunCloudV2.testLeave();resolve({data:[{revision:2,payload:{storage:{sunOrders:{t:'j',v:[{id:'previous-user'}]}}}}]});await pending; return {orders:localStorage.getItem('sunOrders'),toasts,workspace:SunCloudV2.getWorkspace()}; }); expect(result.orders).toBeNull();expect(result.toasts).toEqual([]);expect(result.workspace).toBeNull(); }); 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,cooldown:0}); 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); });