caterium-app/tests/login-recovery.spec.mjs
2026-09-18 18:13:00 +03:00

215 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs';
import {test,expect} from '@playwright/test';
test('Safari without streaming uploads can send login and binary bodies through fallback',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 NativeRequest=window.Request;
window.Request=new Proxy(NativeRequest,{construct(Target,args){if(args[1]?.body instanceof ReadableStream)throw new TypeError('ReadableStream uploading is not supported');return new Target(...args)}});
const calls=[];
window.fetch=async request=>{calls.push({url:request.url,body:[...new Uint8Array(await request.arrayBuffer())],type:request.headers.get('content-type')});return request.url.includes('proxy.example')?new Response('',{status:503}):new Response('{}',{headers:{'content-type':'application/json'}})};
const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',cooldown:0});
const body=JSON.stringify({email:'test@example.invalid',password:'test-password'});
await send('https://backend.example/auth/v1/token?grant_type=password',{method:'POST',headers:{'content-type':'application/json'},body});
await send('https://backend.example/storage/v1/object/test/image',{method:'POST',headers:{'content-type':'image/png'},body:new Uint8Array([0,255,137,80]).buffer});
return {calls,expected:[...new TextEncoder().encode(body)]};
});
expect(result.calls).toHaveLength(3);
expect(result.calls[0].body).toEqual(result.expected);expect(result.calls[1].body).toEqual(result.expected);
expect(result.calls[2].body).toEqual([0,255,137,80]);expect(result.calls[2].type).toBe('image/png');
});
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={testUploadDataUrl:uploadDataUrl,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('cloud photos use raw bytes rather than multipart consumed by the PHP proxy',async({page})=>{
await syncHarness(page);
const result=await page.evaluate(async()=>{
let uploaded;
SunCloudV2.testInit({storage:{from:()=>({upload:async(path,body,options)=>{uploaded={path,raw:body instanceof ArrayBuffer,bytes:[...new Uint8Array(body)],options};return {error:null}}})}});
await SunCloudV2.testUploadDataUrl('data:image/png;base64,AP+JUA==');return uploaded;
});
expect(result.raw).toBe(true);expect(result.bytes).toEqual([0,255,137,80]);expect(result.options.contentType).toBe('image/png');expect(result.path).toMatch(/^company\/media\//);
});
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);
});