186 lines
18 KiB
JavaScript
186 lines
18 KiB
JavaScript
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:'<html><body></body></html>'}));
|
||
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:'<html><body></body></html>'}));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:'<!doctype html><html><body></body></html>'}));
|
||
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:'<!doctype html><html><body></body></html>'}));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);
|
||
});
|