diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 038051c..c5321f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -92,6 +92,9 @@ jobs: node tests/deep-checks.cjs node tests/project-checks.cjs node tests/cloud-sync.spec.cjs + node tests/atomic-restoration.cjs + BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs + BROWSER=${{ matrix.browser }} node tests/storage-multitab.cjs - name: Dossier reconversion, écoles, budget et compatibilité env: BROWSER: ${{ matrix.browser }} diff --git a/NOESIS_FINAL_MILE_STORAGE_20261024.md b/NOESIS_FINAL_MILE_STORAGE_20261024.md new file mode 100644 index 0000000..705975f --- /dev/null +++ b/NOESIS_FINAL_MILE_STORAGE_20261024.md @@ -0,0 +1,45 @@ +# NOESIS — multi-tab writes and failed restoration verification + +This branch is stacked on #26 and does not modify its source branch. + +Confirmed before correction: two instances of the actual atomic functions loaded the +same V3 baseline; the second overwrote the first without refusal. A second red test +showed that a readback failure after writing a replacement left the replacement in +persistent storage despite reporting failure. + +Corrections are confined to existing persistence and its safety notice: +- One native exclusive Web Lock grants a writer lease for a document lifetime. Other + tabs retain memory and export capability, but cannot commit imports, resets or edits. + A failed or unsupported lock never falls back to unsafe concurrent writes. +- Before writes, compare the stored canonical bytes with the loaded/confirmed baseline. + An external change blocks further writes rather than being adopted or overwritten. +- Manual/cloud restore establishes a confirmed source first. If replacement readback + fails, restore the previous exact bytes only when the exclusive writer still sees its + own replacement. If this rollback also fails or another value appears, block writes + and preserve canonical/legacy keys for diagnosis. No key is deleted. +- Invalid canonical progression/Daily Five and unparseable historical storage cannot + be overwritten by empty defaults. Canonical business sanitizers run strictly. +- Cloud snapshots are refused for unconfirmed or secondary-tab memory. + +Regression evidence: exact production functions in the VM; native Web Locks, real +localStorage, two pages, simultaneous edits, rejected import, exported unsaved work, +complete business/Daily Five comparison after closure, corrupted canonical bytes, quota +and readback failure in both required browser jobs. No Google or Supabase connection. +The local browser installation failed; GitHub CI on the exact head is required before +claiming browser success. Independent device and production states remain unverified. + +Limits: this is a cooperative lock, not protection from an XSS/hostile same-origin script +or an old version that ignores the protocol. Close old tabs before a reviewed upgrade. +A secondary tab requires export and reload after the writer closes; it never silently +promotes stale memory. Pagehide releases the lease; BFCache returns remain read-only. +A persistent storage failure cannot promise automatic rollback; the visible blocked +state is a recovery incident, not a success. Preserve all keys and independent exports. + +Rollback: retain V3 canonical bytes, both legacy mirrors and full export before any +approved code downgrade. Do not assume old code reads V3 or that mirrors are current +when a storage warning occurred. Recover in a separate origin/profile, compare every +business field, then decide. No migration, data reset or personal activation here. + +An additional red regression proves unsupported legacy topic IDs were silently dropped +before migration. Legacy business readers now refuse invalid/unknown data, retain original +bytes and block migration. Both browsers cover this historical preservation case. diff --git a/app.js b/app.js index 5a7810e..f2a7082 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,6 @@ (function(){ "use strict"; -var KEY="reconversion-control.state.v1", THEME="reconversion-control.theme", DAILY_KEY="reconversion-control.daily-five.v1"; +var KEY="reconversion-control.state.v1", THEME="reconversion-control.theme", DAILY_KEY="reconversion-control.daily-five.v1", ATOMIC_KEY="reconversion-control.snapshot.v3"; var $=function(s){return document.querySelector(s);}; var $$=function(s){return Array.from(document.querySelectorAll(s));}; var esc=function(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});}; @@ -157,21 +157,89 @@ var RC_CAREER_STAGES=[ var defaults={view:"cockpit",section:"all",search:"",status:"all",progress:{},review:{},labs:{},proofs:{},labChecks:{},career:RC_PROJECT_PLAN.empty()}; var labCheckMessage=Object.create(null); var storageUnavailable=false, storageFailures=Object.create(null), progressUnread=false, dailyUnread=false, detailOpener=null, detailId=null, libraryTabsScroll=0, detailRoutePushed=false, pendingRouteFocus=null; +var atomicUnread=false, storageWriter=false, storageWriterPending=true, storageConflict=false, atomicSnapshot=atomicRead(); var state=load(); var dailyState=dailyLoad(); var syncBridge=null; function storageStatus(key,failed){if(failed)storageFailures[key]=true;else delete storageFailures[key];storageUnavailable=Object.keys(storageFailures).length>0;} function storageGet(key){try{var value=localStorage.getItem(key);storageStatus(key,false);return value;}catch(e){storageStatus(key,true);if(key===KEY)progressUnread=true;if(key===DAILY_KEY)dailyUnread=true;return null;}} +/* NOESIS_ATOMIC_BEGIN: complete authoritative state under a single localStorage key. */ +var atomicBaseline; +function atomicRead(){ + try{ + var raw=localStorage.getItem(ATOMIC_KEY); + atomicBaseline=raw; + if(raw===null){storageStatus(ATOMIC_KEY,false);return null;} + var parsed=JSON.parse(raw); + if(!plainObject(parsed)||parsed.version!==3||!plainObject(parsed.main)||!plainObject(parsed.main.progress)||!plainObject(parsed.main.review)||!plainObject(parsed.daily)||!plainObject(parsed.daily.days))throw Error("Invalid atomic snapshot"); + storageStatus(ATOMIC_KEY,false);return parsed; + }catch(e){atomicUnread=true;storageStatus(ATOMIC_KEY,true);return null;} +} +function atomicCommit(nextMain,nextDaily){ + if(!storageWriter||storageConflict){storageStatus("writer",true);storageNotice();throw Error("Autre onglet ou écriture concurrente : enregistrement refusé");} + if(atomicUnread)throw Error("Instantané existant inaccessible : écriture refusée"); + if(progressUnread||dailyUnread)throw Error("Ancienne progression non lisible : migration suspendue"); + if(!plainObject(nextMain)||!plainObject(nextDaily)||!plainObject(nextDaily.days))throw Error("Instantané incomplet"); + var raw=JSON.stringify({version:3,main:nextMain,daily:nextDaily}),previous=atomicBaseline,wrote=false; + try{ + if(localStorage.getItem(ATOMIC_KEY)!==atomicBaseline){storageConflict=true;throw Error("Instantané modifié dans un autre contexte");} + localStorage.setItem(ATOMIC_KEY,raw); + wrote=true; + if(localStorage.getItem(ATOMIC_KEY)!==raw)throw Error("Relecture différente"); + atomicBaseline=raw; + atomicSnapshot=JSON.parse(raw); + storageStatus(ATOMIC_KEY,false);return true; + }catch(e){ + if(wrote){ + try{ + var current=localStorage.getItem(ATOMIC_KEY); + if(current===raw&&previous!==null){ + localStorage.setItem(ATOMIC_KEY,previous); + if(localStorage.getItem(ATOMIC_KEY)!==previous)throw Error("Retour non confirmé"); + }else if(current!==previous)storageConflict=true; + }catch(rollbackError){storageConflict=true;} + } + storageStatus(ATOMIC_KEY,true);storageNotice();throw Error("Instantané non confirmé : "+String(e&&e.message||"stockage")); + } +} +function mirrorLegacy(nextMain,nextDaily){ + // Backwards-compatibility only; never authoritative if the atomic key exists. + try{localStorage.setItem(KEY,JSON.stringify(nextMain));storageStatus(KEY,false);}catch(e){storageStatus(KEY,true);} + try{localStorage.setItem(DAILY_KEY,JSON.stringify(nextDaily));storageStatus(DAILY_KEY,false);}catch(e){storageStatus(DAILY_KEY,true);} + storageNotice(); +} +/* NOESIS_ATOMIC_END */ +// One writer for this document lifetime; stale tabs never acquire a write lease silently. +function acquireStorageWriter(){ + if(!navigator.locks||typeof navigator.locks.request!=="function"){ + storageWriterPending=false;storageStatus("writer",true);storageNotice();return; + } + navigator.locks.request("reconversion-control-writer-v3",{ifAvailable:true},function(lock){ + storageWriterPending=false;storageWriter=!!lock;storageStatus("writer",!lock);storageNotice(); + if(lock)return new Promise(function(release){ + try{atomicCommit(state,dailyState);mirrorLegacy(state,dailyState);}catch(e){storageNotice();} + window.addEventListener("pagehide",function(){storageWriter=false;storageStatus("writer",true);release();},{once:true}); + }); + }).catch(function(){storageWriterPending=false;storageWriter=false;storageStatus("writer",true);storageNotice();}); +} +window.addEventListener("storage",function(e){ + if(e.key===ATOMIC_KEY&&e.newValue!==atomicBaseline){storageConflict=true;storageStatus("writer",true);storageNotice();} +}); function storageSet(key,value,replace){ - if(!replace&&((key===KEY&&progressUnread)||(key===DAILY_KEY&&dailyUnread))){storageNotice();return;} - try{localStorage.setItem(key,value);if(key===KEY)progressUnread=false;if(key===DAILY_KEY)dailyUnread=false;storageStatus(key,false);}catch(e){storageStatus(key,true);}storageNotice(); + if(key===KEY||key===DAILY_KEY){ + if(!replace&&((key===KEY&&progressUnread)||(key===DAILY_KEY&&dailyUnread))){storageNotice();return false;} + try{atomicCommit(state,dailyState);}catch(e){storageNotice();return false;} + try{localStorage.setItem(key,value);storageStatus(key,false);}catch(e){storageStatus(key,true);} + progressUnread=dailyUnread=false;storageNotice();return true; + } + try{localStorage.setItem(key,value);storageStatus(key,false);return true;}catch(e){storageStatus(key,true);storageNotice();return false;} } function storageRemove(key){try{localStorage.removeItem(key);if(key===KEY)progressUnread=false;if(key===DAILY_KEY)dailyUnread=false;storageStatus(key,false);}catch(e){storageStatus(key,true);}storageNotice();} function storageNotice(){ var app=$("#app"), notice=$("#storage-notice");if(!app)return; if(!notice){notice=document.createElement("p");notice.id="storage-notice";notice.className="privacy";notice.setAttribute("role","status");app.parentNode.insertBefore(notice,app);} notice.hidden=!storageUnavailable; - notice.textContent=storageUnavailable?"Le stockage local est indisponible. Tes changements restent en mémoire jusqu’à la fermeture de cette page. Exporte ta progression dans Sources & privacy pour la conserver.":""; + notice.textContent=storageUnavailable?(storageConflict||(!storageWriter&&!storageWriterPending)?"Enregistrement suspendu : un autre onglet utilise la progression, le stockage a changé ou le verrou navigateur est indisponible. Tes changements restent en mémoire. Exporte-les avant fermeture, puis ferme les autres onglets et recharge.":"Le stockage local est indisponible. Tes changements restent en mémoire jusqu’à la fermeture de cette page. Exporte ta progression dans Sources & privacy pour la conserver."):""; } function plainObject(value){return !!value&&Object.prototype.toString.call(value)==="[object Object]";} function validDate(value){return typeof value==="string"&&/^\d{4}-\d{2}-\d{2}$/.test(value)&&!isNaN(Date.parse(value+"T12:00:00Z"))&&new Date(value+"T12:00:00Z").toISOString().slice(0,10)===value;} @@ -233,8 +301,8 @@ function dailySanitize(value,strict){ }); return out; } -function dailyLoad(){try{return dailySanitize(JSON.parse(storageGet(DAILY_KEY)||"null"),false);}catch(e){return{days:{}};}} -function dailySave(replace){storageSet(DAILY_KEY,JSON.stringify(dailyState),replace);if(syncBridge)syncBridge.changed();} +function dailyLoad(){try{var data=atomicSnapshot?atomicSnapshot.daily:JSON.parse(storageGet(DAILY_KEY)||"null");return data===null?{days:{}}:dailySanitize(data,true);}catch(e){dailyUnread=true;storageStatus(DAILY_KEY,true);return{days:{}};}} +function dailySave(replace){if(storageSet(DAILY_KEY,JSON.stringify(dailyState),replace)&&syncBridge)syncBridge.changed();} function dailyRecord(date){return dailyState.days[date]||{};} function ensureDailyRecord(date,selection){ if(!dailyState.days[date])dailyState.days[date]={}; @@ -284,16 +352,16 @@ async function checkLabEvidence(id,file){ function load(){ var initial=JSON.parse(JSON.stringify(defaults)); - try{var j=JSON.parse(storageGet(KEY)||"null");if(!plainObject(j))return initial; - var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},false);initial.progress=saved.progress;initial.review=saved.review; + try{var j=atomicSnapshot?atomicSnapshot.main:JSON.parse(storageGet(KEY)||"null");if(j===null)return initial;if(!plainObject(j)||(j.progress!==undefined&&!plainObject(j.progress))||(j.review!==undefined&&!plainObject(j.review)))throw Error("Invalid stored progression"); if(views.indexOf(j.view)>=0)initial.view=j.view; if(j.section==="all"||section(j.section))initial.section=j.section; if(typeof j.search==="string")initial.search=j.search; if(j.status==="all"||statuses.indexOf(j.status)>=0)initial.status=j.status; - initial.labs=labStateSanitize(j.labs,false);initial.proofs=proofStateSanitize(j.proofs,false);initial.labChecks=labChecksSanitize(j.labChecks,false);initial.career=RC_PROJECT_PLAN.sanitize(j.career,false); - }catch(e){}return initial; + var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},true);initial.progress=saved.progress;initial.review=saved.review; + initial.labs=labStateSanitize(j.labs,true);initial.proofs=proofStateSanitize(j.proofs,true);initial.labChecks=labChecksSanitize(j.labChecks,true);initial.career=RC_PROJECT_PLAN.sanitize(j.career,true); + }catch(e){progressUnread=true;storageStatus(KEY,true);}return initial; } -function save(replace){storageSet(KEY,JSON.stringify(state),replace);if(syncBridge)syncBridge.changed();} +function save(replace){if(storageSet(KEY,JSON.stringify(state),replace)&&syncBridge)syncBridge.changed();} function today(){var now=new Date();return now.getFullYear()+"-"+String(now.getMonth()+1).padStart(2,"0")+"-"+String(now.getDate()).padStart(2,"0");} function addDays(d,n){var x=new Date(d+"T12:00:00Z");x.setUTCDate(x.getUTCDate()+n);return x.toISOString().slice(0,10);} function daysBetween(a,b){return Math.round((Date.parse(a+"T12:00:00Z")-Date.parse(b+"T12:00:00Z"))/86400000);} @@ -594,13 +662,16 @@ function reviewRow(x){ } function cloudSnapshot(){ + if(!storageWriter||storageConflict||atomicUnread||storageUnavailable)throw Error("Progression locale non confirmée : synchronisation suspendue"); return {version:2,progress:state.progress,review:state.review,labs:state.labs,proofs:state.proofs,labChecks:state.labChecks,dailyFive:dailyState,career:state.career||RC_PROJECT_PLAN.empty()}; } function cloudRestore(o){ if(!plainObject(o)||o.version!==2)throw Error("Sauvegarde cloud incompatible"); var v=progression(o,true),labs=labStateSanitize(o.labs,true),proofs=proofStateSanitize(o.proofs,true),checks=labChecksSanitize(o.labChecks,true),daily=dailySanitize(o.dailyFive,true),career=o.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(o.career,true); - state.progress=v.progress;state.review=v.review;state.labs=labs;state.proofs=proofs;state.labChecks=checks; - state.career=career;dailyState=daily;dailySave(true);save(true);render(); + atomicCommit(state,dailyState); // Establish a confirmed source before any replacing restore. + var nextState={...state,progress:v.progress,review:v.review,labs:labs,proofs:proofs,labChecks:checks,career:career}; + atomicCommit(nextState,daily); // No live change or cloud acknowledgement until one verified commit. + state=nextState;dailyState=daily;mirrorLegacy(state,dailyState);render(); } function privacy(){ @@ -692,14 +763,14 @@ function bind(){ var st=$("#status"); if(st)st.onchange=function(){state.status=st.value;save();updateLibrary();}; if($("#export"))$("#export").onclick=exportState; if($("#import"))$("#import").onclick=function(){$("#importer").click();}; - if($("#reset"))$("#reset").onclick=function(){if(confirm("Réinitialiser toute la progression locale ?")){storageRemove(KEY);storageRemove(DAILY_KEY);state=JSON.parse(JSON.stringify(defaults));dailyState={days:{}};render();}}; + if($("#reset"))$("#reset").onclick=function(){if(confirm("Réinitialiser toute la progression locale ?")){var blank=JSON.parse(JSON.stringify(defaults)),empty={days:{}};try{atomicCommit(blank,empty);state=blank;dailyState=empty;storageRemove(KEY);storageRemove(DAILY_KEY);render();}catch(e){alert("Réinitialisation non confirmée : les données existantes sont conservées.");}}}; } function exportState(){ var blob=new Blob([JSON.stringify({version:2,exportedAt:new Date().toISOString(),progress:state.progress,review:state.review,labs:state.labs,proofs:state.proofs,labChecks:state.labChecks,dailyFive:dailyState,career:state.career},null,2)],{type:"application/json"}); var a=document.createElement("a"), url=URL.createObjectURL(blob);a.href=url;a.download="reconversion-control-progression.json";a.hidden=true;document.body.appendChild(a);a.click();a.remove();setTimeout(function(){URL.revokeObjectURL(url);},60000); } function readFile(file){return typeof file.text==="function"?file.text():new Promise(function(resolve,reject){var reader=new FileReader();reader.onload=function(){resolve(reader.result);};reader.onerror=function(){reject(reader.error);};reader.readAsText(file);});} -$("#importer").onchange=async function(e){var f=e.target.files&&e.target.files[0];if(!f)return;try{if(f.size>1024*1024)throw new Error("Too large");var j=JSON.parse(await readFile(f));if(!plainObject(j)||(j.version!==undefined&&j.version!==1&&j.version!==2))throw new Error("Invalid version");var imported=progression(j,true),importedLabs=labStateSanitize(j.labs,true),importedProofs=proofStateSanitize(j.proofs,true),importedChecks=labChecksSanitize(j.labChecks,true),importedDaily=j.dailyFive===undefined?null:dailySanitize(j.dailyFive,true);var importedCareer=j.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(j.career,true);state.career=importedCareer;state.progress=imported.progress;state.review=imported.review;state.labs=importedLabs;state.proofs=importedProofs;state.labChecks=importedChecks;if(importedDaily){dailyState=importedDaily;dailySave(true);}save(true);render();alert(storageUnavailable?"Progression importée en mémoire. Le stockage local reste indisponible.":"Progression importée.");}catch(err){alert("Fichier invalide. La progression actuelle est conservée.");}e.target.value="";}; +$("#importer").onchange=async function(e){var f=e.target.files&&e.target.files[0];if(!f)return;try{if(f.size>1024*1024)throw new Error("Too large");var j=JSON.parse(await readFile(f));if(!plainObject(j)||(j.version!==undefined&&j.version!==1&&j.version!==2))throw new Error("Invalid version");var imported=progression(j,true),importedLabs=labStateSanitize(j.labs,true),importedProofs=proofStateSanitize(j.proofs,true),importedChecks=labChecksSanitize(j.labChecks,true),importedDaily=j.dailyFive===undefined?null:dailySanitize(j.dailyFive,true);var importedCareer=j.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(j.career,true);atomicCommit(state,dailyState);var nextState={...state,career:importedCareer,progress:imported.progress,review:imported.review,labs:importedLabs,proofs:importedProofs,labChecks:importedChecks},nextDaily=importedDaily||dailyState;atomicCommit(nextState,nextDaily);state=nextState;dailyState=nextDaily;mirrorLegacy(state,dailyState);render();alert("Progression importée et instantané complet vérifié.");}catch(err){alert("Import refusé : fichier invalide ou stockage non confirmé. Vérifie la progression et la bannière de stockage.");}e.target.value="";}; function afterDetailClose(){document.body.classList.remove("dialog-open");$("#dlg").classList.remove("dialog-fallback");var focus=detailOpener&&detailOpener.isConnected?detailOpener:$("#course-chapters [data-open=\""+detailId+"\"]")||$("[data-open=\""+detailId+"\"]")||$("#nav [aria-current]");if(focus)focus.focus({preventScroll:true});detailOpener=null;detailRoutePushed=false;} function physicalCloseDetail(){var dlg=$("#dlg");if(typeof dlg.close==="function")dlg.close();else{dlg.removeAttribute("open");afterDetailClose();}} function closeDetail(fromRoute){ @@ -723,6 +794,7 @@ if("serviceWorker" in navigator){ navigator.serviceWorker.addEventListener("controllerchange",function(){if(hadController)showUpdateNotice();hadController=true;}); navigator.serviceWorker.register("./sw.js",{updateViaCache:"none"}).then(function(reg){if(reg.waiting&&hadController)showUpdateNotice();}).catch(function(){}); } +acquireStorageWriter(); syncBridge=RC_CLOUD_UI.attach({snapshot:cloudSnapshot,restore:cloudRestore,escape:esc}); applyRoute(); })(); diff --git a/docs/NOESIS_CONSOLIDATED_RESTORE_20261024.md b/docs/NOESIS_CONSOLIDATED_RESTORE_20261024.md new file mode 100644 index 0000000..5c258fc --- /dev/null +++ b/docs/NOESIS_CONSOLIDATED_RESTORE_20261024.md @@ -0,0 +1,110 @@ +# NOESIS — consolidation de la restauration locale + +Auteur : Bryan Fiarda. Relevé : 24 octobre 2026 (Europe/Paris). + +## Portée et filiation + +Cette branche part de main `21a4ad7a4f79d03ecd88d5bc97fb212472fb0c43` et +intègre ensemble les corrections de #26 `300f11f86566b4bd353da0d791fbfbc932a03216` +et #27 `706e204040676f9022eadb0db175e34d68961966`. #26 est ancêtre de #27. +Les deux lignées sont conservées dans les parents du commit consolidé ; aucune +branche source n'est réécrite. Ne pas fusionner #26 seule. + +Le code applicatif final est identique à #27. Le schéma canonique reste +`{version:3,main,daily}` sous `reconversion-control.snapshot.v3` ; imports et +exports métier conservent leurs formats. Le verrou exclusif coopératif, +la comparaison de base, le rollback vérifié et le refus de migration d'un +historique inconnu accompagnent obligatoirement l'instantané atomique. +Il n'y a pas de nouvelle fonctionnalité, de refonte ou d'activation Supabase. + +## Conservation du main et comparaison avec #30 + +Les neuf fichiers modifiés sur main par #28/#29 restent identiques à ce main : +README.md, course-operations.js, docs/content-refresh-20261024.md, +drive-learning.js, english.js, learning.js, project-plan.js, +tests/deep-checks.cjs et tests/learning.spec.cjs. + +La référence #30 est `9b887cc03c8244b4112eda5747033b90574df9ec`, arbre +`38b5b090eb022876c9009777d29fe08add15457d`. Elle n'est ni fusionnée ni utilisée +comme parent de cette branche. Les différences attendues sont exclusivement : + +- absence de sa note temporaire docs/NOESIS_CURRENT_MAIN_INTEGRATION_20261024.md ; +- présente note de consolidation et procédure de décision ; +- deux assertions supplémentaires du test navigateur atomique : aucune annonce + d'instantané vérifié après quota ou relecture perdue ; +- une assertion supplémentaire du contrôleur cloud : aucune annonce synchronisée + après restauration locale refusée. + +Tous les autres fichiers doivent être identiques à #30, notamment app.js, les +tests existants, le workflow de test et tous les ajouts de #28/#29. Les assertions +existantes sont conservées. Les nouvelles assertions renforcent la preuve du +refus de faux succès ; elles ne corrigent aucun nouveau comportement applicatif. + +## Acceptation sur le SHA consolidé exact + +La CI doit vérifier provenance, Chromium et WebKit sur la tête courante, sans +réutiliser le résultat historique de #30. Contrôles inclus : instantané et +migration V3, progression/Daily Five, quotas, relecture perdue/rollback, +idempotence, clés historiques inconnues, import malformé ou surdimensionné, +Web Locks natifs, onglet secondaire, écritures simultanées, mémoire exportable, +fermeture/réouverture, crypto et courses cloud simulées, cours et navigation. +Chromium exécute en plus le service worker réel ; l'étape WebKit correspondante +reste non applicable selon le workflow existant. Aucun test iPhone physique ou +service Supabase réel n'est revendiqué. + +Commandes locales indépendantes du navigateur : + +```bash +node --check app.js +node tests/atomic-restoration.cjs +node tests/deep-checks.cjs +node tests/project-checks.cjs +node tests/cloud-sync.spec.cjs +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_pages*.py' +``` + +La matrice navigateur doit utiliser l'image Playwright verrouillée de la CI, +sans changement de moteur ou assouplissement pour compenser un environnement +local indisponible. Les fixtures restent synthétiques. + +## Publication : condition bloquante distincte + +Le workflow `.github/workflows/pages.yml` écoute la fin réussie des tests sur un +push main. Il vérifie SHA et PR fusionnée puis appelle actions/deploy-pages. +Les contrôles de provenance empêchent une mauvaise provenance, pas une +publication qui n'a pas été autorisée humainement. + +L'environnement github-pages relu via API publique contient uniquement +`branch_policy`, aucun required_reviewers ni règle d'approbation personnalisée, +avec `can_admins_bypass:true`. La barrière d'approbation n'est donc pas présente. +main est déclaré non protégé et la liste des rulesets est vide ; le détail +administratif de branch protection n'est pas lisible par le connecteur. + +**BLOQUÉ : aucune fusion tant qu'une barrière effective et toutes les voies de +publication n'ont pas été vérifiées, ou qu'un GO de déploiement distinct et +précis n'a pas été accordé.** L'environnement, les permissions, secrets et +workflows de publication restent inchangés. La CI de PR ne doit rien publier. +Les webhooks et intégrations non lisibles ne sont pas réputés absents. + +## Retour arrière et récupération + +Avant fusion : conserver les SHA et branches sources ; laisser la PR ouverte +si une condition manque. Le main et les données restent inchangés. + +Après une future fusion autorisée : vérifier le commit main, sa CI et l'absence +de publication sans GO ; ne pas ajouter un commit direct sur main. Une +régression se traite par une nouvelle PR ciblée et validée, en gardant la +barrière de publication. Un revert global du code antérieur à V3 est dangereux +sans lecteur de récupération : les miroirs historiques peuvent être anciens. + +Un incident de données exige de conserver intégralement la clé canonique V3, +les deux miroirs, l'export complet et les éventuelles saisies seulement en +mémoire. Ne pas supprimer des clés, réinstaller, vider Safari ou remettre un +instantané vide. Récupérer dans un profil/origine isolé, comparer progression, +Daily Five, révisions, labs, preuves, contrôles et carrière, puis fermer et +rouvrir avant toute décision. Un retour de code ne restaure aucune donnée. + +Limites : verrou coopératif, anciennes pages pouvant ignorer le protocole, +stockage définitivement indisponible, absence de preuve iPhone physique. +Fermer les anciennes pages lors d'une future mise à jour approuvée, après copie +indépendante vérifiée. Aucun Supabase personnel ni donnée réelle dans cette PR. diff --git a/docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md b/docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md new file mode 100644 index 0000000..f1e7cc7 --- /dev/null +++ b/docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md @@ -0,0 +1,23 @@ +# NOESIS-RCC-001 — Restauration atomique (proposition technique) +Date : 23 octobre 2026 · Base `f3dddaf591b515eeee103538b33f199a1db955c5` · Aucune fusion ni publication autorisée. + +## Défaillance historique +La restauration cloud et l'import écrivaient `reconversion-control.daily-five.v1` et `reconversion-control.state.v1` séparément. Les exceptions de `storageSet()` étaient masquées. Une erreur de quota lors de la seconde écriture permettait un état mêlé au rechargement, avec acquittement possible par `cloud-ui.js`. + +## Changement proposé +- Le nouvel instantané local `reconversion-control.snapshot.v3` est un objet `{version:3,main,daily}`, écrit par **un seul** `localStorage.setItem`, puis relu intégralement avant accusé. +- Toutes les sauvegardes ordinaires migrent d'abord vers cet instantané atomique. Dès son existence, `load()` et `dailyLoad()` l'utilisent tous les deux, quel que soit l'état des anciennes clés. +- L'import JSON et `cloudRestore()` valident les données puis confirment cet instantané **avant** de remplacer l'état en mémoire ; si la lecture/écriture échoue, ils refusent l'accusé de succès. +- Les anciennes clés ne sont pas purgées pendant migration/restauration. Elles restent des miroirs de compatibilité **non autoritaires** ; leur écriture peut échouer sans invalider l'instantané déjà confirmé. Une application ancienne peut lire ces miroirs sans connaître leur statut : ne pas déployer deux versions concurrentes sur la même origine. +- Si un instantané V3 existe mais n'est pas lisible ou valide, aucune écriture ne le remplace ; une bannière indique le blocage pour éviter un effacement silencieux. +- La réinitialisation volontaire en interface n'efface les clés historiques qu'**après** validation du nouvel instantané vide. + +## Recette et réserves +`node tests/atomic-restoration.cjs` (appelé depuis la CI Chromium/WebKit) : erreurs de quota, relecture perdue, migration, état historique, reprise et idempotence. Les vérifications atomiques fonctionnent avec la **fonction réelle extraite de app.js** et `localStorage` synthétique à fautes injectées ; elles ne prouvent pas le comportement d'un iPhone physique. + +Autres contrôles : exécuter `node tests/cloud-ui-races.cjs`, `node tests/cloud-sync.spec.cjs`, l'import JSON et les tests du navigateur. Les résultats CI du SHA de la PR doivent être lus avant revue. Les erreurs de stockage multi-onglets et les conflits simultanés entre onglets ne sont **pas** résolus par cette PR : localStorage ne fournit aucun CAS inter-onglets. Prévoir une phase distincte avec verrou exclusif et retour utilisateur explicite plutôt qu'un dernier écrivain silencieux. + +Une restauration physique, une migration d'origine, l'activation Supabase et une publication nécessitent toujours des autorisations distinctes. Aucun jeton, donnée personnelle ou backend réel n'est touché par cette PR. + +## Repli sûr +Ne pas supprimer la clé V3. En cas de régression, conserver **toutes** les valeurs exportées avec une copie chiffrée hors de l'appareil et préparer un lecteur/migrateur de récupération avant rollback. Un simple retour de version qui ignorerait V3 peut présenter des clés historiques plus anciennes : il **ne constitue pas** un rollback des données. diff --git a/tests/atomic-restoration-browser.cjs b/tests/atomic-restoration-browser.cjs new file mode 100644 index 0000000..6db22d8 --- /dev/null +++ b/tests/atomic-restoration-browser.cjs @@ -0,0 +1,73 @@ +"use strict"; +const assert = require("node:assert/strict"); +const {chromium,webkit} = require("playwright"); +const {startServer} = require("./server.cjs"); +const B = {chromium,webkit}[process.env.BROWSER || "chromium"]; +if(!B)throw Error("Set BROWSER to chromium or webkit"); +const CANON = "reconversion-control.snapshot.v3", LEGACY = "reconversion-control.state.v1"; +const fixture = {version:2,progress:{dns:"mastered"},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}}}; +const buffer = {name:"synthetic-atomic-restore.json",mimeType:"application/json",buffer:Buffer.from(JSON.stringify(fixture))}; +async function run(){ + const server=await startServer(),browser=await B.launch({headless:true}); + const page=await browser.newPage(); + const dialogs=[]; page.on("dialog",async d=>{dialogs.push(d.message());await d.accept();}); + try { + await page.goto(server.url + "/?noesis=fixture"); + await page.locator('#nav [data-view="library"]').click(); + await page.locator('[data-card="dns"] [data-prog="progress"]').click(); + await page.locator('#nav [data-view="privacy"]').click(); + const before=await page.evaluate(key=>localStorage.getItem(key),CANON); + assert(before && JSON.parse(before).main.progress.dns==="progress","seeded V3 must exist"); + await page.evaluate(key=>{ + const original=Storage.prototype.setItem; + Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic quota","QuotaExceededError");return original.call(this,k,v);}; + window.noesisRestore=()=>{Storage.prototype.setItem=original;}; + },CANON); + await page.locator("#importer").setInputFiles(buffer); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/Import refusé/.test(x)),"quota must visibly refuse import"); + assert(!dialogs.some(x=>/instantané complet vérifié/.test(x)),"failed quota import must never announce a verified snapshot"); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),before,"failed commit cannot replace V3"); + await page.reload(); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"progress","old state must survive restart"); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).daily.days&&typeof JSON.parse(localStorage.getItem(k)).daily.days,CANON),"object"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" failed manual import does not acknowledge/quota erase V3, including reload"); + await page.locator('#nav [data-view="privacy"]').click(); + await page.locator("#importer").setInputFiles(buffer); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/instantané complet vérifié/.test(x)),"successful import must be acknowledged"); + await page.reload(); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"mastered"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" successful import survives restart"); + await page.locator('#nav [data-view="privacy"]').click(); + const rollbackBefore=await page.evaluate(k=>localStorage.getItem(k),CANON); + await page.evaluate(key=>{ + const set=Storage.prototype.setItem,get=Storage.prototype.getItem;let writes=0,fail=false; + Storage.prototype.setItem=function(k,v){const r=set.call(this,k,v);if(k===key&&++writes===2)fail=true;return r;}; + Storage.prototype.getItem=function(k){if(k===key&&fail){fail=false;throw Error("synthetic lost readback");}return get.call(this,k);}; + },CANON); + const rollbackDialogs=dialogs.length; + await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify({...fixture,progress:{dns:"learn"}}))}); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.slice(rollbackDialogs).some(x=>/Import refusé/.test(x)),"lost readback must refuse success"); + assert(!dialogs.slice(rollbackDialogs).some(x=>/instantané complet vérifié/.test(x)),"failed readback must never announce a verified snapshot"); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),rollbackBefore,"rollback restores every previous byte"); + await page.reload(); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),rollbackBefore,"confirmed rollback survives reload"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" lost readback restores complete prior state and rejects success"); + await page.locator('#nav [data-view="privacy"]').click(); + await page.evaluate(key=>{ + const original=Storage.prototype.setItem; + Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic legacy quota","QuotaExceededError");return original.call(this,k,v);}; + },LEGACY); + const beforeLegacyDialogs=dialogs.length; + const fixture2={...fixture,progress:{dns:"learn"}}; + await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify(fixture2))}); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.slice(beforeLegacyDialogs).some(x=>/instantané complet vérifié/.test(x)),"legacy mirror failure must not undermine canonical write"); + await page.reload(); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"learn"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" legacy mirror failure cannot split authoritative state"); + }finally{await browser.close();await server.close();} +} +run().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/atomic-restoration.cjs b/tests/atomic-restoration.cjs new file mode 100644 index 0000000..6c19167 --- /dev/null +++ b/tests/atomic-restoration.cjs @@ -0,0 +1,164 @@ +"use strict"; +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const source = fs.readFileSync(path.join(__dirname, "../app.js"), "utf8"); +const segment = source.match(/\/\* NOESIS_ATOMIC_BEGIN[\s\S]*?\/\* NOESIS_ATOMIC_END \*\//); +assert(segment, "The tests must exercise the exact atomic implementation from app.js, not a fictional restore adapter"); +const A = "reconversion-control.snapshot.v3", M = "reconversion-control.state.v1", D = "reconversion-control.daily-five.v1"; +const oldMain = {progress:{dns:"progress"},review:{},labs:{},proofs:{},labChecks:{},career:{marker:"old"}}; +const oldDaily = {days:{"2026-10-22":{completed:true}}}; +const nextMain = {...oldMain, progress:{dns:"mastered"},career:{marker:"new"}}; +const nextDaily = {days:{"2026-10-23":{completed:true}}}; +const serialize = v => JSON.stringify(v); +const document = (main,daily) => serialize({version:3,main,daily}); + +function harness(initial) { + const values = new Map(Object.entries(initial||{})); + const errors = {set:null,get:null,getAfterSet:null}; + const storage = { + getItem(key) { + if(errors.get===key){errors.get=null;throw Error("synthetic get failure");} + return values.has(key)?values.get(key):null; + }, + setItem(key,value) { + if(errors.set===key){errors.set=null;throw Object.assign(Error("QuotaExceededError"),{name:"QuotaExceededError"});} + values.set(key,String(value)); + if(errors.getAfterSet===key){errors.getAfterSet=null;errors.get=key;} + }, + removeItem(key){values.delete(key);} + }; + const faults = []; + const sandbox = {localStorage:storage,ATOMIC_KEY:A,KEY:M,DAILY_KEY:D, + atomicUnread:false,atomicSnapshot:null,progressUnread:false,dailyUnread:false,storageWriter:true,storageConflict:false, + plainObject:v=>v!==null&&typeof v==="object"&&!Array.isArray(v), + storageStatus:(key,failed)=>{if(failed)faults.push(key);},storageNotice:()=>{}}; + vm.createContext(sandbox); + vm.runInContext(segment[0]+"\nglobalThis.API = {atomicRead,atomicCommit,mirrorLegacy};",sandbox); + sandbox.atomicSnapshot=sandbox.API.atomicRead(); + return {values,errors,storage,sandbox,faults,...sandbox.API}; +} +let checks=0; +function test(title,fn){fn();checks++;console.log("PASS "+title);} +test("baseline: independent historical writes can create a split during quota error",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.storage.setItem(D,serialize(nextDaily)); + h.errors.set=M;assert.throws(()=>h.storage.setItem(M,serialize(nextMain)),/QuotaExceededError/); + assert.equal(JSON.parse(h.values.get(M)).progress.dns,"progress"); + assert.equal(JSON.parse(h.values.get(D)).days["2026-10-23"].completed,true); +}); +test("atomic commit: quota before write preserves the prior complete state",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + const read=JSON.parse(h.values.get(A)); + assert.equal(read.main.progress.dns,"progress"); + assert.ok(read.daily.days["2026-10-22"]); +}); +test("atomic commit: failed readback restores the entire prior confirmed snapshot",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.getAfterSet=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + const recovered=JSON.parse(h.values.get(A)); + assert.equal(recovered.main.progress.dns,"progress"); + assert.deepEqual(recovered.daily,oldDaily); +}); +test("first migration: quota preserves both original legacy keys",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily)); + assert.equal(h.values.get(A),undefined); + assert.equal(JSON.parse(h.values.get(M)).progress.dns,"progress"); + assert.ok(JSON.parse(h.values.get(D)).days["2026-10-22"]); +}); +test("complete commit and read-after-reload yield one coherent revision",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + assert.equal(h.atomicRead(),null); + h.atomicCommit(nextMain,nextDaily); + const reboot=harness(Object.fromEntries(h.values)); + const recovered=reboot.atomicRead(); + assert.equal(recovered.version,3); + assert.equal(recovered.main.progress.dns,"mastered"); + assert.equal(recovered.main.career.marker,"new"); + assert.ok(recovered.daily.days["2026-10-23"]); +}); +test("legacy mirrors may partially fail but are never authoritative",()=>{ + const h=harness({[A]:document(oldMain,oldDaily),[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.atomicCommit(nextMain,nextDaily); + h.errors.set=D;h.mirrorLegacy(nextMain,nextDaily); + assert.equal(JSON.parse(h.values.get(M)).progress.dns,"mastered"); + assert.ok(JSON.parse(h.values.get(D)).days["2026-10-22"]); + const reboot=harness(Object.fromEntries(h.values)); + assert.equal(reboot.atomicRead().main.progress.dns,"mastered"); + assert.ok(reboot.atomicRead().daily.days["2026-10-23"]); +}); +test("unread legacy Daily Five blocks first migration without erasing history",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.sandbox.dailyUnread=true; + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Ancienne progression non lisible/); + assert.equal(h.values.get(A),undefined); + assert.equal(h.values.get(D),serialize(oldDaily)); +}); +test("unread legacy progression blocks first migration",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.sandbox.progressUnread=true; + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Ancienne progression non lisible/); + assert.equal(h.values.get(M),serialize(oldMain)); +}); +test("malformed authoritative snapshot blocks overwriting unknown data",()=>{ + const h=harness({[A]:'{"version":3,"main":{}}',[M]:serialize(oldMain)}); + assert.equal(h.atomicRead(),null);assert.equal(h.sandbox.atomicUnread,true); + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/inaccessible/); + assert.equal(h.values.get(A),'{"version":3,"main":{}}'); +}); +test("both primary payloads are required",()=>{ + const h=harness({}); + assert.throws(()=>h.atomicCommit(nextMain,{}),/incomplet/); + assert.equal(h.values.size,0); +}); +test("stale tab refuses a sequential lost update without overwriting either value",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.values.set(A,document(nextMain,nextDaily)); + assert.throws(()=>h.atomicCommit({...oldMain,progress:{linux:"progress"}},oldDaily),/Instantané non confirmé/); + assert.equal(h.values.get(A),document(nextMain,nextDaily)); + assert.equal(h.sandbox.storageConflict,true); +}); +test("read-only tab cannot commit, including first migration and restoration",()=>{ + for(const initial of [{},{[A]:document(oldMain,oldDaily)}]){ + const h=harness(initial);h.sandbox.storageWriter=false; + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Autre onglet/); + assert.deepEqual(Object.fromEntries(h.values),initial); + } +}); +test("preflight read failure preserves the previous authoritative bytes",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.get=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + assert.equal(h.values.get(A),document(oldMain,oldDaily)); +}); +test("historical unknown progression blocks migration instead of dropping its bytes",()=>{ + const loadFunction=source.match(/function load\(\)\{[\s\S]*?\n\}/)[0]; + const progressFunction=source.match(/function progression\(data,strict\)\{[\s\S]*?\n\}/)[0]; + const context=vm.createContext({defaults:{progress:{},review:{}},atomicSnapshot:null, + KEY:M,progressUnread:false,storageGet:()=>serialize({view:"review",progress:{retiredTopic:"mastered"},review:{}}), + storageStatus(){},plainObject:v=>v&&typeof v==="object"&&!Array.isArray(v), + topic:()=>null,statuses:["learn","progress","mastered"],views:["review"],section:()=>null, + labStateSanitize:()=>({}),proofStateSanitize:()=>({}),labChecksSanitize:()=>({}),RC_PROJECT_PLAN:{sanitize:()=>({})}}); + vm.runInContext(progressFunction+loadFunction+';globalThis.loaded=load();',context); + assert.equal(context.loaded.view,'review','safe navigation metadata must remain usable while business migration is blocked'); + assert.equal(context.progressUnread,true,"unsupported historical progression must stay unread and non-migratable"); +}); +test("idempotent retry preserves one complete revision and no extra key",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily)); + h.atomicCommit(nextMain,nextDaily);const a=h.values.get(A); + h.atomicCommit(nextMain,nextDaily); + assert.equal(h.values.get(A),a);assert.equal(h.values.size,1); +}); +test("cloud restore and manual import commit before mutating live state",()=>{ + const cloud=source.match(/function cloudRestore\(o\)\{[\s\S]*?\n\}/); + assert(cloud); + assert(cloud[0].indexOf("atomicCommit(nextState,daily)") { const f = fixture(); await f.prepare("automatic"); f.remote("other-device"); f.failRestore(); await f.tick(); assert.equal(f.local, "initial-fixture"); assert.match(f.html, /progression locale conservée/); + assert.doesNotMatch(f.html, /À jour sur cet appareil et dans le cloud/, "a refused restoration must never acknowledge synchronization success"); f.edit("local-after-failed-restore"); await f.tick(); assert.equal(f.local, "local-after-failed-restore"); assert.match(f.html, /Conflit détecté/); }); diff --git a/tests/mobile.spec.cjs b/tests/mobile.spec.cjs index 9032b1a..928a26e 100644 --- a/tests/mobile.spec.cjs +++ b/tests/mobile.spec.cjs @@ -5,6 +5,7 @@ const {chromium, webkit, devices} = require("playwright"); const {startServer} = require("./server.cjs"); const KEY = "reconversion-control.state.v1"; const DAILY_KEY = "reconversion-control.daily-five.v1"; +const ATOMIC_KEY = "reconversion-control.snapshot.v3"; const browserName = process.env.BROWSER || "chromium"; const browserType = {chromium, webkit}[browserName]; assert(browserType, "BROWSER must be chromium or webkit"); @@ -120,7 +121,7 @@ async function main() { passed("five-minute daily quest completes three playful rounds, persists XP and updates the cockpit"); const tamperedDaily = await open(); - await tamperedDaily.page.evaluate(({key,value})=>localStorage.setItem(key,JSON.stringify(value)),{key:DAILY_KEY,value:{days:{"2026-10-04":{completed:true,xp:200}}}}); + await tamperedDaily.page.evaluate(({key,value})=>{const snapshot=JSON.parse(localStorage.getItem(key));snapshot.daily=value;localStorage.setItem(key,JSON.stringify(snapshot));},{key:ATOMIC_KEY,value:{days:{"2026-10-04":{completed:true,xp:200}}}}); await tamperedDaily.page.reload(); await nav(tamperedDaily.page,"daily"); assert.equal(await tamperedDaily.page.locator(".daily-finish.won").count(),0,"impossible local Daily Five completion must be discarded"); @@ -136,7 +137,7 @@ async function main() { assert.equal(dueRecord.sessionId,"d01");assert.equal(dueRecord.reason,"Révision due"); const adaptiveError = await open({view:"daily",section:"all",search:"",status:"all",progress:{},review:{}}); - await adaptiveError.page.evaluate(key=>{const d=new Date();d.setDate(d.getDate()-1);const date=d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");localStorage.setItem(key,JSON.stringify({days:{[date]:{sessionId:"d03",reason:"Rotation",quizChoice:0,quizCorrect:false}}}));},DAILY_KEY); + await adaptiveError.page.evaluate(key=>{const d=new Date();d.setDate(d.getDate()-1);const date=d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");const snapshot=JSON.parse(localStorage.getItem(key));snapshot.daily={days:{[date]:{sessionId:"d03",reason:"Rotation",quizChoice:0,quizCorrect:false}}};localStorage.setItem(key,JSON.stringify(snapshot));},ATOMIC_KEY); await adaptiveError.page.reload(); assert.equal(await adaptiveError.page.locator(".daily-title-card h2").textContent(),"AuthN ou AuthZ ?"); assert.match(await adaptiveError.page.locator(".daily-game-head .src").textContent(),/ERREUR RÉCENTE/); diff --git a/tests/storage-multitab.cjs b/tests/storage-multitab.cjs new file mode 100644 index 0000000..4ba4817 --- /dev/null +++ b/tests/storage-multitab.cjs @@ -0,0 +1,75 @@ +"use strict"; +const assert = require("node:assert/strict"); +const {chromium,webkit} = require("playwright"); +const {startServer} = require("./server.cjs"); +const B = {chromium,webkit}[process.env.BROWSER || "chromium"]; +const A="reconversion-control.snapshot.v3"; +const fixture={version:2,progress:{dns:"progress"},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{"2026-10-23":{missionDone:true}}}}; +const file={name:"synthetic-multitab.json",mimeType:"application/json",buffer:Buffer.from(JSON.stringify(fixture))}; +const snapshot=page=>page.evaluate(k=>localStorage.getItem(k),A); +async function nav(page,view){await page.locator('#nav [data-view="'+view+'"]').click();} +async function writer(page){await page.waitForFunction(async()=>{const q=await navigator.locks.query();return q.held.some(l=>l.name==="reconversion-control-writer-v3");});} +async function run(){ + const server=await startServer(),browser=await B.launch({headless:true}); + const context=await browser.newContext({serviceWorkers:"block",acceptDownloads:true}); + const first=await context.newPage(),second=await context.newPage(),dialogs=[]; + second.on("dialog",async d=>{dialogs.push(d.message());await d.accept();}); + first.on("dialog",async d=>{await d.accept();}); + try{ + await first.goto(server.url);await writer(first);await nav(first,"privacy"); + await first.locator("#importer").setInputFiles(file); + await first.waitForFunction(()=>document.querySelector("#importer").value===""); + const before=await snapshot(first);assert.equal(JSON.parse(before).main.progress.dns,"progress"); + assert.deepEqual(JSON.parse(before).daily,fixture.dailyFive); + await second.goto(server.url);await nav(second,"privacy"); + await second.locator("#storage-notice").waitFor({state:"visible"}); + assert.equal(await snapshot(second),before,"opening a second tab must not rewrite the source"); + const replacement={...fixture,progress:{dns:"mastered"},dailyFive:{days:{}}}; + await second.locator("#importer").setInputFiles({...file,buffer:Buffer.from(JSON.stringify(replacement))}); + await second.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/Import refusé/.test(x)),"secondary-tab restoration must refuse before commit"); + assert.equal(await snapshot(second),before,"entire main and Daily Five remain unchanged"); + await nav(first,"library");await nav(second,"library"); + await Promise.all([ + first.locator('[data-card="dns"] [data-prog="mastered"]').click(), + second.locator('[data-card="dns"] [data-prog="learn"]').click() + ]); + const committed=JSON.parse(await snapshot(first));assert.equal(committed.main.progress.dns,"mastered"); + assert.deepEqual(committed.daily,fixture.dailyFive); + const downloadPromise=second.waitForEvent("download");await nav(second,"privacy");await second.locator("#export").click(); + const download=await downloadPromise;const stream=await download.createReadStream(),chunks=[];for await(const c of stream)chunks.push(c); + const memory=JSON.parse(Buffer.concat(chunks).toString());assert.equal(memory.progress.dns,"learn","refused memory changes remain exportable"); + assert.equal(JSON.parse(await snapshot(first)).main.progress.dns,"mastered"); + await first.close();await second.close(); + const reopened=await context.newPage();await reopened.goto(server.url);await writer(reopened); + const after=JSON.parse(await snapshot(reopened)); + // Navigation can differ; every durable business field must be structurally identical. + for(const key of ["progress","review","labs","proofs","labChecks","career"])assert.deepEqual(after.main[key],committed.main[key],key); + assert.deepEqual(after.daily,committed.daily);await reopened.close(); + console.log("PASS multi-tab: exclusive native Web Lock, rejected restore, concurrent actions, exportable unsaved work and full business/Daily Five persistence after closure"); + for(const corrupt of [ + {version:3,main:{progress:"corrupt",review:{}},daily:{days:{}}}, + {version:3,main:{progress:{dns:"invalid"},review:{}},daily:{days:{}}}, + {version:3,main:{progress:{},review:{}},daily:{days:{"2026-10-23":{completed:true}}}} + ]){ + const c=await browser.newContext({serviceWorkers:"block"}),p=await c.newPage(); + const raw=JSON.stringify(corrupt);await c.addInitScript(({key,raw})=>localStorage.setItem(key,raw),{key:A,raw}); + await p.goto(server.url);await writer(p);await nav(p,"library"); + await p.locator('[data-card="dns"] [data-prog="mastered"]').click(); + assert.equal(await snapshot(p),raw,"corrupt authoritative bytes must never be overwritten by defaults"); + await p.locator("#storage-notice").waitFor({state:"visible"});await c.close(); + } + console.log("PASS corrupted canonical data: malformed progression, invalid status and incomplete Daily Five preserve every original byte"); + + const legacyContext=await browser.newContext({serviceWorkers:"block"}),legacyPage=await legacyContext.newPage(); + const legacyKey="reconversion-control.state.v1",legacyRaw=JSON.stringify({progress:{retiredTopic:"mastered"},review:{}}); + await legacyContext.addInitScript(({key,raw})=>localStorage.setItem(key,raw),{key:legacyKey,raw:legacyRaw}); + await legacyPage.goto(server.url);await writer(legacyPage);await nav(legacyPage,"library"); + await legacyPage.locator('[data-card="dns"] [data-prog="mastered"]').click(); + assert.equal(await legacyPage.evaluate(k=>localStorage.getItem(k),legacyKey),legacyRaw,"unsupported historical bytes remain untouched"); + assert.equal(await snapshot(legacyPage),null,"no authoritative replacement may hide unsupported history"); + await legacyPage.locator("#storage-notice").waitFor({state:"visible"});await legacyContext.close(); + console.log("PASS historical unsupported progression: original bytes preserved, no default-valued migration"); + }finally{await context.close();await browser.close();await server.close();} +} +run().catch(e=>{console.error(e);process.exitCode=1;});